Compare commits
172 Commits
v4.0.0-bet
...
v4.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43a66fae82 | ||
|
|
ed739e1292 | ||
|
|
212c7eed46 | ||
|
|
ce50042407 | ||
|
|
e17e36f9e4 | ||
|
|
0bc570d741 | ||
|
|
7465c74392 | ||
|
|
a8a91109ee | ||
|
|
add60c2552 | ||
|
|
4dc8826beb | ||
|
|
d35bbdb257 | ||
|
|
8636c3310d | ||
|
|
eddd9419a4 | ||
|
|
2d08314982 | ||
|
|
28d69b0c60 | ||
|
|
7f76400550 | ||
|
|
f551b348a7 | ||
|
|
b1567f4d8d | ||
|
|
d18c94a587 | ||
|
|
f75c142981 | ||
|
|
0959281a01 | ||
|
|
96a0a512f3 | ||
|
|
9899abfe6a | ||
|
|
d02386488c | ||
|
|
5f25bc480c | ||
|
|
07b5003c6f | ||
|
|
8aba5db862 | ||
|
|
b3ee25079e | ||
|
|
85179fd07b | ||
|
|
390360cc4e | ||
|
|
7edd5b9731 | ||
|
|
c681a50b19 | ||
|
|
8df2391a77 | ||
|
|
0982e56ed0 | ||
|
|
0cb995bb35 | ||
|
|
d34d58bf33 | ||
|
|
c19ff41392 | ||
|
|
abb6e56570 | ||
|
|
a7bba81969 | ||
|
|
6356a75478 | ||
|
|
61ef5f9a02 | ||
|
|
2fbf8926c4 | ||
|
|
ce9e3fcb35 | ||
|
|
ffab6d5ec9 | ||
|
|
be9bcaeb3d | ||
|
|
d140e0418f | ||
|
|
6685cb9e21 | ||
|
|
2f24cff33c | ||
|
|
193a6cce3f | ||
|
|
17951504f0 | ||
|
|
ccffe70359 | ||
|
|
503d6cecd0 | ||
|
|
6fbe06d262 | ||
|
|
2394f3fbe5 | ||
|
|
cb815f3c8e | ||
|
|
e6f6c02f90 | ||
|
|
565e66715f | ||
|
|
549de1355a | ||
|
|
d892ed48cc | ||
|
|
b96204654b | ||
|
|
3c75c2bb60 | ||
|
|
db020576ed | ||
|
|
5a93da3177 | ||
|
|
d16285d239 | ||
|
|
0410d834d1 | ||
|
|
b79abf52fd | ||
|
|
6a61057813 | ||
|
|
1a75d49041 | ||
|
|
c054731794 | ||
|
|
a824bd5da3 | ||
|
|
e1ff31718e | ||
|
|
797b5582ac | ||
|
|
452904398f | ||
|
|
517f95fa01 | ||
|
|
d5ee9cd007 | ||
|
|
5918253022 | ||
|
|
2f30dbf645 | ||
|
|
88deb5fc47 | ||
|
|
27c7e86297 | ||
|
|
051f12c712 | ||
|
|
f9ab83ed80 | ||
|
|
c24bfe82e0 | ||
|
|
4f27de3252 | ||
|
|
bf86d17d56 | ||
|
|
54e23a7c84 | ||
|
|
6049e2df17 | ||
|
|
025af41616 | ||
|
|
d80f93a59d | ||
|
|
5aa4b28d53 | ||
|
|
6a072dd33a | ||
|
|
67995085c6 | ||
|
|
9295a9d8fb | ||
|
|
43cd24927f | ||
|
|
6b41644557 | ||
|
|
dceb58cd79 | ||
|
|
570187fc1f | ||
|
|
ca4663e3a4 | ||
|
|
6e097d5cec | ||
|
|
2c95231fd7 | ||
|
|
0d80156eec | ||
|
|
08c709c2ec | ||
|
|
bb28b2f0fb | ||
|
|
0e4313de52 | ||
|
|
e76e29df24 | ||
|
|
d1062ce0c4 | ||
|
|
9ddf3745b8 | ||
|
|
0eefc7b592 | ||
|
|
bea035025e | ||
|
|
33aa7b72b7 | ||
|
|
ca54d23f92 | ||
|
|
7d6244a322 | ||
|
|
f61a74bbee | ||
|
|
7d258908c6 | ||
|
|
0e53356589 | ||
|
|
aa43dcdb2b | ||
|
|
093c48505a | ||
|
|
c19ef632aa | ||
|
|
bb1455ec71 | ||
|
|
09af43c4a7 | ||
|
|
35594ecb00 | ||
|
|
3044c77309 | ||
|
|
0632044573 | ||
|
|
e6551e9774 | ||
|
|
11d09b2f09 | ||
|
|
8676b846c2 | ||
|
|
d4f98aa7f7 | ||
|
|
35458e846c | ||
|
|
49e9133fed | ||
|
|
5dd76674ff | ||
|
|
8bdd5ff662 | ||
|
|
9e68b36de6 | ||
|
|
e78bfb2e9a | ||
|
|
f5d4ca85d8 | ||
|
|
c65b637bc4 | ||
|
|
062efe349f | ||
|
|
696f46d64b | ||
|
|
42b88d9a32 | ||
|
|
3a840fcc5d | ||
|
|
a333abcaa5 | ||
|
|
d583f6f273 | ||
|
|
a1aeb1d614 | ||
|
|
bd13b78e21 | ||
|
|
4d44be76ac | ||
|
|
8cd758cfb6 | ||
|
|
9a1077d192 | ||
|
|
bc4f174e22 | ||
|
|
046595825c | ||
|
|
e0c5eabdd5 | ||
|
|
06e9d192ef | ||
|
|
906c8fc767 | ||
|
|
6149e00c2f | ||
|
|
b053770998 | ||
|
|
45b380ca00 | ||
|
|
631dad11d6 | ||
|
|
9647c37285 | ||
|
|
9e5f749556 | ||
|
|
c69795c0cd | ||
|
|
339f5537c6 | ||
|
|
d2c5be5bd5 | ||
|
|
21b753f9b5 | ||
|
|
a28a6ea127 | ||
|
|
38da3e9fef | ||
|
|
f1aad2545e | ||
|
|
2ed446be80 | ||
|
|
caf0070d14 | ||
|
|
c9b28785fb | ||
|
|
135af39fd4 | ||
|
|
8a2676701b | ||
|
|
e97fd7da5b | ||
|
|
3477a5540a | ||
|
|
490c8f38d8 | ||
|
|
5c5c08ae39 |
85
CHANGELOG.md
@@ -1,5 +1,90 @@
|
||||
# Change Log
|
||||
|
||||
## UNRELEASED
|
||||
|
||||
### Added
|
||||
|
||||
- MatterMost alert destination. @alon710
|
||||
- Full screen view on map visualizations. @deecay
|
||||
- Choropleth map visualization 🗺. @kravets-levko
|
||||
- Report Celery queue size. @arikfr
|
||||
- Load dashboard refresh rate from URL. @arikfr
|
||||
- Configuration for query refresh intervals. @arikfr
|
||||
|
||||
### Changed
|
||||
|
||||
- TreasureData: improve query failure message. @toru-takahashi
|
||||
- Update botocore version (fixes an issue with loading Athena tables). @arikfr
|
||||
- Changed Map visualization name to "Map (Markers)" to distinguish from the Choropleth one. @arikfr
|
||||
- Use MongoClient for ReplicaSet connections. @fmy
|
||||
- Update pymongo version to support newer MongoDB versions. @arikfr
|
||||
- Changed "his" to "their" in user creation form success message. @tnetennba3
|
||||
- Show friendly names in dynamic forms labels. @arikfr
|
||||
- Render safe HTML by default in tables to remain backward compatible. @arikfr
|
||||
- Apply time limit to alert status checking task. @arikfr
|
||||
- Plotly: increase Y value accuracy. @arikfr
|
||||
- close metadata database connection early in the execute query Celery task. @arikfr
|
||||
|
||||
### Fixed
|
||||
|
||||
- Query page layout gets messed up when clicking on "cancel" in "Do you want to leave this page?" dialog. @kravets-levko
|
||||
- docker-entrypoint broke for other database names than "postgres". @valentin2105
|
||||
- (BigQuery) UDF URI was used even if empty. @arikfr
|
||||
- Show correct Box Plot chart hover data. @deeccay
|
||||
- Fork button shows in data only view, but not working. @arikfr
|
||||
- Saving widget sends too much data to the server, sometimes making dashboard save fail. @arikfr
|
||||
- DynamoDB: always return counter as a number rather than string. @arikfr
|
||||
- MSSQL: UUID fields were detected as booleans. @arikfr
|
||||
- The whole dashboard page reloads when clicking on refresh. @arikfr
|
||||
- Line chart with category x-axis: when some values missing, wrong hints displayed on hover. @kravets-levko
|
||||
- Second Y-axis not displayed when stacking enabled. @kravets-levko
|
||||
- Widget with empty contents had extra 40px of white space (paddings of container). @kravets-levko
|
||||
- Add scrollbars to pivot table widgets. @kravets-levko
|
||||
- Multiple performance, usability and auto-height related fixes to the dashboard rendering engine (also switched to GridStack). @kravets-levko
|
||||
- Login form missing on LDAP logging page. @idalin
|
||||
- Empty state: show connect data source link only to admins. @arikfr
|
||||
- Dashboard "dancing" widgets (when auto-height enabled). @kravets-levko
|
||||
|
||||
### Other
|
||||
|
||||
- Webpack: ignore vim swap files. @deecay
|
||||
|
||||
## v4.0.0-rc.1 - 2018-03-05
|
||||
|
||||
### Added
|
||||
|
||||
- Configuration for query refresh intervals.
|
||||
- [Prometheus] Support for range queries. @jubel-han
|
||||
- Extensions system based on Python entrypoints. @jezdez
|
||||
- Funnel visualization. @tonyjiangh
|
||||
- UI to edit allowed Google OAuth domains. @arikfr
|
||||
- Empty state for homepage, alerts, queries and dashboards pages. @kocsmy, @arikfr
|
||||
|
||||
### Changed
|
||||
|
||||
- Maintain widget's auto-height state until it's been resized by the user. @kravets-levko
|
||||
- Change default table viz width from 4 to 3 columns. @kravets-levko
|
||||
- When saving dashboard adding or removing widgets, save only modified widgets (with changed size and/or position). @kravets-levko
|
||||
- Don't allow disabling Password based login if no SSO is enabled. @arikfr
|
||||
- Always show login page, even if password based login disabled. @arikfr
|
||||
- Upgrade `sqlparse` to 0.2.4. @ariarijp
|
||||
- Make sure datetime/number columns in table visualization don't wrap. @kravets-levko
|
||||
- Explicitly set order of tabs in settings page. @kravets-levko
|
||||
- User can no longer change the type of a saved visualization. @kravets-levko
|
||||
- Update docker-compose.yml to restart postgres/redis containers `unless-stopped`. @benmanns
|
||||
- New default colors for chart visualizations. @kocsmy
|
||||
- Updated design of all the authentication pages (login, forgot password, etc). @kravets-levko
|
||||
|
||||
### Fixed
|
||||
|
||||
- Glue schemas with more than 100 tables were showing only first 100 tables. @jezdez
|
||||
- Long visualizations dind't render scrollbars on some browsers. @kravets-levko
|
||||
- When the dataset was returning some columns name as non strings, table couldn't be rendered. @kravets-levko
|
||||
- Missing logos for Prometheus and Snowflake. @kocsmy
|
||||
- Render correct link to LDAP login on login page. @arikfr
|
||||
- Sort widgets by column/row to make sure they are placed correctly. @arikfr
|
||||
- Public dashboards were not rendered due to Javascript error. @kravets-levko
|
||||
|
||||
## v4.0.0-beta - 2018-02-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -60,7 +60,7 @@ If you would like to suggest an enhancement or ask for a new feature:
|
||||
|
||||
### Documentation
|
||||
|
||||
The project's documentation can be found at [https://redash.io/help/](https://redash.io/help/). The [documentation sources](https://github.com/getredash/website/tree/master/user-guide) are hosted on GitHub. To contribute edits / new pages, you can use GitHub's interface. Click the "Edit on GitHub" link on the documentation page to quickly open the edit interface.
|
||||
The project's documentation can be found at [https://redash.io/help/](https://redash.io/help/). The [documentation sources](https://github.com/getredash/website/tree/master/website/_kb) are hosted on GitHub. To contribute edits / new pages, you can use GitHub's interface. Click the "Edit on GitHub" link on the documentation page to quickly open the edit interface.
|
||||
|
||||
## Additional Notes
|
||||
|
||||
|
||||
@@ -23,10 +23,6 @@ server() {
|
||||
}
|
||||
|
||||
create_db() {
|
||||
while ! bash -c "echo > /dev/tcp/postgres/5432" &> /dev/null ; do
|
||||
echo "Waiting for PostgreSQL container to become available."
|
||||
sleep 5
|
||||
done
|
||||
exec /app/manage.py database create_tables
|
||||
}
|
||||
|
||||
@@ -72,6 +68,7 @@ case "$1" in
|
||||
scheduler
|
||||
;;
|
||||
dev_server)
|
||||
export FLASK_DEBUG=1
|
||||
exec /app/manage.py runserver --debugger --reload -h 0.0.0.0
|
||||
;;
|
||||
shell)
|
||||
@@ -96,3 +93,4 @@ case "$1" in
|
||||
exec "$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -18,8 +18,12 @@ test:
|
||||
override:
|
||||
- pytest --junitxml=$CIRCLE_TEST_REPORTS/junit.xml tests/
|
||||
deployment:
|
||||
github_and_docker:
|
||||
tarball:
|
||||
branch: [master, /release.*/]
|
||||
commands:
|
||||
- bin/pack
|
||||
docker:
|
||||
tag: /v[0-9]+(\.[0-9\-a-z]+)*/
|
||||
commands:
|
||||
- bin/pack
|
||||
- docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS
|
||||
|
||||
BIN
client/app/assets/images/db-logos/Cassandra.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
client/app/assets/images/db-logos/prometheus.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
client/app/assets/images/db-logos/scylla.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
client/app/assets/images/db-logos/snowflake.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
client/app/assets/images/destinations/mattermost.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 18 KiB |
1
client/app/assets/images/google_logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"/><path clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"/><path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"/><path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"/></svg>
|
||||
|
After Width: | Height: | Size: 688 B |
@@ -72,3 +72,17 @@ strong {
|
||||
position: relative;
|
||||
|
||||
}
|
||||
|
||||
.resize-vertical {
|
||||
resize: vertical !important;
|
||||
transition: height 0s !important;
|
||||
}
|
||||
.resize-horizontal {
|
||||
resize: horizontal !important;
|
||||
transition: width 0s !important;
|
||||
}
|
||||
.resize-both,
|
||||
.resize-vertical.resize-horizontal {
|
||||
resize: both !important;
|
||||
transition: height 0s, width 0s !important;
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
.contacts {
|
||||
&:not(.c-profile) {
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
& > [class*="col-"] {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.c-item {
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 2px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.ci-avatar {
|
||||
display: block;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ci-avatar {
|
||||
margin: -1px -1px 0;
|
||||
}
|
||||
|
||||
.c-info {
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
padding: 0 5px;
|
||||
|
||||
strong {
|
||||
color: #000;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #999;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
strong,
|
||||
small {
|
||||
.text-overflow();
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.c-footer {
|
||||
border-top: 1px solid #e2e2e2;
|
||||
margin-top: 18px;
|
||||
|
||||
& > button {
|
||||
padding: 4px 10px 3px;
|
||||
color: #333;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
border: 0;
|
||||
|
||||
& > i {
|
||||
font-size: 16px;
|
||||
vertical-align: middle;
|
||||
margin-top: -3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
.gridster .preview-holder {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
.gridster li .heading {
|
||||
border: #ddd;
|
||||
background-color: #f5f5f5;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
li.widget {
|
||||
/*background-color:grey;*/
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: grey;
|
||||
opacity: 0.7;
|
||||
cursor: move;
|
||||
&:hover {
|
||||
opacity: 1.0 !important;
|
||||
|
||||
-webkit-transition: opacity .6s;
|
||||
-moz-transition: opacity .6s;
|
||||
-o-transition: opacity .6s;
|
||||
-ms-transition: opacity .6s;
|
||||
transition: opacity .6s;
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ div.table-name {
|
||||
|
||||
&:hover {
|
||||
background: fade(@redash-gray, 10%);
|
||||
|
||||
|
||||
.copy-to-editor {
|
||||
display: flex;
|
||||
}
|
||||
@@ -75,7 +75,6 @@ div.table-name {
|
||||
padding: 0;
|
||||
|
||||
.form-control {
|
||||
height: 30px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
.todo-lists {
|
||||
padding: 15px 0 0 0;
|
||||
position: relative;
|
||||
|
||||
.list-group-item {
|
||||
margin: 0;
|
||||
min-height: 36px;
|
||||
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid #E2EBFF;
|
||||
}
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
background: #E2EBFF;
|
||||
left: 50px;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.input-helper:before {
|
||||
background: lighten(@red, 10%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tl-item {
|
||||
padding-left: 47px !important;
|
||||
|
||||
input:checked + .input-helper + span {
|
||||
text-decoration: line-through;
|
||||
color: #b5b5b5;
|
||||
}
|
||||
}
|
||||
|
||||
.todo-footer {
|
||||
padding: 10px;
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid #E2EBFF;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background: #fff;
|
||||
}
|
||||
@@ -1,3 +1,37 @@
|
||||
.map-visualization-container {
|
||||
height: 500px;
|
||||
|
||||
> div:first-child {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.map-custom-control.leaflet-bar {
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
margin: 10px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
&.top-left {
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&.top-right {
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&.bottom-left {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&.bottom-right {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
@import '~ui-select/dist/select.css';
|
||||
@import '~angular-toastr/src/toastr';
|
||||
@import '~angular-resizable/src/angular-resizable.css';
|
||||
@import '~angular-gridster/src/angular-gridster';
|
||||
@import '~pace-progress/themes/blue/pace-theme-minimal.css';
|
||||
@import '~material-design-iconic-font/dist/css/material-design-iconic-font.css';
|
||||
|
||||
@@ -51,7 +50,6 @@
|
||||
@import 'inc/navbar';
|
||||
@import 'inc/edit-in-place';
|
||||
@import 'inc/growl';
|
||||
@import 'inc/gridster';
|
||||
@import 'inc/flex';
|
||||
@import 'inc/ace-editor';
|
||||
@import 'inc/overlay';
|
||||
|
||||
@@ -230,6 +230,7 @@ edit-in-place p.editable:hover {
|
||||
.widget-wrapper {
|
||||
.body-container {
|
||||
filters {
|
||||
display: block;
|
||||
padding-left: 15px;
|
||||
}
|
||||
}
|
||||
@@ -301,6 +302,15 @@ edit-in-place p.editable:hover {
|
||||
flex-direction: column;
|
||||
padding-bottom: 0;
|
||||
padding-top: 0 !important;
|
||||
position: relative;
|
||||
|
||||
schema-browser {
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 0;
|
||||
right: 15px;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
main {
|
||||
|
||||
@@ -35,6 +35,20 @@ body {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.callout {
|
||||
padding: 20px;
|
||||
border: 1px solid #eee;
|
||||
border-left-width: 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.callout-warning {
|
||||
border-left-color: #aa6708;
|
||||
}
|
||||
|
||||
.callout-info {
|
||||
border-left-color: #1b809e;
|
||||
}
|
||||
|
||||
// Fixed width layout for specific pages
|
||||
@media (min-width: 768px) {
|
||||
|
||||
75
client/app/assets/less/server.less
Normal file
@@ -0,0 +1,75 @@
|
||||
/** LESS Plugins **/
|
||||
@import 'inc/less-plugins/for';
|
||||
|
||||
/** Load Main Bootstrap LESS files **/
|
||||
@import '~bootstrap/less/bootstrap';
|
||||
@import '~material-design-iconic-font/dist/css/material-design-iconic-font.css';
|
||||
|
||||
@import 'inc/variables';
|
||||
@import 'inc/mixins';
|
||||
@import 'inc/font';
|
||||
@import 'inc/print';
|
||||
|
||||
@import 'inc/bootstrap-overrides';
|
||||
@import 'inc/base';
|
||||
@import 'inc/generics';
|
||||
@import 'inc/form';
|
||||
@import 'inc/button';
|
||||
@import 'inc/404';
|
||||
@import 'inc/ie-warning';
|
||||
@import 'inc/flex';
|
||||
|
||||
@import 'redash/redash-newstyle';
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #F6F8F9;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top-width: 2px;
|
||||
margin: 25px 0;
|
||||
}
|
||||
|
||||
.tiled {
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-top: -100px;
|
||||
|
||||
img {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.fixed-width-page {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px 0;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 25px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
height: 25px;
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,9 @@ const layouts = {
|
||||
function selectLayout(route) {
|
||||
let layout = layouts.default;
|
||||
if (route.layout) {
|
||||
layout = layouts[route.layout];
|
||||
layout = layouts[route.layout] || layouts.default;
|
||||
} else if (!route.authenticated) {
|
||||
layout = layout.defaultSignedOut;
|
||||
layout = layouts.defaultSignedOut;
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
@@ -52,9 +52,7 @@ class AppViewComponent {
|
||||
// For routes that need authentication, check if session is already
|
||||
// loaded, and load it if not.
|
||||
logger('Requested authenticated route: ', route);
|
||||
if (Auth.isAuthenticated()) {
|
||||
this.applyLayout($$route);
|
||||
} else {
|
||||
if (!Auth.isAuthenticated()) {
|
||||
event.preventDefault();
|
||||
// Auth.requireSession resolves only if session loaded
|
||||
Auth.requireSession().then(() => {
|
||||
@@ -62,12 +60,17 @@ class AppViewComponent {
|
||||
$route.reload();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.applyLayout(route.$$route);
|
||||
}
|
||||
});
|
||||
|
||||
$rootScope.$on('$routeChangeSuccess', (event, route) => {
|
||||
const $$route = route.$$route || { authenticated: true };
|
||||
this.applyLayout($$route);
|
||||
});
|
||||
|
||||
$rootScope.$on('$routeChangeError', (event, current, previous, rejection) => {
|
||||
const $$route = current.$$route || { authenticated: true };
|
||||
this.applyLayout($$route);
|
||||
throw new PromiseRejectionError(rejection);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
</p>
|
||||
|
||||
<div ng-show="$ctrl.isTextBox">
|
||||
<div class="form-group">
|
||||
<textarea class="form-control" ng-model="$ctrl.text" ng-model-options="{ debounce: 200 }" rows="3" autofocus></textarea>
|
||||
<div class="form-group m-b-0">
|
||||
<textarea class="form-control resize-vertical" style="min-height: 100px"
|
||||
ng-model="$ctrl.text" ng-model-options="{ debounce: 200 }" rows="5" autofocus></textarea>
|
||||
</div>
|
||||
<div ng-show="$ctrl.text">
|
||||
<div ng-show="$ctrl.text" class="m-t-15">
|
||||
<strong>Preview:</strong>
|
||||
<p ng-bind-html="$ctrl.text | markdown"></p>
|
||||
</div>
|
||||
@@ -61,10 +62,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="$ctrl.isTextBox">
|
||||
<label><input type="checkbox" ng-model="$ctrl.isHidden"> Hidden</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -16,7 +16,6 @@ const AddWidgetDialog = {
|
||||
|
||||
// Textbox
|
||||
this.text = '';
|
||||
this.isHidden = false;
|
||||
|
||||
// Visualization
|
||||
this.selectedQuery = null;
|
||||
@@ -84,7 +83,7 @@ const AddWidgetDialog = {
|
||||
visualization_id: selectedVis && selectedVis.id,
|
||||
dashboard_id: this.dashboard.id,
|
||||
options: {
|
||||
isHidden: this.isTextBox && this.isHidden,
|
||||
isHidden: false,
|
||||
position: {},
|
||||
},
|
||||
visualization: selectedVis,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<textarea class="form-control" ng-model="$ctrl.widget.new_text" rows="3"></textarea>
|
||||
<textarea class="form-control" style="resize: vertical" ng-model="$ctrl.widget.new_text" rows="3"></textarea>
|
||||
</div>
|
||||
<div ng-show="$ctrl.widget.new_text">
|
||||
<strong>Preview:</strong>
|
||||
|
||||
87
client/app/components/dashboards/gridstack/gridstack.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import $ from 'jquery';
|
||||
import _ from 'underscore';
|
||||
import 'jquery-ui/ui/widgets/draggable';
|
||||
import 'jquery-ui/ui/widgets/droppable';
|
||||
import 'jquery-ui/ui/widgets/resizable';
|
||||
import 'gridstack/dist/gridstack.css';
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import gridstack from 'gridstack';
|
||||
|
||||
function sequence(...fns) {
|
||||
fns = _.filter(fns, _.isFunction);
|
||||
if (fns.length > 0) {
|
||||
return function sequenceWrapper(...args) {
|
||||
for (let i = 0; i < fns.length; i += 1) {
|
||||
fns[i].apply(this, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
return _.noop;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
function JQueryUIGridStackDragDropPlugin(grid) {
|
||||
gridstack.GridStackDragDropPlugin.call(this, grid);
|
||||
}
|
||||
|
||||
gridstack.GridStackDragDropPlugin.registerPlugin(JQueryUIGridStackDragDropPlugin);
|
||||
|
||||
JQueryUIGridStackDragDropPlugin.prototype = Object.create(gridstack.GridStackDragDropPlugin.prototype);
|
||||
JQueryUIGridStackDragDropPlugin.prototype.constructor = JQueryUIGridStackDragDropPlugin;
|
||||
|
||||
JQueryUIGridStackDragDropPlugin.prototype.resizable = function resizable(el, opts, key, value) {
|
||||
el = $(el);
|
||||
if (opts === 'disable' || opts === 'enable') {
|
||||
el.resizable(opts);
|
||||
} else if (opts === 'option') {
|
||||
el.resizable(opts, key, value);
|
||||
} else {
|
||||
el.resizable(_.extend({}, this.grid.opts.resizable, {
|
||||
// run user-defined callback before internal one
|
||||
start: sequence(this.grid.opts.resizable.start, opts.start),
|
||||
// this and next - run user-defined callback after internal one
|
||||
stop: sequence(opts.stop, this.grid.opts.resizable.stop),
|
||||
resize: sequence(opts.resize, this.grid.opts.resizable.resize),
|
||||
}));
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
JQueryUIGridStackDragDropPlugin.prototype.draggable = function draggable(el, opts) {
|
||||
el = $(el);
|
||||
if (opts === 'disable' || opts === 'enable') {
|
||||
el.draggable(opts);
|
||||
} else {
|
||||
el.draggable(_.extend({}, this.grid.opts.draggable, {
|
||||
containment: this.grid.opts.isNested ? this.grid.container.parent() : null,
|
||||
// run user-defined callback before internal one
|
||||
start: sequence(this.grid.opts.draggable.start, opts.start),
|
||||
// this and next - run user-defined callback after internal one
|
||||
stop: sequence(opts.stop, this.grid.opts.draggable.stop),
|
||||
drag: sequence(opts.drag, this.grid.opts.draggable.drag),
|
||||
}));
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
JQueryUIGridStackDragDropPlugin.prototype.droppable = function droppable(el, opts) {
|
||||
el = $(el);
|
||||
if (opts === 'disable' || opts === 'enable') {
|
||||
el.droppable(opts);
|
||||
} else {
|
||||
el.droppable({
|
||||
accept: opts.accept,
|
||||
});
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
JQueryUIGridStackDragDropPlugin.prototype.isDroppable = function isDroppable(el) {
|
||||
return Boolean($(el).data('droppable'));
|
||||
};
|
||||
|
||||
JQueryUIGridStackDragDropPlugin.prototype.on = function on(el, eventName, callback) {
|
||||
$(el).on(eventName, callback);
|
||||
return this;
|
||||
};
|
||||
55
client/app/components/dashboards/gridstack/gridstack.less
Normal file
@@ -0,0 +1,55 @@
|
||||
.grid-stack {
|
||||
// Same options as in JS
|
||||
@gridstack-margin: 15px;
|
||||
@gridstack-width: 6;
|
||||
|
||||
margin-right: -@gridstack-margin;
|
||||
|
||||
.gridstack-columns(@column, @total) when (@column > 0) {
|
||||
@value: 100% * (@column / @total);
|
||||
> .grid-stack-item[data-gs-min-width="@{column}"] { min-width: @value }
|
||||
> .grid-stack-item[data-gs-max-width="@{column}"] { max-width: @value }
|
||||
> .grid-stack-item[data-gs-width="@{column}"] { width: @value }
|
||||
> .grid-stack-item[data-gs-x="@{column}"] { left: @value }
|
||||
|
||||
.gridstack-columns((@column - 1), @total); // next iteration
|
||||
}
|
||||
|
||||
.gridstack-columns(@gridstack-width, @gridstack-width);
|
||||
|
||||
.grid-stack-item {
|
||||
.grid-stack-item-content {
|
||||
overflow: visible !important;
|
||||
box-shadow: none !important;
|
||||
opacity: 1 !important;
|
||||
left: 0 !important;
|
||||
right: @gridstack-margin !important;
|
||||
}
|
||||
|
||||
.ui-resizable-handle {
|
||||
background: none !important;
|
||||
|
||||
&.ui-resizable-w,
|
||||
&.ui-resizable-sw {
|
||||
left: 0 !important;
|
||||
}
|
||||
|
||||
&.ui-resizable-e,
|
||||
&.ui-resizable-se {
|
||||
right: @gridstack-margin !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.grid-stack-placeholder > .placeholder-content {
|
||||
border: 0;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 3px;
|
||||
left: 0 !important;
|
||||
right: @gridstack-margin !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.grid-stack-one-column-mode > .grid-stack-item {
|
||||
margin-bottom: @gridstack-margin !important;
|
||||
}
|
||||
}
|
||||
444
client/app/components/dashboards/gridstack/index.js
Normal file
@@ -0,0 +1,444 @@
|
||||
import $ from 'jquery';
|
||||
import _ from 'underscore';
|
||||
import './gridstack';
|
||||
import './gridstack.less';
|
||||
|
||||
function toggleAutoHeightClass($element, isEnabled) {
|
||||
const className = 'widget-auto-height-enabled';
|
||||
if (isEnabled) {
|
||||
$element.addClass(className);
|
||||
} else {
|
||||
$element.removeClass(className);
|
||||
}
|
||||
}
|
||||
|
||||
function computeAutoHeight($element, grid, node, minHeight, maxHeight) {
|
||||
const wrapper = $element[0];
|
||||
const element = wrapper.querySelector('.scrollbox, .spinner-container');
|
||||
|
||||
let resultHeight = _.isObject(node) ? node.height : 1;
|
||||
if (element) {
|
||||
const childrenBounds = _.chain(element.children)
|
||||
.map((child) => {
|
||||
const bounds = child.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(child);
|
||||
return {
|
||||
top: bounds.top - parseFloat(style.marginTop),
|
||||
bottom: bounds.bottom + parseFloat(style.marginBottom),
|
||||
};
|
||||
})
|
||||
.reduce((result, bounds) => ({
|
||||
top: Math.min(result.top, bounds.top),
|
||||
bottom: Math.max(result.bottom, bounds.bottom),
|
||||
}))
|
||||
.value() || { top: 0, bottom: 0 };
|
||||
|
||||
// Height of controls outside visualization area
|
||||
const bodyWrapper = wrapper.querySelector('.body-container');
|
||||
if (bodyWrapper) {
|
||||
const elementStyle = window.getComputedStyle(element);
|
||||
const controlsHeight = _.chain(bodyWrapper.children)
|
||||
.filter(n => n !== element)
|
||||
.reduce((result, n) => {
|
||||
const b = n.getBoundingClientRect();
|
||||
return result + (b.bottom - b.top);
|
||||
}, 0)
|
||||
.value();
|
||||
|
||||
const additionalHeight = grid.opts.verticalMargin +
|
||||
// include container paddings too
|
||||
parseFloat(elementStyle.paddingTop) + parseFloat(elementStyle.paddingBottom) +
|
||||
// add few pixels for scrollbar (if visible)
|
||||
(element.scrollWidth > element.offsetWidth ? 16 : 0);
|
||||
|
||||
const contentsHeight = childrenBounds.bottom - childrenBounds.top;
|
||||
|
||||
const cellHeight = grid.cellHeight() + grid.opts.verticalMargin;
|
||||
resultHeight = Math.ceil(Math.round(controlsHeight + contentsHeight + additionalHeight) / cellHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// minHeight <= resultHeight <= maxHeight
|
||||
return Math.min(Math.max(minHeight, resultHeight), maxHeight);
|
||||
}
|
||||
|
||||
function gridstack($parse, dashboardGridOptions) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
replace: false,
|
||||
scope: {
|
||||
editing: '=',
|
||||
batchUpdate: '=', // set by directive - for using in wrapper components
|
||||
isOneColumnMode: '=',
|
||||
},
|
||||
controller() {
|
||||
this.$el = null;
|
||||
|
||||
this.resizingWidget = null;
|
||||
this.draggingWidget = null;
|
||||
|
||||
this.grid = () => (this.$el ? this.$el.data('gridstack') : null);
|
||||
|
||||
this.addWidget = ($element, item, itemId) => {
|
||||
const grid = this.grid();
|
||||
if (grid) {
|
||||
grid.addWidget(
|
||||
$element,
|
||||
item.col, item.row, item.sizeX, item.sizeY,
|
||||
false, // auto position
|
||||
item.minSizeX, item.maxSizeX, item.minSizeY, item.maxSizeY,
|
||||
itemId,
|
||||
);
|
||||
grid._updateStyles(grid.grid.getGridHeight());
|
||||
}
|
||||
};
|
||||
|
||||
this.updateWidget = ($element, item) => {
|
||||
this.update((grid) => {
|
||||
grid.update($element, item.col, item.row, item.sizeX, item.sizeY);
|
||||
grid.minWidth($element, item.minSizeX);
|
||||
grid.maxWidth($element, item.maxSizeX);
|
||||
grid.minHeight($element, item.minSizeY);
|
||||
grid.maxHeight($element, item.maxSizeY);
|
||||
});
|
||||
};
|
||||
|
||||
this.batchUpdateWidgets = (items) => {
|
||||
// This method is used to update multiple widgets with a single
|
||||
// reflow (for example, restore positions when dashboard editing cancelled).
|
||||
// "dirty" part of code: updating grid and DOM nodes directly.
|
||||
// layout reflow is triggered by `batchUpdate`/`commit` calls
|
||||
this.update((grid) => {
|
||||
_.each(grid.grid.nodes, (node) => {
|
||||
const item = items[node.id];
|
||||
if (item) {
|
||||
if (_.isNumber(item.col)) {
|
||||
node.x = parseFloat(item.col);
|
||||
node.el.attr('data-gs-x', node.x);
|
||||
node._dirty = true;
|
||||
}
|
||||
|
||||
if (_.isNumber(item.row)) {
|
||||
node.y = parseFloat(item.row);
|
||||
node.el.attr('data-gs-y', node.y);
|
||||
node._dirty = true;
|
||||
}
|
||||
|
||||
if (_.isNumber(item.sizeX)) {
|
||||
node.width = parseFloat(item.sizeX);
|
||||
node.el.attr('data-gs-width', node.width);
|
||||
node._dirty = true;
|
||||
}
|
||||
|
||||
if (_.isNumber(item.sizeY)) {
|
||||
node.height = parseFloat(item.sizeY);
|
||||
node.el.attr('data-gs-height', node.height);
|
||||
node._dirty = true;
|
||||
}
|
||||
|
||||
if (_.isNumber(item.minSizeX)) {
|
||||
node.minWidth = parseFloat(item.minSizeX);
|
||||
node.el.attr('data-gs-min-width', node.minWidth);
|
||||
node._dirty = true;
|
||||
}
|
||||
|
||||
if (_.isNumber(item.maxSizeX)) {
|
||||
node.maxWidth = parseFloat(item.maxSizeX);
|
||||
node.el.attr('data-gs-max-width', node.maxWidth);
|
||||
node._dirty = true;
|
||||
}
|
||||
|
||||
if (_.isNumber(item.minSizeY)) {
|
||||
node.minHeight = parseFloat(item.minSizeY);
|
||||
node.el.attr('data-gs-min-height', node.minHeight);
|
||||
node._dirty = true;
|
||||
}
|
||||
|
||||
if (_.isNumber(item.maxSizeY)) {
|
||||
node.maxHeight = parseFloat(item.maxSizeY);
|
||||
node.el.attr('data-gs-max-height', node.maxHeight);
|
||||
node._dirty = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
this.removeWidget = ($element) => {
|
||||
const grid = this.grid();
|
||||
if (grid) {
|
||||
grid.removeWidget($element, false);
|
||||
}
|
||||
};
|
||||
|
||||
this.getNodeByElement = (element) => {
|
||||
const grid = this.grid();
|
||||
if (grid && grid.grid) {
|
||||
// This method seems to be internal
|
||||
return grid.grid.getNodeDataByDOMEl($(element));
|
||||
}
|
||||
};
|
||||
|
||||
this.setWidgetId = ($element, id) => {
|
||||
// `gridstack` has no API method to change node id; but since it's not used
|
||||
// by library, we can just update grid and DOM node
|
||||
const node = this.getNodeByElement($element);
|
||||
if (node) {
|
||||
node.id = id;
|
||||
$element.attr('data-gs-id', _.isUndefined(id) ? null : id);
|
||||
}
|
||||
};
|
||||
|
||||
this.setEditing = (value) => {
|
||||
const grid = this.grid();
|
||||
if (grid) {
|
||||
if (value) {
|
||||
grid.enable();
|
||||
} else {
|
||||
grid.disable();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.update = (callback) => {
|
||||
const grid = this.grid();
|
||||
if (grid) {
|
||||
grid.batchUpdate();
|
||||
try {
|
||||
if (_.isFunction(callback)) {
|
||||
callback(grid);
|
||||
}
|
||||
// `_updateStyles` is internal, but grid sometimes "forgets"
|
||||
// to rebuild stylesheet, so we need to force it
|
||||
grid._updateStyles(grid.grid.getGridHeight());
|
||||
} finally {
|
||||
grid.commit();
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
link: ($scope, $element, $attr, controller) => {
|
||||
const batchUpdateAssignable = _.isFunction($parse($attr.batchUpdate).assign);
|
||||
const isOneColumnModeAssignable = _.isFunction($parse($attr.batchUpdate).assign);
|
||||
|
||||
let enablePolling = true;
|
||||
|
||||
$element.addClass('grid-stack');
|
||||
$element.gridstack({
|
||||
auto: false,
|
||||
verticalMargin: dashboardGridOptions.margins,
|
||||
// real row height will be `cellHeight` + `verticalMargin`
|
||||
cellHeight: dashboardGridOptions.rowHeight - dashboardGridOptions.margins,
|
||||
width: dashboardGridOptions.columns, // columns
|
||||
height: 0, // max rows (0 for unlimited)
|
||||
animate: true,
|
||||
float: false,
|
||||
minWidth: dashboardGridOptions.mobileBreakPoint,
|
||||
resizable: {
|
||||
handles: 'e, se, s, sw, w',
|
||||
start: (event, ui) => {
|
||||
controller.resizingWidget = ui.element;
|
||||
$(ui.element).trigger(
|
||||
'gridstack.resize-start',
|
||||
controller.getNodeByElement(ui.element),
|
||||
);
|
||||
},
|
||||
stop: (event, ui) => {
|
||||
controller.resizingWidget = null;
|
||||
$(ui.element).trigger(
|
||||
'gridstack.resize-end',
|
||||
controller.getNodeByElement(ui.element),
|
||||
);
|
||||
controller.update();
|
||||
},
|
||||
},
|
||||
draggable: {
|
||||
start: (event, ui) => {
|
||||
controller.draggingWidget = ui.helper;
|
||||
$(ui.helper).trigger(
|
||||
'gridstack.drag-start',
|
||||
controller.getNodeByElement(ui.helper),
|
||||
);
|
||||
},
|
||||
stop: (event, ui) => {
|
||||
controller.draggingWidget = null;
|
||||
$(ui.helper).trigger(
|
||||
'gridstack.drag-end',
|
||||
controller.getNodeByElement(ui.helper),
|
||||
);
|
||||
controller.update();
|
||||
},
|
||||
},
|
||||
});
|
||||
controller.$el = $element;
|
||||
|
||||
// `change` events sometimes fire too frequently (for example,
|
||||
// on initial rendering when all widgets add themselves to grid, grid
|
||||
// will fire `change` event will _all_ items available at that moment).
|
||||
// Collect changed items, and then delegate event with some delay
|
||||
let changedNodes = {};
|
||||
const triggerChange = _.debounce(() => {
|
||||
_.each(changedNodes, (node) => {
|
||||
if (node.el) {
|
||||
$(node.el).trigger('gridstack.changed', node);
|
||||
}
|
||||
});
|
||||
changedNodes = {};
|
||||
});
|
||||
|
||||
$element.on('change', (event, nodes) => {
|
||||
nodes = _.isArray(nodes) ? nodes : [];
|
||||
_.each(nodes, (node) => {
|
||||
changedNodes[node.id] = node;
|
||||
});
|
||||
triggerChange();
|
||||
});
|
||||
|
||||
$scope.$watch('editing', (value) => {
|
||||
controller.setEditing(!!value);
|
||||
});
|
||||
|
||||
if (batchUpdateAssignable) {
|
||||
$scope.batchUpdate = controller.batchUpdateWidgets;
|
||||
}
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
enablePolling = false;
|
||||
controller.$el = null;
|
||||
});
|
||||
|
||||
// `gridstack` does not provide API to detect when one-column mode changes.
|
||||
// Just watch `$element` for specific class
|
||||
function updateOneColumnMode() {
|
||||
const grid = controller.grid();
|
||||
if (grid) {
|
||||
const isOneColumnMode = $element.hasClass(grid.opts.oneColumnModeClass);
|
||||
if ($scope.isOneColumnMode !== isOneColumnMode) {
|
||||
$scope.isOneColumnMode = isOneColumnMode;
|
||||
$scope.$applyAsync();
|
||||
}
|
||||
}
|
||||
|
||||
if (enablePolling) {
|
||||
setTimeout(updateOneColumnMode, 150);
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling only if we can update scope binding; otherwise it
|
||||
// will just waisting CPU time (example: public dashboards don't need it)
|
||||
if (isOneColumnModeAssignable) {
|
||||
updateOneColumnMode();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function gridstackItem($timeout) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
replace: false,
|
||||
require: '^gridstack',
|
||||
scope: {
|
||||
gridstackItem: '=',
|
||||
gridstackItemId: '@',
|
||||
},
|
||||
link: ($scope, $element, $attr, controller) => {
|
||||
let enablePolling = true;
|
||||
let heightBeforeResize = null;
|
||||
|
||||
controller.addWidget($element, $scope.gridstackItem, $scope.gridstackItemId);
|
||||
|
||||
// these events are triggered only on user interaction
|
||||
$element.on('gridstack.resize-start', () => {
|
||||
const node = controller.getNodeByElement($element);
|
||||
heightBeforeResize = _.isObject(node) ? node.height : null;
|
||||
});
|
||||
$element.on('gridstack.resize-end', (event, node) => {
|
||||
const item = $scope.gridstackItem;
|
||||
if (
|
||||
_.isObject(node) && _.isObject(item) &&
|
||||
(node.height !== heightBeforeResize) &&
|
||||
(heightBeforeResize !== null)
|
||||
) {
|
||||
item.autoHeight = false;
|
||||
toggleAutoHeightClass($element, item.autoHeight);
|
||||
$scope.$applyAsync();
|
||||
}
|
||||
});
|
||||
|
||||
$element.on('gridstack.changed', (event, node) => {
|
||||
const item = $scope.gridstackItem;
|
||||
if (_.isObject(node) && _.isObject(item)) {
|
||||
let dirty = false;
|
||||
if (node.x !== item.col) {
|
||||
item.col = node.x;
|
||||
dirty = true;
|
||||
}
|
||||
if (node.y !== item.row) {
|
||||
item.row = node.y;
|
||||
dirty = true;
|
||||
}
|
||||
if (node.width !== item.sizeX) {
|
||||
item.sizeX = node.width;
|
||||
dirty = true;
|
||||
}
|
||||
if (node.height !== item.sizeY) {
|
||||
item.sizeY = node.height;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) {
|
||||
$scope.$applyAsync();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('gridstackItem.autoHeight', () => {
|
||||
const item = $scope.gridstackItem;
|
||||
if (_.isObject(item)) {
|
||||
toggleAutoHeightClass($element, item.autoHeight);
|
||||
} else {
|
||||
toggleAutoHeightClass($element, false);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('gridstackItemId', () => {
|
||||
controller.setWidgetId($element, $scope.gridstackItemId);
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
enablePolling = false;
|
||||
$timeout(() => {
|
||||
controller.removeWidget($element);
|
||||
});
|
||||
});
|
||||
|
||||
function update() {
|
||||
if (!controller.resizingWidget && !controller.draggingWidget) {
|
||||
const item = $scope.gridstackItem;
|
||||
const grid = controller.grid();
|
||||
if (grid && _.isObject(item) && item.autoHeight) {
|
||||
const sizeY = computeAutoHeight(
|
||||
$element, grid, controller.getNodeByElement($element),
|
||||
item.minSizeY, item.maxSizeY,
|
||||
);
|
||||
if (sizeY !== item.sizeY) {
|
||||
item.sizeY = sizeY;
|
||||
controller.updateWidget($element, { sizeY });
|
||||
$scope.$applyAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (enablePolling) {
|
||||
setTimeout(update, 150);
|
||||
}
|
||||
}
|
||||
|
||||
update();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('gridstack', gridstack);
|
||||
ngModule.directive('gridstackItem', gridstackItem);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="widget-wrapper">
|
||||
<div class="tile body-container" ng-if="$ctrl.type=='visualization'" ng-class="$ctrl.type"
|
||||
ng-switch="$ctrl.queryResult.getStatus()">
|
||||
<div class="tile body-container widget-visualization" ng-if="$ctrl.type=='visualization'" ng-class="$ctrl.type"
|
||||
ng-switch="$ctrl.widget.getQueryResult().getStatus()">
|
||||
<div class="body-row">
|
||||
<div class="t-header widget clearfix">
|
||||
<div class="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()">
|
||||
@@ -14,23 +14,23 @@
|
||||
</div>
|
||||
|
||||
<ul class="dropdown-menu pull-right" uib-dropdown-menu style="z-index:1000000">
|
||||
<li ng-class="{'disabled': $ctrl.queryResult.isEmpty()}"><a ng-href="{{$ctrl.queryResult.getLink($ctrl.query.id, 'csv')}}" download="{{$ctrl.queryResult.getName($ctrl.query.name, 'csv')}}" target="_self">Download as CSV File</a></li>
|
||||
<li ng-class="{'disabled': $ctrl.queryResult.isEmpty()}"><a ng-href="{{$ctrl.queryResult.getLink($ctrl.query.id, 'xlsx')}}" download="{{$ctrl.queryResult.getName($ctrl.query.name, 'xlsx')}}" target="_self">Download as Excel File</a></li>
|
||||
<li><a ng-href="{{$ctrl.query.getUrl(true, $ctrl.widget.visualization.id)}}" ng-show="$ctrl.canViewQuery">View Query</a></li>
|
||||
<li ng-class="{'disabled': $ctrl.widget.getQueryResult().isEmpty()}"><a ng-href="{{$ctrl.widget.getQueryResult().getLink($ctrl.widget.getQuery().id, 'csv')}}" download="{{$ctrl.widget.getQueryResult().getName($ctrl.widget.getQuery().name, 'csv')}}" target="_self">Download as CSV File</a></li>
|
||||
<li ng-class="{'disabled': $ctrl.widget.getQueryResult().isEmpty()}"><a ng-href="{{$ctrl.widget.getQueryResult().getLink($ctrl.widget.getQuery().id, 'xlsx')}}" download="{{$ctrl.widget.getQueryResult().getName($ctrl.widget.getQuery().name, 'xlsx')}}" target="_self">Download as Excel File</a></li>
|
||||
<li><a ng-href="{{$ctrl.widget.getQuery().getUrl(true, $ctrl.widget.visualization.id)}}" ng-show="$ctrl.canViewQuery">View Query</a></li>
|
||||
<li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.deleteWidget()">Remove From Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="th-title">
|
||||
<p class="hidden-print">
|
||||
<span ng-hide="$ctrl.canViewQuery">{{$ctrl.query.name}}</span>
|
||||
<query-link query="$ctrl.query" visualization="$ctrl.widget.visualization" ng-show="$ctrl.canViewQuery"></query-link>
|
||||
<span ng-hide="$ctrl.canViewQuery">{{$ctrl.widget.getQuery().name}}</span>
|
||||
<query-link query="$ctrl.widget.getQuery()" visualization="$ctrl.widget.visualization" ng-show="$ctrl.canViewQuery"></query-link>
|
||||
<small><visualization-name visualization="$ctrl.widget.visualization"/></small>
|
||||
</p>
|
||||
<p class="visible-print">
|
||||
{{$ctrl.query.name}}
|
||||
{{$ctrl.widget.getQuery().name}}
|
||||
<visualization-name visualization="$ctrl.widget.visualization"/>
|
||||
</p>
|
||||
<div class="text-muted query--description" ng-bind-html="$ctrl.query.description | markdown"></div>
|
||||
<div class="text-muted query--description" ng-bind-html="$ctrl.widget.getQuery().description | markdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-b-10" ng-if="$ctrl.localParametersDefs().length > 0">
|
||||
@@ -39,10 +39,10 @@
|
||||
</div>
|
||||
|
||||
<div ng-switch-when="failed" class="body-row-auto scrollbox">
|
||||
<div class="alert alert-danger m-5" ng-show="$ctrl.queryResult.getError()">Error running query: <strong>{{$ctrl.queryResult.getError()}}</strong></div>
|
||||
<div class="alert alert-danger m-5" ng-show="$ctrl.widget.getQueryResult().getError()">Error running query: <strong>{{$ctrl.widget.getQueryResult().getError()}}</strong></div>
|
||||
</div>
|
||||
<div ng-switch-when="done" class="body-row-auto scrollbox" ng-style="$ctrl.getWidgetStyles()">
|
||||
<visualization-renderer visualization="$ctrl.widget.visualization" query-result="$ctrl.queryResult" class="t-body"></visualization-renderer>
|
||||
<div ng-switch-when="done" class="body-row-auto scrollbox">
|
||||
<visualization-renderer visualization="$ctrl.widget.visualization" query-result="$ctrl.widget.getQueryResult()" class="t-body"></visualization-renderer>
|
||||
</div>
|
||||
<div ng-switch-default class="body-row-auto spinner-container">
|
||||
<div class="spinner">
|
||||
@@ -52,20 +52,20 @@
|
||||
|
||||
<div class="body-row clearfix tile__bottom-control">
|
||||
<a class="small hidden-print" ng-click="$ctrl.reload(true)" ng-if="!$ctrl.public">
|
||||
<i class="zmdi zmdi-time-restore"></i> <span am-time-ago="$ctrl.queryResult.getUpdatedAt()"></span>
|
||||
<i class="zmdi zmdi-time-restore"></i> <span am-time-ago="$ctrl.widget.getQueryResult().getUpdatedAt()"></span>
|
||||
</a>
|
||||
<span class="small hidden-print" ng-if="$ctrl.public">
|
||||
<i class="zmdi zmdi-time-restore"></i> <span am-time-ago="$ctrl.queryResult.getUpdatedAt()"></span>
|
||||
<i class="zmdi zmdi-time-restore"></i> <span am-time-ago="$ctrl.widget.getQueryResult().getUpdatedAt()"></span>
|
||||
</span>
|
||||
<span class="visible-print">
|
||||
<i class="zmdi zmdi-time-restore"></i> {{$ctrl.queryResult.getUpdatedAt() | dateTime}}
|
||||
<i class="zmdi zmdi-time-restore"></i> {{$ctrl.widget.getQueryResult().getUpdatedAt() | dateTime}}
|
||||
</span>
|
||||
|
||||
<button class="btn btn-sm btn-default pull-right hidden-print btn-transparent btn__refresh" ng-click="$ctrl.reload(true)" ng-if="!$ctrl.public"><i class="zmdi zmdi-refresh"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tile body-container" ng-if="$ctrl.type=='restricted'" ng-class="$ctrl.type">
|
||||
<div class="tile body-container widget-restricted" ng-if="$ctrl.type=='restricted'" ng-class="$ctrl.type">
|
||||
<div class="t-body body-row-auto scrollbox">
|
||||
<div class="text-center">
|
||||
<h1><span class="zmdi zmdi-lock"></span></h1>
|
||||
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tile body-container" ng-hide="$ctrl.widget.width === 0" ng-if="$ctrl.type=='textbox'" ng-class="$ctrl.type">
|
||||
<div class="tile body-container widget-text" ng-hide="$ctrl.widget.width === 0" ng-if="$ctrl.type=='textbox'" ng-class="$ctrl.type">
|
||||
<div class="body-row clearfix t-body">
|
||||
<div class="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()">
|
||||
<div class="dropdown-header">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as _ from 'underscore';
|
||||
import template from './widget.html';
|
||||
import editTextBoxTemplate from './edit-text-box.html';
|
||||
import './widget.less';
|
||||
@@ -19,13 +18,17 @@ const EditTextBoxComponent = {
|
||||
this.saveInProgress = true;
|
||||
if (this.widget.new_text !== this.widget.existing_text) {
|
||||
this.widget.text = this.widget.new_text;
|
||||
this.widget.$save().then(() => {
|
||||
this.close();
|
||||
}).catch(() => {
|
||||
toastr.error('Widget can not be updated');
|
||||
}).finally(() => {
|
||||
this.saveInProgress = false;
|
||||
});
|
||||
this.widget
|
||||
.$save()
|
||||
.then(() => {
|
||||
this.close();
|
||||
})
|
||||
.catch(() => {
|
||||
toastr.error('Widget can not be updated');
|
||||
})
|
||||
.finally(() => {
|
||||
this.saveInProgress = false;
|
||||
});
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
@@ -47,18 +50,12 @@ function DashboardWidgetCtrl($location, $uibModal, $window, Events, currentUser)
|
||||
});
|
||||
};
|
||||
|
||||
this.getWidgetStyles = () => {
|
||||
if (_.isObject(this.widget) && _.isObject(this.widget.visualization)) {
|
||||
const visualization = this.widget.visualization;
|
||||
if (visualization.type === 'PIVOT') {
|
||||
return { overflow: 'visible' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.localParametersDefs = () => {
|
||||
if (!this.localParameters) {
|
||||
this.localParameters = this.widget.getQuery().getParametersDefs().filter(p => !p.global);
|
||||
this.localParameters = this.widget
|
||||
.getQuery()
|
||||
.getParametersDefs()
|
||||
.filter(p => !p.global);
|
||||
}
|
||||
return this.localParameters;
|
||||
};
|
||||
@@ -71,8 +68,7 @@ function DashboardWidgetCtrl($location, $uibModal, $window, Events, currentUser)
|
||||
Events.record('delete', 'widget', this.widget.id);
|
||||
|
||||
this.widget.$delete((response) => {
|
||||
this.dashboard.widgets = this.dashboard.widgets
|
||||
.filter(widget => (widget.id !== undefined) && (widget.id !== this.widget.id));
|
||||
this.dashboard.widgets = this.dashboard.widgets.filter(w => w.id !== undefined && w.id !== this.widget.id);
|
||||
this.dashboard.version = response.version;
|
||||
if (this.deleted) {
|
||||
this.deleted({});
|
||||
@@ -84,14 +80,13 @@ function DashboardWidgetCtrl($location, $uibModal, $window, Events, currentUser)
|
||||
|
||||
this.reload = (force) => {
|
||||
const maxAge = $location.search().maxAge;
|
||||
this.queryResult = this.widget.getQueryResult(force, maxAge);
|
||||
this.widget.load(force, maxAge);
|
||||
};
|
||||
|
||||
if (this.widget.visualization) {
|
||||
Events.record('view', 'query', this.widget.visualization.query.id, { dashboard: true });
|
||||
Events.record('view', 'visualization', this.widget.visualization.id, { dashboard: true });
|
||||
|
||||
this.query = this.widget.getQuery();
|
||||
this.reload(false);
|
||||
|
||||
this.type = 'visualization';
|
||||
|
||||
@@ -39,12 +39,26 @@
|
||||
|
||||
.t-header.widget {
|
||||
.dropdown {
|
||||
margin-top: -5px;
|
||||
margin-right: -5px;
|
||||
margin-top: -15px;
|
||||
margin-right: -15px;
|
||||
|
||||
.actions {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbox:empty {
|
||||
padding: 0 !important;
|
||||
font-size: 1px !important;
|
||||
}
|
||||
|
||||
.widget-text {
|
||||
:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
</div>
|
||||
<hr>
|
||||
<div class="form-group" ng-class='{"has-error": (inner.input | showError), "required": field.property.required}' ng-form="inner" ng-repeat="field in fields">
|
||||
<label ng-if="field.property.type !== 'checkbox'" class="control-label">{{field.property.title || field.name | capitalize}}</label>
|
||||
<label ng-if="field.property.type !== 'checkbox'" class="control-label">{{field.property.title || field.name | toHuman}}</label>
|
||||
<input name="input" type="{{field.property.type}}" class="form-control" ng-model="target.options[field.name]" ng-required="field.property.required"
|
||||
ng-if="field.property.type !== 'file' && field.property.type !== 'checkbox'" accesskey="tab" placeholder="{{field.property.default}}">
|
||||
|
||||
<label ng-if="field.property.type=='checkbox'">
|
||||
<input name="input" type="{{field.property.type}}" ng-model="target.options[field.name]" ng-required="field.property.required"
|
||||
ng-if="field.property.type !== 'file'" accesskey="tab" placeholder="{{field.property.default}}">
|
||||
{{field.property.title || field.name | capitalize}}
|
||||
{{field.property.title || field.name | toHuman}}
|
||||
</label>
|
||||
|
||||
<input name="input" type="file" class="form-control" ng-model="files[field.name]" ng-required="field.property.required && !target.options[field.name]"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<td ng-class="'content-align-' + column.alignContent">
|
||||
<td ng-class="'content-align-' + column.alignContent + ' display-as-' + column.displayAs">
|
||||
<div ng-if="allowHTML" ng-bind-html="value"></div>
|
||||
<div ng-if="!allowHTML" ng-bind="value"></div>
|
||||
</td>
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { isUndefined, isFunction } from 'underscore';
|
||||
|
||||
const hasOwnProperty = Object.prototype.hasOwnProperty;
|
||||
import { isFunction, extend } from 'underscore';
|
||||
import { formatSimpleTemplate } from '@/lib/value-format';
|
||||
|
||||
function trim(str) {
|
||||
return str.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
|
||||
function processTags(str, data, defaultColumn) {
|
||||
return str.replace(/{{\s*([^\s]+)\s*}}/g, (match, column) => {
|
||||
if (column === '@') {
|
||||
column = defaultColumn;
|
||||
}
|
||||
if (hasOwnProperty.call(data, column) && !isUndefined(data[column])) {
|
||||
return data[column];
|
||||
}
|
||||
return match;
|
||||
});
|
||||
return formatSimpleTemplate(str, extend({
|
||||
'@': data[defaultColumn],
|
||||
}, data));
|
||||
}
|
||||
|
||||
export function renderDefault(column, row) {
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th ng-repeat="column in $ctrl.columns" ng-click="$ctrl.onColumnHeaderClick($event, column)"
|
||||
class="sortable-column" ng-class="'content-align-' + column.alignContent"
|
||||
width="{{ ['number', 'boolean', 'datetime', 'image'].indexOf(column.displayAs) >= 0 ? '1%' : undefined }}">
|
||||
class="sortable-column" ng-class="'content-align-' + column.alignContent + ' display-as-' + column.displayAs">
|
||||
<span ng-if="($ctrl.orderBy.length > 1) && ($ctrl.orderByColumnsIndex[column.name] > 0)"
|
||||
class="sort-order-indicator">{{ $ctrl.orderByColumnsIndex[column.name] }}</span>
|
||||
<span>{{column.title}}</span>
|
||||
|
||||
@@ -49,4 +49,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.display-as-number,
|
||||
.display-as-boolean,
|
||||
.display-as-datetime,
|
||||
.display-as-image {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<td>
|
||||
<td ng-class="'display-as-' + column.displayAs">
|
||||
<div ng-if="!isValid" class="json-cell-invalid">{{ value }}</div>
|
||||
<div ng-show="isValid" class="json-cell-valid"></div>
|
||||
</td>
|
||||
|
||||
33
client/app/components/empty-state/empty-state.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<div class="empty-state bg-white tiled" ng-if="!$ctrl.loading && $ctrl.shouldShowOnboarding()">
|
||||
<div class="empty-state__summary">
|
||||
<h4 ng-if="$ctrl.title">{{$ctrl.title}}</h4>
|
||||
<h2 ng-if="$ctrl.icon">
|
||||
<i ng-class="$ctrl.icon" aria-hidden="true"></i>
|
||||
</h2>
|
||||
<p>{{$ctrl.description}}</p>
|
||||
</div>
|
||||
<div class="empty-state__steps">
|
||||
<h4>Let's get started</h4>
|
||||
<ol>
|
||||
<li ng-class="{done: $ctrl.dataSourceStepCompleted}">
|
||||
<span ng-if="!$ctrl.isAdmin">Ask an account admin to connect a data source.</span>
|
||||
<span ng-if="$ctrl.isAdmin">
|
||||
<a href="data_sources">Connect</a> a Data Source
|
||||
</span>
|
||||
</li>
|
||||
<li ng-class="{done: $ctrl.queryStepCompleted}">
|
||||
<a href="queries/new">Create</a> your first Query</li>
|
||||
<li ng-if="$ctrl.showAlertStep" ng-class="{done: $ctrl.alertStepCompleted}">
|
||||
<a href="alerts/new">Create</a> your first Alert</li>
|
||||
<li ng-if="$ctrl.showDashboardStep" ng-class="{done: $ctrl.dashboardStepCompleted}">
|
||||
<a ng-click="$ctrl.newDashboard()">Create</a> your first Dashboard</li>
|
||||
<li ng-if="$ctrl.showInviteStep" ng-class="{done: $ctrl.inviteStepCompleted}">
|
||||
<a href="users/new">Invite</a> your team members</li>
|
||||
</ol>
|
||||
<p>Need more support?
|
||||
<a href="{{$ctrl.helpLink}}" target="_blank">See our Help
|
||||
<i class="fa fa-external-link" aria-hidden="true"></i>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
62
client/app/components/empty-state/empty-state.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import './empty-state.less';
|
||||
import template from './empty-state.html';
|
||||
|
||||
const EmptyStateComponent = {
|
||||
template,
|
||||
replace: true,
|
||||
bindings: {
|
||||
icon: '@',
|
||||
title: '@',
|
||||
description: '@',
|
||||
helpLink: '@',
|
||||
showAlertStep: '<',
|
||||
showDashboardStep: '<',
|
||||
showInviteStep: '<',
|
||||
onboardingMode: '<',
|
||||
},
|
||||
controller($http, $uibModal, currentUser) {
|
||||
this.loading = true;
|
||||
this.isAdmin = currentUser.isAdmin;
|
||||
|
||||
$http.get('api/organization/status').then((response) => {
|
||||
this.loading = false;
|
||||
|
||||
const counters = response.data.object_counters;
|
||||
this.dataSourceStepCompleted = counters.data_sources > 0;
|
||||
this.queryStepCompleted = counters.queries > 0;
|
||||
this.dashboardStepCompleted = counters.dashboards > 0;
|
||||
this.alertStepCompleted = counters.alerts > 0;
|
||||
this.inviteStepCompleted = counters.users > 1;
|
||||
});
|
||||
|
||||
this.shouldShowOnboarding = () => {
|
||||
if (this.loading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.onboardingMode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !(
|
||||
this.dataSourceStepCompleted &&
|
||||
this.queryStepCompleted &&
|
||||
this.dashboardStepCompleted &&
|
||||
this.inviteStepCompleted
|
||||
);
|
||||
};
|
||||
|
||||
this.newDashboard = () => {
|
||||
$uibModal.open({
|
||||
component: 'editDashboardDialog',
|
||||
resolve: {
|
||||
dashboard: () => ({ name: null, layout: null }),
|
||||
},
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('emptyState', EmptyStateComponent);
|
||||
}
|
||||
56
client/app/components/empty-state/empty-state.less
Normal file
@@ -0,0 +1,56 @@
|
||||
// Empty states
|
||||
.empty-state {
|
||||
width: 100%;
|
||||
margin: 0px auto 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 35px;
|
||||
font-size: 14px;
|
||||
line-height: 21px;
|
||||
|
||||
.empty-state__summary,
|
||||
.empty-state__steps {
|
||||
width: 48%;
|
||||
}
|
||||
|
||||
.empty-state__summary {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
ol {
|
||||
margin-bottom: 15px;
|
||||
padding: 17px;
|
||||
}
|
||||
|
||||
li.done {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 15px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
color: #767676;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
flex-direction: column;
|
||||
|
||||
.empty-state__summary {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.empty-state__summary,
|
||||
.empty-state__steps {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import moment from 'moment';
|
||||
import { map, range, partial } from 'underscore';
|
||||
import { durationHumanize } from '@/filters';
|
||||
|
||||
import template from './schedule-dialog.html';
|
||||
|
||||
@@ -57,7 +58,7 @@ function queryTimePicker() {
|
||||
};
|
||||
}
|
||||
|
||||
function queryRefreshSelect() {
|
||||
function queryRefreshSelect(clientConfig) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
@@ -73,43 +74,8 @@ function queryRefreshSelect() {
|
||||
<option value="">No Refresh</option>
|
||||
</select>`,
|
||||
link($scope) {
|
||||
$scope.refreshOptions = [
|
||||
{
|
||||
value: '60',
|
||||
name: 'Every minute',
|
||||
},
|
||||
];
|
||||
|
||||
[5, 10, 15, 30].forEach((i) => {
|
||||
$scope.refreshOptions.push({
|
||||
value: String(i * 60),
|
||||
name: `Every ${i} minutes`,
|
||||
});
|
||||
});
|
||||
|
||||
range(1, 13).forEach((i) => {
|
||||
$scope.refreshOptions.push({
|
||||
value: String(i * 3600),
|
||||
name: `Every ${i}h`,
|
||||
});
|
||||
});
|
||||
|
||||
$scope.refreshOptions.push({
|
||||
value: String(24 * 3600),
|
||||
name: 'Every 24h',
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: String(7 * 24 * 3600),
|
||||
name: 'Every 7 days',
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: String(14 * 24 * 3600),
|
||||
name: 'Every 14 days',
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: String(30 * 24 * 3600),
|
||||
name: 'Every 30 days',
|
||||
});
|
||||
$scope.refreshOptions =
|
||||
clientConfig.queryRefreshIntervals.map(interval => ({ value: String(interval), name: `Every ${durationHumanize(interval)}` }));
|
||||
|
||||
$scope.$watch('refreshType', () => {
|
||||
if ($scope.refreshType === 'periodic') {
|
||||
|
||||
@@ -1,33 +1,15 @@
|
||||
const dashboardGridOptions = {
|
||||
columns: 6,
|
||||
pushing: true,
|
||||
floating: true,
|
||||
swapping: true,
|
||||
width: 'auto',
|
||||
colWidth: 'auto',
|
||||
rowHeight: 50,
|
||||
margins: [15, 15],
|
||||
outerMargin: false,
|
||||
sparse: false,
|
||||
isMobile: false,
|
||||
columns: 6, // grid columns count
|
||||
rowHeight: 50, // grid row height (incl. bottom padding)
|
||||
margins: 15, // widget margins
|
||||
mobileBreakPoint: 800,
|
||||
mobileModeEnabled: true,
|
||||
minColumns: 1,
|
||||
minRows: 1,
|
||||
maxRows: 100,
|
||||
// defaults for widgets
|
||||
defaultSizeX: 3,
|
||||
defaultSizeY: 3,
|
||||
minSizeX: 1,
|
||||
maxSizeX: null,
|
||||
minSizeY: 4,
|
||||
maxSizeY: null,
|
||||
resizable: {
|
||||
enabled: false,
|
||||
handles: ['n', 'e', 's', 'w', 'ne', 'se', 'sw', 'nw'],
|
||||
},
|
||||
draggable: {
|
||||
enabled: false,
|
||||
},
|
||||
maxSizeX: 6,
|
||||
minSizeY: 1,
|
||||
maxSizeY: 1000,
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
|
||||
@@ -17,7 +17,6 @@ import 'angular-moment';
|
||||
import 'brace';
|
||||
import 'angular-ui-ace';
|
||||
import 'angular-resizable';
|
||||
import ngGridster from 'angular-gridster';
|
||||
import { each, isFunction } from 'underscore';
|
||||
|
||||
import '@/lib/sortable';
|
||||
@@ -52,7 +51,6 @@ const requirements = [
|
||||
'angularResizable',
|
||||
vsRepeat,
|
||||
'ui.sortable',
|
||||
ngGridster.name,
|
||||
];
|
||||
|
||||
const ngModule = angular.module('app', requirements);
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'font-awesome/css/font-awesome.css';
|
||||
import 'ui-select/dist/select.css';
|
||||
import 'angular-toastr/dist/angular-toastr.css';
|
||||
import 'angular-resizable/src/angular-resizable.css';
|
||||
import 'angular-gridster/dist/angular-gridster.css';
|
||||
import 'pace-progress/themes/blue/pace-theme-minimal.css';
|
||||
|
||||
import '@/assets/css/superflat_redash.css';
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import * as _ from 'underscore';
|
||||
import { requestAnimationFrame } from './utils';
|
||||
|
||||
function gridsterAutoHeight($timeout) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
require: 'gridsterItem',
|
||||
link($scope, $element, attr, controller) {
|
||||
let destroyed = false;
|
||||
|
||||
function updateHeight() {
|
||||
const wrapper = $element[0];
|
||||
// Query element, but keep selector order
|
||||
const element = _.chain(attr.gridsterAutoHeight.split(','))
|
||||
.map(selector => wrapper.querySelector(selector))
|
||||
.filter(_.isObject)
|
||||
.first()
|
||||
.value();
|
||||
if (element) {
|
||||
const childrenBounds = _.chain(element.children)
|
||||
.map(child => child.getBoundingClientRect())
|
||||
.reduce((result, bounds) => ({
|
||||
left: Math.min(result.left, bounds.left),
|
||||
top: Math.min(result.top, bounds.top),
|
||||
right: Math.min(result.right, bounds.right),
|
||||
bottom: Math.min(result.bottom, bounds.bottom),
|
||||
}))
|
||||
.value();
|
||||
|
||||
const additionalHeight = 100 + _.last(controller.gridster.margins);
|
||||
const contentsHeight = childrenBounds.bottom - childrenBounds.top;
|
||||
$timeout(() => {
|
||||
controller.sizeY = Math.ceil((contentsHeight + additionalHeight) /
|
||||
controller.gridster.curRowHeight);
|
||||
});
|
||||
}
|
||||
|
||||
if (!destroyed) {
|
||||
requestAnimationFrame(updateHeight);
|
||||
}
|
||||
}
|
||||
|
||||
if (controller.sizeY < 0) {
|
||||
$element.addClass('gridster-auto-height-enabled');
|
||||
updateHeight();
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
destroyed = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('gridsterAutoHeight', gridsterAutoHeight);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import autofocus from './autofocus';
|
||||
import compareTo from './compare-to';
|
||||
import gridsterAutoHeight from './gridster-auto-height';
|
||||
import title from './title';
|
||||
import resizeEvent from './resize-event';
|
||||
import resizableToggle from './resizable-toggle';
|
||||
@@ -8,7 +7,6 @@ import resizableToggle from './resizable-toggle';
|
||||
export default function init(ngModule) {
|
||||
autofocus(ngModule);
|
||||
compareTo(ngModule);
|
||||
gridsterAutoHeight(ngModule);
|
||||
title(ngModule);
|
||||
resizeEvent(ngModule);
|
||||
resizableToggle(ngModule);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { find } from 'underscore';
|
||||
|
||||
function sameNumber(a, b) {
|
||||
return (isNaN(a) && isNaN(b)) || (a === b);
|
||||
}
|
||||
|
||||
const flexBasis = ['flexBasis', 'webkitFlexBasis', 'msFlexPreferredSize']
|
||||
.find(prop => prop in document.documentElement.style) || 'flexBasis';
|
||||
const flexBasis = find(
|
||||
['flexBasis', 'webkitFlexBasis', 'msFlexPreferredSize'],
|
||||
prop => prop in document.documentElement.style,
|
||||
) || 'flexBasis';
|
||||
|
||||
const threshold = 5;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as _ from 'underscore';
|
||||
import { requestAnimationFrame } from './utils';
|
||||
|
||||
const items = new Map();
|
||||
|
||||
@@ -18,7 +17,7 @@ function checkItems() {
|
||||
}
|
||||
});
|
||||
|
||||
requestAnimationFrame(checkItems);
|
||||
setTimeout(checkItems, 50);
|
||||
}
|
||||
|
||||
checkItems(); // ensure it was called only once!
|
||||
|
||||
@@ -127,3 +127,11 @@ export function prettySize(bytes) {
|
||||
|
||||
return bytes.toFixed(3) + ' ' + units[unit];
|
||||
}
|
||||
|
||||
export function join(arr) {
|
||||
if (arr === undefined || arr === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return arr.join(' / ');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import _ from 'underscore';
|
||||
// eslint-disable-next-line
|
||||
const urlPattern = /(^|[\s\n]|<br\/?>)((?:https?|ftp):\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi;
|
||||
|
||||
const hasOwnProperty = Object.prototype.hasOwnProperty;
|
||||
|
||||
function createDefaultFormatter(highlightLinks) {
|
||||
if (highlightLinks) {
|
||||
return (value) => {
|
||||
@@ -50,7 +52,7 @@ function createNumberFormatter(format) {
|
||||
return value => value;
|
||||
}
|
||||
|
||||
export default function createFormatter(column) {
|
||||
export function createFormatter(column) {
|
||||
switch (column.displayAs) {
|
||||
case 'number': return createNumberFormatter(column.numberFormat);
|
||||
case 'boolean': return createBooleanFormatter(column.booleanValues);
|
||||
@@ -58,3 +60,15 @@ export default function createFormatter(column) {
|
||||
default: return createDefaultFormatter(column.allowHTML && column.highlightLinks);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSimpleTemplate(str, data) {
|
||||
if (!_.isString(str)) {
|
||||
return '';
|
||||
}
|
||||
return str.replace(/{{\s*([^\s]+)\s*}}/g, (match, prop) => {
|
||||
if (hasOwnProperty.call(data, prop) && !_.isUndefined(data[prop])) {
|
||||
return data[prop];
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<div class="container">
|
||||
<page-header title="Alerts">
|
||||
</page-header>
|
||||
<page-header title="Alerts"></page-header>
|
||||
|
||||
<div class="bg-white tiled">
|
||||
<empty-state icon="fa fa-bell-o"
|
||||
description="Get notified on certain events"
|
||||
show-alert-step="true"
|
||||
help-link="http://help.redash.io/category/23-alerts"
|
||||
ng-if="$ctrl.showEmptyState"></empty-state>
|
||||
|
||||
<div class="bg-white tiled" ng-if="$ctrl.showList">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -11,8 +11,17 @@ class AlertsListCtrl {
|
||||
constructor(Events, Alert) {
|
||||
Events.record('view', 'page', 'alerts');
|
||||
|
||||
this.showEmptyState = false;
|
||||
this.showList = false;
|
||||
|
||||
this.alerts = new Paginator([], { itemsPerPage: 20 });
|
||||
Alert.query((alerts) => {
|
||||
if (alerts.length > 0) {
|
||||
this.showList = true;
|
||||
} else {
|
||||
this.showEmptyState = true;
|
||||
}
|
||||
|
||||
this.alerts.updateRows(alerts.map(alert => ({
|
||||
id: alert.id,
|
||||
name: alert.name,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<div class='container'>
|
||||
<page-header title="Dashboards"></page-header>
|
||||
|
||||
<div class="row">
|
||||
<empty-state icon="zmdi zmdi-view-quilt"
|
||||
description="See the big picture"
|
||||
show-dashboard-step="true"
|
||||
help-link="http://help.redash.io/category/22-dashboards"
|
||||
ng-if="$ctrl.showEmptyState"></empty-state>
|
||||
|
||||
<div class="row" ng-if="$ctrl.showList">
|
||||
<div class="col-lg-3">
|
||||
<input type='text' class='form-control' placeholder="Search Dashboards..."
|
||||
ng-change="$ctrl.update()" ng-model="$ctrl.searchText" autofocus/>
|
||||
|
||||
@@ -35,7 +35,15 @@ function DashboardListCtrl(Dashboard, $location) {
|
||||
};
|
||||
|
||||
this.allTags = [];
|
||||
this.showList = false;
|
||||
this.showEmptyState = false;
|
||||
|
||||
this.dashboards.$promise.then((data) => {
|
||||
if (data.length > 0) {
|
||||
this.showList = true;
|
||||
} else {
|
||||
this.showEmptyState = true;
|
||||
}
|
||||
const out = data.map(dashboard => dashboard.name.match(TAGS_REGEX));
|
||||
this.allTags = _.unique(_.flatten(out)).filter(e => e).map(tag => tag.replace(/:$/, ''));
|
||||
this.allTags.sort();
|
||||
|
||||
@@ -84,12 +84,16 @@
|
||||
<filters filters="$ctrl.filters" on-change="$ctrl.filtersOnChange(filter, $modal)"></filters>
|
||||
</div>
|
||||
|
||||
<div style="overflow: hidden; padding-bottom: 5px;" ng-if="$ctrl.dashboard.widgets.length > 0">
|
||||
<div gridster="$ctrl.dashboardGridOptions" class="dashboard-wrapper"
|
||||
<div style="padding-bottom: 5px;" ng-if="$ctrl.dashboard.widgets.length > 0">
|
||||
<div gridstack editing="$ctrl.layoutEditing && !$ctrl.saveInProgress" batch-update="$ctrl.updateGridItems"
|
||||
is-one-column-mode="$ctrl.isGridDisabled" class="dashboard-wrapper"
|
||||
ng-class="{'preview-mode': !$ctrl.layoutEditing, 'editing-mode': $ctrl.layoutEditing}">
|
||||
<div ng-repeat="widget in $ctrl.dashboard.widgets" gridster-item="widget.options.position"
|
||||
gridster-auto-height=".scrollbox, .spinner-container">
|
||||
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" on-delete="$ctrl.removeWidget()"></dashboard-widget>
|
||||
<div class="dashboard-widget-wrapper"
|
||||
ng-repeat="widget in $ctrl.dashboard.widgets track by widget.id"
|
||||
gridstack-item="widget.options.position" gridstack-item-id="{{ widget.id }}">
|
||||
<div class="grid-stack-item-content">
|
||||
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" on-delete="$ctrl.removeWidget()"></dashboard-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,22 @@ import template from './dashboard.html';
|
||||
import shareDashboardTemplate from './share-dashboard.html';
|
||||
import './dashboard.less';
|
||||
|
||||
function isWidgetPositionChanged(oldPosition, newPosition) {
|
||||
const fields = ['col', 'row', 'sizeX', 'sizeY', 'autoHeight'];
|
||||
oldPosition = _.pick(oldPosition, fields);
|
||||
newPosition = _.pick(newPosition, fields);
|
||||
return !!_.find(fields, key => newPosition[key] !== oldPosition[key]);
|
||||
}
|
||||
|
||||
function getWidgetsWithChangedPositions(widgets) {
|
||||
return _.filter(widgets, (widget) => {
|
||||
if (!_.isObject(widget.$originalPosition)) {
|
||||
return true;
|
||||
}
|
||||
return isWidgetPositionChanged(widget.$originalPosition, widget.options.position);
|
||||
});
|
||||
}
|
||||
|
||||
function DashboardCtrl(
|
||||
$rootScope,
|
||||
$routeParams,
|
||||
@@ -18,26 +34,27 @@ function DashboardCtrl(
|
||||
currentUser,
|
||||
clientConfig,
|
||||
Events,
|
||||
dashboardGridOptions,
|
||||
toastr,
|
||||
) {
|
||||
this.saveInProgress = false;
|
||||
const saveDashboardLayout = () => {
|
||||
|
||||
const saveDashboardLayout = (widgets) => {
|
||||
if (!this.dashboard.canEdit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.saveInProgress = true;
|
||||
const showMessages = true; // this.layoutEditing;
|
||||
// Temporarily disable grid editing (but allow user to use UI controls)
|
||||
this.dashboardGridOptions.draggable.enabled = false;
|
||||
this.dashboardGridOptions.resizable.enabled = false;
|
||||
const showMessages = true;
|
||||
return $q
|
||||
.all(_.map(this.dashboard.widgets, widget => widget.$save()))
|
||||
.all(_.map(widgets, widget => widget.$save()))
|
||||
.then(() => {
|
||||
if (showMessages) {
|
||||
toastr.success('Changes saved.');
|
||||
}
|
||||
// Update original widgets positions
|
||||
_.each(widgets, (widget) => {
|
||||
_.extend(widget.$originalPosition, widget.options.position);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (showMessages) {
|
||||
@@ -46,51 +63,28 @@ function DashboardCtrl(
|
||||
})
|
||||
.finally(() => {
|
||||
this.saveInProgress = false;
|
||||
// If user didn't disable editing mode while saving - restore grid
|
||||
this.dashboardGridOptions.draggable.enabled = this.layoutEditing;
|
||||
this.dashboardGridOptions.resizable.enabled = this.layoutEditing;
|
||||
});
|
||||
};
|
||||
|
||||
this.layoutEditing = false;
|
||||
this.dashboardGridOptions = _.extend({}, dashboardGridOptions, {
|
||||
resizable: {
|
||||
enabled: false,
|
||||
handles: ['n', 'e', 's', 'w', 'ne', 'se', 'sw', 'nw'],
|
||||
},
|
||||
draggable: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
this.isFullscreen = false;
|
||||
this.refreshRate = null;
|
||||
this.isGridDisabled = false;
|
||||
this.updateGridItems = null;
|
||||
this.showPermissionsControl = clientConfig.showPermissionsControl;
|
||||
this.globalParameters = [];
|
||||
this.refreshRates = [
|
||||
{ name: '10 seconds', rate: 10 },
|
||||
{ name: '30 seconds', rate: 30 },
|
||||
{ name: '1 minute', rate: 60 },
|
||||
{ name: '5 minutes', rate: 60 * 5 },
|
||||
{ name: '10 minutes', rate: 60 * 10 },
|
||||
{ name: '30 minutes', rate: 60 * 30 },
|
||||
{ name: '1 hour', rate: 60 * 60 },
|
||||
{ name: '12 hour', rate: 12 * 60 * 60 },
|
||||
{ name: '24 hour', rate: 24 * 60 * 60 },
|
||||
];
|
||||
|
||||
this.refreshRates =
|
||||
clientConfig.dashboardRefreshIntervals.map(interval => ({ name: durationHumanize(interval), rate: interval }));
|
||||
this.refreshRates = clientConfig.dashboardRefreshIntervals.map(interval => ({
|
||||
name: durationHumanize(interval),
|
||||
rate: interval,
|
||||
}));
|
||||
|
||||
$rootScope.$on('gridster-mobile-changed', ($event, gridster) => {
|
||||
this.isGridDisabled = gridster.isMobile;
|
||||
});
|
||||
|
||||
this.setRefreshRate = (rate) => {
|
||||
this.setRefreshRate = (rate, load = true) => {
|
||||
this.refreshRate = rate;
|
||||
if (rate !== null) {
|
||||
this.loadDashboard(true);
|
||||
if (load) {
|
||||
this.loadDashboard(true);
|
||||
}
|
||||
this.autoRefresh();
|
||||
}
|
||||
};
|
||||
@@ -124,8 +118,7 @@ function DashboardCtrl(
|
||||
};
|
||||
|
||||
const collectFilters = (dashboard, forceRefresh) => {
|
||||
const queryResultPromises = _.compact(this.dashboard.widgets.map(widget => widget.getQueryResult(forceRefresh)))
|
||||
.map(queryResult => queryResult.toPromise());
|
||||
const queryResultPromises = _.compact(this.dashboard.widgets.map(widget => widget.loadPromise(forceRefresh)));
|
||||
|
||||
$q.all(queryResultPromises).then((queryResults) => {
|
||||
const filters = {};
|
||||
@@ -171,9 +164,10 @@ function DashboardCtrl(
|
||||
};
|
||||
|
||||
this.loadDashboard = _.throttle((force) => {
|
||||
this.dashboard = Dashboard.get(
|
||||
Dashboard.get(
|
||||
{ slug: $routeParams.dashboardSlug },
|
||||
(dashboard) => {
|
||||
this.dashboard = dashboard;
|
||||
Events.record('view', 'dashboard', dashboard.id);
|
||||
renderDashboard(dashboard, force);
|
||||
|
||||
@@ -181,6 +175,20 @@ function DashboardCtrl(
|
||||
$location.search('edit', null);
|
||||
this.editLayout(true);
|
||||
}
|
||||
|
||||
if ($location.search().refresh !== undefined) {
|
||||
if (this.refreshRate === null) {
|
||||
const refreshRate = Math.max(30, parseFloat($location.search().refresh));
|
||||
|
||||
this.setRefreshRate(
|
||||
{
|
||||
name: durationHumanize(refreshRate),
|
||||
rate: refreshRate,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
(rejection) => {
|
||||
const statusGroup = Math.floor(rejection.status / 100);
|
||||
@@ -230,34 +238,25 @@ function DashboardCtrl(
|
||||
|
||||
this.editLayout = (enableEditing, applyChanges) => {
|
||||
if (!this.isGridDisabled) {
|
||||
if (enableEditing) {
|
||||
if (!this.layoutEditing) {
|
||||
// Save current positions of widgets
|
||||
_.each(this.dashboard.widgets, (widget) => {
|
||||
widget.$savedPosition = _.clone(widget.options.position);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!enableEditing) {
|
||||
if (applyChanges) {
|
||||
// Clear saved data and save layout
|
||||
_.each(this.dashboard.widgets, (widget) => {
|
||||
widget.$savedPosition = undefined;
|
||||
});
|
||||
saveDashboardLayout();
|
||||
const changedWidgets = getWidgetsWithChangedPositions(this.dashboard.widgets);
|
||||
saveDashboardLayout(changedWidgets);
|
||||
} else {
|
||||
// Revert changes
|
||||
const items = {};
|
||||
_.each(this.dashboard.widgets, (widget) => {
|
||||
if (_.isObject(widget.$savedPosition)) {
|
||||
widget.options.position = widget.$savedPosition;
|
||||
}
|
||||
widget.$savedPosition = undefined;
|
||||
_.extend(widget.options.position, widget.$originalPosition);
|
||||
items[widget.id] = widget.options.position;
|
||||
});
|
||||
this.dashboard.widgets = Dashboard.prepareWidgetsForDashboard(this.dashboard.widgets);
|
||||
if (this.updateGridItems) {
|
||||
this.updateGridItems(items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.layoutEditing = enableEditing;
|
||||
this.dashboardGridOptions.draggable.enabled = this.layoutEditing && !this.saveInProgress;
|
||||
this.dashboardGridOptions.resizable.enabled = this.layoutEditing && !this.saveInProgress;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -297,19 +296,10 @@ function DashboardCtrl(
|
||||
})
|
||||
.result.then(() => {
|
||||
this.extractGlobalParameters();
|
||||
if (this.layoutEditing) {
|
||||
// Save position of newly added widget (but not entire layout)
|
||||
const widget = _.last(this.dashboard.widgets);
|
||||
if (_.isObject(widget)) {
|
||||
return widget.$save().then(() => {
|
||||
if (this.layoutEditing) {
|
||||
widget.$savedPosition = _.clone(widget.options.position);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Update entire layout
|
||||
return saveDashboardLayout();
|
||||
// Save position of newly added widget (but not entire layout)
|
||||
const widget = _.last(this.dashboard.widgets);
|
||||
if (_.isObject(widget)) {
|
||||
return widget.$save();
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -317,10 +307,10 @@ function DashboardCtrl(
|
||||
this.removeWidget = () => {
|
||||
this.extractGlobalParameters();
|
||||
if (!this.layoutEditing) {
|
||||
// We need to wait a bit for `angular-gridster` before it updates widgets,
|
||||
// and only then save new layout
|
||||
// We need to wait a bit while `angular` updates widgets, and only then save new layout
|
||||
$timeout(() => {
|
||||
saveDashboardLayout();
|
||||
const changedWidgets = getWidgetsWithChangedPositions(this.dashboard.widgets);
|
||||
saveDashboardLayout(changedWidgets);
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
.dashboard-wrapper {
|
||||
.tile {
|
||||
display: flex;
|
||||
position: static;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin-bottom: 15px;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div {
|
||||
@@ -28,21 +35,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
.gridster-preview-holder {
|
||||
background: #aaa;
|
||||
}
|
||||
.dashboard-widget-wrapper:not(.widget-auto-height-enabled) {
|
||||
visualization-renderer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
&.gridster-mobile {
|
||||
margin: 0;
|
||||
> filters {
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.gridster-item {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
> div {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.gridster-mobile) {
|
||||
.tile {
|
||||
.sunburst-visualization-container,
|
||||
.sankey-visualization-container,
|
||||
.map-visualization-container,
|
||||
.plotly-chart-container {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
@@ -51,60 +67,27 @@
|
||||
width: auto;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.gridster-item:not(.gridster-auto-height-enabled) {
|
||||
visualization-renderer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
> filters {
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
> div {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.sunburst-visualization-container,
|
||||
.sankey-visualization-container,
|
||||
.map-visualization-container,
|
||||
.plotly-chart-container {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
counter {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 15px;
|
||||
right: 10px;
|
||||
bottom: 15px;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
counter {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 15px;
|
||||
right: 10px;
|
||||
bottom: 15px;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.gridster-auto-height-enabled {
|
||||
.widget-auto-height-enabled {
|
||||
.spinner {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.scrollbox {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="container m-t-10 m-b-20">
|
||||
<div class="container p-t-10 p-b-20">
|
||||
<page-header title="{{$ctrl.dashboard.name}}">
|
||||
</page-header>
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
<filters ng-if="$ctrl.dashboard.dashboard_filters_enabled"></filters>
|
||||
</div>
|
||||
|
||||
<div style="overflow: hidden">
|
||||
<div gridster="$ctrl.dashboardGridOptions" class="dashboard-wrapper">
|
||||
<div ng-repeat="widget in $ctrl.dashboard.widgets" gridster-item="widget.options.position"
|
||||
gridster-auto-height=".scrollbox, .spinner-container">
|
||||
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" public="true"></dashboard-widget>
|
||||
<div style="padding-bottom: 5px" ng-if="$ctrl.dashboard.widgets.length > 0">
|
||||
<div gridstack editing="false" class="dashboard-wrapper preview-mode">
|
||||
<div class="dashboard-widget-wrapper"
|
||||
ng-repeat="widget in $ctrl.dashboard.widgets"
|
||||
gridstack-item="widget.options.position" gridstack-item-id="{{ widget.id }}">
|
||||
<div class="grid-stack-item-content">
|
||||
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" public="true"></dashboard-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ export default function init(ngModule) {
|
||||
permission: 'admin',
|
||||
title: 'Alert Destinations',
|
||||
path: 'destinations',
|
||||
order: 4,
|
||||
});
|
||||
|
||||
ngModule.controller('DestinationsCtrl', DestinationsCtrl);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<div class="container">
|
||||
<empty-state title="Welcome to Redash 👋"
|
||||
description="Connect to any data source, easily visualize and share your data"
|
||||
show-dashboard-step="true"
|
||||
show-invite-step="true"
|
||||
onboarding-mode="true"
|
||||
help-link="http://help.redash.io/article/32-getting-started"></empty-state>
|
||||
|
||||
<div class="tile">
|
||||
<div class="t-body tb-padding">
|
||||
|
||||
@@ -50,6 +50,9 @@ class QueriesListCtrl {
|
||||
{ name: 'My Queries', path: 'queries/my' },
|
||||
{ name: 'Search', path: 'queries/search' },
|
||||
];
|
||||
|
||||
this.showList = () => this.paginator.getPageRows() !== undefined && this.paginator.getPageRows().length > 0;
|
||||
this.showEmptyState = () => this.paginator.getPageRows() !== undefined && this.paginator.getPageRows().length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
<div class="container">
|
||||
<page-header title="Queries"></page-header>
|
||||
<tab-nav tabs="$ctrl.tabs"></tab-nav>
|
||||
|
||||
<div class="bg-white tiled">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Created By</th>
|
||||
<th>Created At</th>
|
||||
<th>Runtime</th>
|
||||
<th>Last Executed At</th>
|
||||
<th>Update Schedule</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="query in $ctrl.paginator.getPageRows()">
|
||||
<td><a href="queries/{{query.id}}">{{query.name}}</a> <span class="label label-default" ng-if="query.is_draft">Unpublished</span></td>
|
||||
<td>
|
||||
<img ng-src="{{query.user.profile_image_url}}" class="profile__image_thumb"/>
|
||||
{{query.user.name}}
|
||||
</td>
|
||||
<td>{{query.created_at | dateTime}}</td>
|
||||
<td>{{query.runtime | durationHumanize}}</td>
|
||||
<td>{{query.retrieved_at | dateTime}}</td>
|
||||
<td>{{query.schedule | scheduleHumanize}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<paginator paginator="$ctrl.paginator"></paginator>
|
||||
<empty-state icon="fa fa-code" description="Getting the data from your datasources." help-link="http://help.redash.io/category/21-querying"
|
||||
ng-if="$ctrl.showEmptyState()"></empty-state>
|
||||
|
||||
<div ng-if="$ctrl.showList()">
|
||||
<tab-nav tabs="$ctrl.tabs"></tab-nav>
|
||||
|
||||
<div class="bg-white tiled">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Created By</th>
|
||||
<th>Created At</th>
|
||||
<th>Runtime</th>
|
||||
<th>Last Executed At</th>
|
||||
<th>Update Schedule</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="query in $ctrl.paginator.getPageRows()">
|
||||
<td>
|
||||
<a href="queries/{{query.id}}">{{query.name}}</a>
|
||||
<span class="label label-default" ng-if="query.is_draft">Unpublished</span>
|
||||
</td>
|
||||
<td>
|
||||
<img ng-src="{{query.user.profile_image_url}}" class="profile__image_thumb" /> {{query.user.name}}
|
||||
</td>
|
||||
<td>{{query.created_at | dateTime}}</td>
|
||||
<td>{{query.runtime | durationHumanize}}</td>
|
||||
<td>{{query.retrieved_at | dateTime}}</td>
|
||||
<td>{{query.schedule | scheduleHumanize}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<paginator paginator="$ctrl.paginator"></paginator>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,119 +118,123 @@
|
||||
</nav>
|
||||
|
||||
<div class="content">
|
||||
<div class="flex-fill d-flex flex-column p-l-15 p-r-15">
|
||||
<div class="row editor" resizable r-directions="['bottom']" r-flex="true" resizable-toggle
|
||||
style="min-height: 11px; max-height: 460px;" ng-if="sourceMode">
|
||||
<section>
|
||||
<div class="flex-fill p-relative">
|
||||
<div class="p-absolute d-flex flex-column p-l-15 p-r-15" style="left: 0; top: 0; right: 0; bottom: 0;">
|
||||
<div class="row editor" resizable r-directions="['bottom']" r-flex="true" resizable-toggle
|
||||
style="min-height: 11px; max-height: 460px;" ng-if="sourceMode">
|
||||
<section>
|
||||
|
||||
<div class="container p-15 m-b-10" style="height:100%;">
|
||||
<p style="height:calc(100% - 40px); margin-bottom: 0px;" class="editor__container">
|
||||
<query-editor query="query"
|
||||
schema="schema"
|
||||
syntax="dataSource.syntax"></query-editor>
|
||||
<div class="container p-15 m-b-10" style="height:100%;">
|
||||
<p style="height:calc(100% - 40px); margin-bottom: 0px;" class="editor__container">
|
||||
<query-editor query="query"
|
||||
schema="schema"
|
||||
syntax="dataSource.syntax"></query-editor>
|
||||
|
||||
<button type="button" class="btn btn-default btn-s btn__format pull-right" ng-click="formatQuery()" title="Format">
|
||||
<span class="zmdi zmdi-format-indent-increase"></span>
|
||||
</button>
|
||||
</p>
|
||||
<button type="button" class="btn btn-default btn-s btn__format pull-right" ng-click="formatQuery()" title="Format">
|
||||
<span class="zmdi zmdi-format-indent-increase"></span>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<div class="editor__control">
|
||||
<div class="row form-inline">
|
||||
<div class="col-xs-5 text-left">
|
||||
<select class="form-control datasource-small" ng-disabled="!isQueryOwner || !sourceMode" ng-model="query.data_source_id" ng-change="updateDataSource()"
|
||||
ng-options="ds.id as ds.name for ds in dataSources"></select>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-7">
|
||||
<div class="editor__control--right">
|
||||
<button class="btn btn-default" ng-show="canEdit" ng-click="saveQuery()" title="Save">
|
||||
<span class="fa fa-floppy-o"></span>
|
||||
<span class="hidden-xs">Save</span>
|
||||
<span
|
||||
ng-show="isDirty">*</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-primary" ng-disabled="queryExecuting || !canExecuteQuery()" ng-click="executeQuery()">
|
||||
<span class="zmdi zmdi-play"></span>
|
||||
<span class="hidden-xs">Execute</span>
|
||||
</button>
|
||||
<div class="editor__control">
|
||||
<div class="row form-inline">
|
||||
<div class="col-xs-5 text-left">
|
||||
<select class="form-control datasource-small" ng-disabled="!isQueryOwner || !sourceMode" ng-model="query.data_source_id" ng-change="updateDataSource()"
|
||||
ng-options="ds.id as ds.name for ds in dataSources"></select>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-7">
|
||||
<div class="editor__control--right">
|
||||
<button class="btn btn-default" ng-show="canEdit" ng-click="saveQuery()" title="Save">
|
||||
<span class="fa fa-floppy-o"></span>
|
||||
<span class="hidden-xs">Save</span>
|
||||
<span
|
||||
ng-show="isDirty">*</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-primary" ng-disabled="queryExecuting || !canExecuteQuery()" ng-click="executeQuery()">
|
||||
<span class="zmdi zmdi-play"></span>
|
||||
<span class="hidden-xs">Execute</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="row query-metadata__mobile">
|
||||
<div class="col-xs-4 text-left">
|
||||
<span class="m-r-5">Created by</span>
|
||||
<img ng-src="{{query.user.profile_image_url}}" class="profile__image_thumb"/> <strong><rd-time-ago value="query.created_at"></rd-time-ago></strong>
|
||||
</div>
|
||||
<div class="col-xs-4 text-center">
|
||||
<span class="m-r-5">Updated by</span>
|
||||
<img ng-src="{{query.last_modified_by.profile_image_url}}" class="profile__image_thumb"/> <strong><rd-time-ago value="query.updated_at"></rd-time-ago></strong>
|
||||
</div>
|
||||
<div class="col-xs-4 text-right">
|
||||
<span class="query-metadata__property"></span> Refresh Schedule</span>
|
||||
<a ng-click="openScheduleForm()" ng-if="!query.isNew()">{{query.schedule | scheduleHumanize}}</a>
|
||||
<span ng-if="query.isNew()">Never</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="flex-fill p-relative t-body">
|
||||
<div class="d-flex flex-column p-b-15 p-absolute" style="left: 0; top: 0; right: 0; bottom: 0;">
|
||||
<div class="p-t-15 p-b-15" ng-if="query.getParametersDefs().length > 0">
|
||||
<parameters parameters="query.getParametersDefs()" sync-values="!query.isNew()" editable="sourceMode && canEdit"></parameters>
|
||||
</div>
|
||||
<!-- Query Execution Status -->
|
||||
|
||||
<div class="query-alerts">
|
||||
<div class="alert alert-info m-t-15" ng-show="queryResult.getStatus() == 'processing'">
|
||||
Executing query…
|
||||
<rd-timer timestamp="queryResult.getUpdatedAt()"></rd-timer>
|
||||
<button type="button" class="btn btn-warning btn-xs pull-right" ng-disabled="cancelling"
|
||||
ng-click="cancelExecution()">Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-info m-t-15" ng-show="queryResult.getStatus() == 'waiting'">
|
||||
Query in queue…
|
||||
<rd-timer timestamp="queryResult.getUpdatedAt()"></rd-timer>
|
||||
<button type="button" class="btn btn-warning btn-xs pull-right" ng-disabled="cancelling"
|
||||
ng-click="cancelExecution()">Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-danger m-t-15" ng-show="queryResult.getError()">Error running query: <strong>{{queryResult.getError()}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End of Query Execution Status -->
|
||||
|
||||
<!-- tabs and data -->
|
||||
<div ng-if="showDataset" class="flex-fill p-relative">
|
||||
<div class="d-flex flex-column p-absolute" style="left: 0; top: 0; right: 0; bottom: 0;">
|
||||
<div class="p-10" ng-show="showLog">
|
||||
<p>Log Information:</p>
|
||||
<p ng-repeat="l in queryResult.getLog()">{{l}}</p>
|
||||
</div>
|
||||
<ul class="tab-nav">
|
||||
<rd-tab ng-if="!query.visualizations.length" tab-id="table" name="Table" base-path="query.getUrl(sourceMode)"></rd-tab>
|
||||
<rd-tab tab-id="{{vis.id}}" name="{{vis.name}}" base-path="query.getUrl(sourceMode)" ng-repeat="vis in query.visualizations | orderBy:'id'">
|
||||
<span class="remove" ng-click="deleteVisualization($event, vis)" ng-if="canEdit && !($first && (vis.type === 'TABLE'))"> ×</span>
|
||||
</rd-tab>
|
||||
<li class="rd-tab"><a ng-click="openVisualizationEditor()" ng-if="sourceMode && canEdit">+ New Visualization</a></li>
|
||||
</ul>
|
||||
<div ng-if="!query.visualizations.length" class="query__vis m-t-15 p-b-15 scrollbox">
|
||||
<filters filters="filters"></filters>
|
||||
<grid-renderer query-result="queryResult" items-per-page="50"></grid-renderer>
|
||||
</div>
|
||||
|
||||
<div ng-if="selectedTab == vis.id" ng-repeat="vis in query.visualizations" class="query__vis m-t-15 scrollbox">
|
||||
<visualization-renderer visualization="vis" query-result="queryResult"></visualization-renderer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="row query-metadata__mobile">
|
||||
<div class="col-xs-4 text-left">
|
||||
<span class="m-r-5">Created by</span>
|
||||
<img ng-src="{{query.user.profile_image_url}}" class="profile__image_thumb"/> <strong><rd-time-ago value="query.created_at"></rd-time-ago></strong>
|
||||
</div>
|
||||
<div class="col-xs-4 text-center">
|
||||
<span class="m-r-5">Updated by</span>
|
||||
<img ng-src="{{query.last_modified_by.profile_image_url}}" class="profile__image_thumb"/> <strong><rd-time-ago value="query.updated_at"></rd-time-ago></strong>
|
||||
</div>
|
||||
<div class="col-xs-4 text-right">
|
||||
<span class="query-metadata__property"></span> Refresh Schedule</span>
|
||||
<a ng-click="openScheduleForm()" ng-if="!query.isNew()">{{query.schedule | scheduleHumanize}}</a>
|
||||
<span ng-if="query.isNew()">Never</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="flex-fill d-flex flex-column">
|
||||
<div class="t-body p-b-15 flex-fill d-flex flex-column">
|
||||
<div class="p-t-15 p-b-15" ng-if="query.getParametersDefs().length > 0">
|
||||
<parameters parameters="query.getParametersDefs()" sync-values="!query.isNew()" editable="sourceMode && canEdit"></parameters>
|
||||
</div>
|
||||
<!-- Query Execution Status -->
|
||||
|
||||
<div class="query-alerts">
|
||||
<div class="alert alert-info m-t-15" ng-show="queryResult.getStatus() == 'processing'">
|
||||
Executing query…
|
||||
<rd-timer timestamp="queryResult.getUpdatedAt()"></rd-timer>
|
||||
<button type="button" class="btn btn-warning btn-xs pull-right" ng-disabled="cancelling"
|
||||
ng-click="cancelExecution()">Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-info m-t-15" ng-show="queryResult.getStatus() == 'waiting'">
|
||||
Query in queue…
|
||||
<rd-timer timestamp="queryResult.getUpdatedAt()"></rd-timer>
|
||||
<button type="button" class="btn btn-warning btn-xs pull-right" ng-disabled="cancelling"
|
||||
ng-click="cancelExecution()">Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-danger m-t-15" ng-show="queryResult.getError()">Error running query: <strong>{{queryResult.getError()}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End of Query Execution Status -->
|
||||
|
||||
<!-- tabs and data -->
|
||||
<div ng-if="showDataset" class="flex-fill d-flex flex-column">
|
||||
<div class="p-10" ng-show="showLog">
|
||||
<p>Log Information:</p>
|
||||
<p ng-repeat="l in queryResult.getLog()">{{l}}</p>
|
||||
</div>
|
||||
<ul class="tab-nav">
|
||||
<rd-tab ng-if="!query.visualizations.length" tab-id="table" name="Table" base-path="query.getUrl(sourceMode)"></rd-tab>
|
||||
<rd-tab tab-id="{{vis.id}}" name="{{vis.name}}" base-path="query.getUrl(sourceMode)" ng-repeat="vis in query.visualizations | orderBy:'id'">
|
||||
<span class="remove" ng-click="deleteVisualization($event, vis)" ng-if="canEdit && !($first && (vis.type === 'TABLE'))"> ×</span>
|
||||
</rd-tab>
|
||||
<li class="rd-tab"><a ng-click="openVisualizationEditor()" ng-if="sourceMode && canEdit">+ New Visualization</a></li>
|
||||
</ul>
|
||||
<div ng-if="!query.visualizations.length" class="query__vis m-t-15 p-b-15 scrollbox">
|
||||
<filters filters="filters"></filters>
|
||||
<grid-renderer query-result="queryResult" items-per-page="50"></grid-renderer>
|
||||
</div>
|
||||
|
||||
<div ng-if="selectedTab == vis.id" ng-repeat="vis in query.visualizations" class="query__vis m-t-15 scrollbox">
|
||||
<visualization-renderer visualization="vis" query-result="queryResult"></visualization-renderer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="bottom-controller-container">
|
||||
<div class="bottom-controller">
|
||||
@@ -243,7 +247,7 @@
|
||||
uib-dropdown-toggle aria-expanded="false">
|
||||
Download <span class="hidden-xs">Dataset </span><span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu pull-right" uib-dropdown-menu>
|
||||
<ul class="dropdown-menu" ng-class="{'pull-right': !query.isNew()}" uib-dropdown-menu>
|
||||
<li>
|
||||
<a query-result-link target="_self">
|
||||
<span class="fa fa-file-o"></span> Download as CSV File
|
||||
|
||||
@@ -71,14 +71,6 @@ function QuerySourceCtrl(
|
||||
.catch(error => toastr.error(error));
|
||||
};
|
||||
|
||||
$scope.duplicateQuery = () => {
|
||||
Events.record('fork', 'query', $scope.query.id);
|
||||
|
||||
Query.fork({ id: $scope.query.id }, (newQuery) => {
|
||||
$location.url(newQuery.getSourceLink()).replace();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.deleteVisualization = ($e, vis) => {
|
||||
$e.preventDefault();
|
||||
|
||||
|
||||
@@ -172,6 +172,14 @@ function QueryViewCtrl(
|
||||
});
|
||||
};
|
||||
|
||||
$scope.duplicateQuery = () => {
|
||||
Events.record('fork', 'query', $scope.query.id);
|
||||
|
||||
Query.fork({ id: $scope.query.id }, (newQuery) => {
|
||||
$location.url(newQuery.getSourceLink()).replace();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.saveQuery = (customOptions, data) => {
|
||||
let request = data;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function init(ngModule) {
|
||||
permission: 'create_query',
|
||||
title: 'Query Snippets',
|
||||
path: 'query_snippets',
|
||||
order: 5,
|
||||
});
|
||||
|
||||
ngModule.component('snippetsListPage', {
|
||||
|
||||
@@ -16,38 +16,64 @@
|
||||
<h3>Authentication</h3>
|
||||
<p>
|
||||
<label>
|
||||
<input name="input" type="checkbox" ng-model="$ctrl.settings.auth_password_login_enabled"
|
||||
ng-change="$ctrl.update('auth_password_login_enabled')" accesskey="tab">
|
||||
Password Login Enabled
|
||||
<input name="input" type="checkbox" ng-model="$ctrl.settings.auth_password_login_enabled" ng-change="$ctrl.update('auth_password_login_enabled')"
|
||||
accesskey="tab" ng-disabled="$ctrl.disablePasswordLoginToggle()"> Password Login Enabled
|
||||
<span uib-popover="Password login can be disabled only if another login method is enabled." popover-trigger="'mouseenter'"
|
||||
ng-if="$ctrl.disablePasswordLoginToggle()">
|
||||
<i class="fa fa-question-circle"></i>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="callout callout-warning" ng-if="!$ctrl.settings.auth_password_login_enabled">
|
||||
Password based login is currently disabled and users will be able to login only with the enabled SSO options.
|
||||
</div>
|
||||
</p>
|
||||
|
||||
<div ng-if="$ctrl.googleLoginEnabled">
|
||||
<h4>Google Login</h4>
|
||||
<label>
|
||||
Allowed Google Apps Domains
|
||||
</label>
|
||||
<ui-select multiple tagging tagging-label="false" ng-model="$ctrl.settings.auth_google_apps_domains" tagging-tokens="SPACE|,"
|
||||
title="Google Apps Domain(s)"
|
||||
ng-change="$ctrl.update('auth_google_apps_domains')">
|
||||
<ui-select-match placeholder="Google Apps Domain(s)">{{$item}}</ui-select-match>
|
||||
<!-- the ui-select-choices is here just to make ui-select work -->
|
||||
<ui-select-choices repeat="domain in $ctrl.domains">
|
||||
{{domain}}
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
|
||||
<div class="callout callout-info m-t-5" ng-if="$ctrl.settings.auth_google_apps_domains | notEmpty">
|
||||
Any user registered with a <strong>{{$ctrl.settings.auth_google_apps_domains | join}}</strong> Google Apps account will be able to login. If they don't have an existing user, a new user will be created and join the <strong>Default</strong> group.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>SAML</h4>
|
||||
<p>
|
||||
<label>
|
||||
<input name="input" type="checkbox" ng-model="$ctrl.settings.auth_saml_enabled"
|
||||
ng-change="$ctrl.update('auth_saml_enabled')" accesskey="tab">
|
||||
SAML Enabled
|
||||
<input name="input" type="checkbox" ng-model="$ctrl.settings.auth_saml_enabled" ng-change="$ctrl.update('auth_saml_enabled')"
|
||||
accesskey="tab"> SAML Enabled
|
||||
</label>
|
||||
|
||||
<div ng-show="$ctrl.settings.auth_saml_enabled">
|
||||
<div class="form-group">
|
||||
<label>SAML Metadata URL</label>
|
||||
<input name="input" type="string" class="form-control" ng-model="$ctrl.settings.auth_saml_metadata_url" accesskey="tab"
|
||||
ng-change="$ctrl.update('auth_saml_metadata_url')" ng-model-options="{ debounce: 200 }">
|
||||
<input name="input" type="string" class="form-control" ng-model="$ctrl.settings.auth_saml_metadata_url" accesskey="tab" ng-change="$ctrl.update('auth_saml_metadata_url')"
|
||||
ng-model-options="{ debounce: 200 }">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SAML Entity ID</label>
|
||||
<input name="input" type="string" class="form-control" ng-model="$ctrl.settings.auth_saml_entity_id" accesskey="tab"
|
||||
ng-change="$ctrl.update('auth_saml_entity_id')" ng-model-options="{ debounce: 200 }">
|
||||
<input name="input" type="string" class="form-control" ng-model="$ctrl.settings.auth_saml_entity_id" accesskey="tab" ng-change="$ctrl.update('auth_saml_entity_id')"
|
||||
ng-model-options="{ debounce: 200 }">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SAML NameID Format</label>
|
||||
<input name="input" type="string" class="form-control" ng-model="$ctrl.settings.auth_saml_nameid_format" accesskey="tab"
|
||||
ng-change="$ctrl.update('auth_saml_nameid_format')" ng-model-options="{ debounce: 200 }">
|
||||
ng-change="$ctrl.update('auth_saml_nameid_format')" ng-model-options="{ debounce: 200 }">
|
||||
</div>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</settings-screen>
|
||||
</settings-screen>
|
||||
@@ -1,7 +1,7 @@
|
||||
import settingsMenu from '@/lib/settings-menu';
|
||||
import template from './organization.html';
|
||||
|
||||
function OrganizationSettingsCtrl($http, toastr, Events) {
|
||||
function OrganizationSettingsCtrl($http, toastr, clientConfig, Events) {
|
||||
Events.record('view', 'page', 'org_settings');
|
||||
|
||||
this.settings = {};
|
||||
@@ -13,10 +13,20 @@ function OrganizationSettingsCtrl($http, toastr, Events) {
|
||||
$http.post('api/settings/organization', { [key]: this.settings[key] }).then((response) => {
|
||||
this.settings = response.data.settings;
|
||||
toastr.success('Settings changes saved.');
|
||||
|
||||
if (this.disablePasswordLoginToggle() && this.settings.auth_password_login_enabled === false) {
|
||||
this.settings.auth_password_login_enabled = true;
|
||||
this.update('auth_password_login_enabled');
|
||||
}
|
||||
}).catch(() => {
|
||||
toastr.error('Failed saving changes.');
|
||||
});
|
||||
};
|
||||
|
||||
this.googleLoginEnabled = clientConfig.googleLoginEnabled;
|
||||
|
||||
this.disablePasswordLoginToggle = () =>
|
||||
(clientConfig.googleLoginEnabled || this.settings.auth_saml_enabled) === false;
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
@@ -24,6 +34,7 @@ export default function init(ngModule) {
|
||||
permission: 'admin',
|
||||
title: 'Settings',
|
||||
path: 'settings/organization',
|
||||
order: 6,
|
||||
});
|
||||
|
||||
ngModule.component('organizationSettingsPage', {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div ng-if="user.created" class="alert alert-success alert-invited">
|
||||
<h4>The user has been created and should receive an invite email soon</h4>
|
||||
<p>You can use the following link to invite them yourself:</p>
|
||||
<textarea class="form-control" rows="2" disabled>{{user.invite_link}}</textarea>
|
||||
<textarea class="form-control m-t-10" rows="2" readonly>{{ inviteLink }}</textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { absoluteUrl } from '@/services/utils';
|
||||
import template from './new.html';
|
||||
|
||||
function NewUserCtrl($scope, $location, toastr, currentUser, Events, User) {
|
||||
function NewUserCtrl($scope, toastr, currentUser, Events, User) {
|
||||
Events.record('view', 'page', 'users/new');
|
||||
|
||||
$scope.inviteLink = '';
|
||||
|
||||
$scope.user = new User({});
|
||||
$scope.saveUser = () => {
|
||||
if (!this.userForm.$valid) {
|
||||
@@ -12,6 +15,7 @@ function NewUserCtrl($scope, $location, toastr, currentUser, Events, User) {
|
||||
$scope.user.$save((user) => {
|
||||
$scope.user = user;
|
||||
$scope.user.created = true;
|
||||
$scope.inviteLink = absoluteUrl(user.invite_link);
|
||||
toastr.success('Saved.');
|
||||
}, (error) => {
|
||||
const message = error.data.message || 'Failed saving.';
|
||||
|
||||
@@ -77,12 +77,12 @@
|
||||
|
||||
<div ng-if="passwordResetLink" class="alert alert-success">
|
||||
<p ng-if="!clientConfig.mailSettingMissing">
|
||||
<strong>The user should receive a link to reset his password by email soon.</strong>
|
||||
<strong>The user should receive a link to reset their password by email soon.</strong>
|
||||
</p>
|
||||
<p ng-if="clientConfig.mailSettingsMissing">
|
||||
You don't have mail server configured, please send the following link
|
||||
to {{user.name}} to reset their password:<br/>
|
||||
<a ng-href="passwordResetLink">{{passwordResetLink}}</a>
|
||||
<textarea class="form-control m-t-10" rows="2" readonly>{{ passwordResetLink }}</textarea>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { each } from 'underscore';
|
||||
import settingsMenu from '@/lib/settings-menu';
|
||||
import { absoluteUrl } from '@/services/utils';
|
||||
import template from './show.html';
|
||||
import './settings.less';
|
||||
|
||||
@@ -104,7 +105,7 @@ function UserCtrl(
|
||||
$scope.disablePasswordResetButton = true;
|
||||
$http.post(`api/users/${$scope.user.id}/reset_password`).success((data) => {
|
||||
$scope.disablePasswordResetButton = false;
|
||||
$scope.passwordResetLink = data.reset_link;
|
||||
$scope.passwordResetLink = absoluteUrl(data.reset_link);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -113,6 +114,7 @@ export default function init(ngModule) {
|
||||
settingsMenu.add({
|
||||
title: 'Account',
|
||||
path: 'users/me',
|
||||
order: 7,
|
||||
});
|
||||
|
||||
ngModule.controller('UserCtrl', UserCtrl);
|
||||
|
||||
@@ -1,8 +1,52 @@
|
||||
import * as _ from 'underscore';
|
||||
import _ from 'underscore';
|
||||
|
||||
function prepareWidgetsForDashboard(widgets) {
|
||||
// Default height for auto-height widgets.
|
||||
// Compute biggest widget size and choose between it and some magic number.
|
||||
// This value should be big enough so auto-height widgets will not overlap other ones.
|
||||
const defaultWidgetSizeY = Math.max(
|
||||
_.chain(widgets)
|
||||
.map(w => w.options.position.sizeY)
|
||||
.max()
|
||||
.value(),
|
||||
20,
|
||||
) + 5;
|
||||
|
||||
// Fix layout:
|
||||
// 1. sort and group widgets by row
|
||||
// 2. update position of widgets in each row - place it right below
|
||||
// biggest widget from previous row
|
||||
_.chain(widgets)
|
||||
.sortBy(widget => widget.options.position.row)
|
||||
.groupBy(widget => widget.options.position.row)
|
||||
.reduce((row, widgetsAtRow) => {
|
||||
let height = 1;
|
||||
_.each(widgetsAtRow, (widget) => {
|
||||
height = Math.max(
|
||||
height,
|
||||
widget.options.position.autoHeight
|
||||
? defaultWidgetSizeY
|
||||
: widget.options.position.sizeY,
|
||||
);
|
||||
widget.options.position.row = row;
|
||||
if (widget.options.position.sizeY < 1) {
|
||||
widget.options.position.sizeY = defaultWidgetSizeY;
|
||||
}
|
||||
});
|
||||
return row + height;
|
||||
}, 0)
|
||||
.value();
|
||||
|
||||
// Sort widgets by updated column and row value
|
||||
widgets = _.sortBy(widgets, widget => widget.options.position.col);
|
||||
widgets = _.sortBy(widgets, widget => widget.options.position.row);
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
function Dashboard($resource, $http, currentUser, Widget, dashboardGridOptions) {
|
||||
function prepareDashboardWidgets(widgets) {
|
||||
return widgets.map(widget => new Widget(widget));
|
||||
return prepareWidgetsForDashboard(_.map(widgets, widget => new Widget(widget)));
|
||||
}
|
||||
|
||||
function transformSingle(dashboard) {
|
||||
@@ -84,6 +128,7 @@ function Dashboard($resource, $http, currentUser, Widget, dashboardGridOptions)
|
||||
};
|
||||
|
||||
resource.prepareDashboardWidgets = prepareDashboardWidgets;
|
||||
resource.prepareWidgetsForDashboard = prepareWidgetsForDashboard;
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
@@ -31,8 +31,7 @@ function getColumnNameWithoutType(column) {
|
||||
}
|
||||
|
||||
export function getColumnCleanName(column) {
|
||||
const name = getColumnNameWithoutType(column);
|
||||
return name;
|
||||
return getColumnNameWithoutType(column);
|
||||
}
|
||||
|
||||
function getColumnFriendlyName(column) {
|
||||
@@ -120,6 +119,7 @@ function QueryResultService($resource, $timeout, $q) {
|
||||
});
|
||||
|
||||
each(this.query_result.data.columns, (column) => {
|
||||
column.name = '' + column.name;
|
||||
if (columnTypes[column.name]) {
|
||||
if (column.type == null || column.type === 'string') {
|
||||
column.type = columnTypes[column.name];
|
||||
@@ -183,9 +183,7 @@ function QueryResultService($resource, $timeout, $q) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = this.query_result.data.rows;
|
||||
|
||||
return data;
|
||||
return this.query_result.data.rows;
|
||||
}
|
||||
|
||||
getData() {
|
||||
@@ -262,12 +260,11 @@ function QueryResultService($resource, $timeout, $q) {
|
||||
let sizeValue = null;
|
||||
|
||||
each(row, (v, definition) => {
|
||||
const name = definition.split('::')[0] || definition.split('__')[0];
|
||||
definition = '' + definition;
|
||||
const definitionParts = definition.split('::') || definition.split('__');
|
||||
const name = definitionParts[0];
|
||||
const type = mapping ? mapping[definition] : definitionParts[1];
|
||||
let value = v;
|
||||
let type = definition.split('::')[1] || definition.split('__')[1];
|
||||
if (mapping) {
|
||||
type = mapping[definition];
|
||||
}
|
||||
|
||||
if (type === 'unused') {
|
||||
return;
|
||||
|
||||
8
client/app/services/utils.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
export function absoluteUrl(url) {
|
||||
const urlObj = new URL(url, window.location);
|
||||
urlObj.protocol = window.location.protocol;
|
||||
urlObj.host = window.location.host;
|
||||
return urlObj.toString();
|
||||
}
|
||||
@@ -1,21 +1,25 @@
|
||||
import { truncate } from 'underscore.string';
|
||||
import { pick, omit, flatten, extend, isObject } from 'underscore';
|
||||
import { pick, flatten, extend, isObject } from 'underscore';
|
||||
|
||||
function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
|
||||
function prepareForSave(data) {
|
||||
return omit(data, 'query');
|
||||
return pick(data, 'options', 'text', 'id', 'width', 'dashboard_id', 'visualization_id');
|
||||
}
|
||||
|
||||
const WidgetResource = $resource('api/widgets/:id', { id: '@id' }, {
|
||||
get: { method: 'GET' },
|
||||
save: {
|
||||
method: 'POST',
|
||||
transformRequest: flatten([prepareForSave, $http.defaults.transformRequest]),
|
||||
const WidgetResource = $resource(
|
||||
'api/widgets/:id',
|
||||
{ id: '@id' },
|
||||
{
|
||||
get: { method: 'GET' },
|
||||
save: {
|
||||
method: 'POST',
|
||||
transformRequest: flatten([prepareForSave, $http.defaults.transformRequest]),
|
||||
},
|
||||
query: { method: 'GET', isArray: true },
|
||||
remove: { method: 'DELETE' },
|
||||
delete: { method: 'DELETE' },
|
||||
},
|
||||
query: { method: 'GET', isArray: true },
|
||||
remove: { method: 'DELETE' },
|
||||
delete: { method: 'DELETE' },
|
||||
});
|
||||
);
|
||||
|
||||
WidgetResource.prototype.getQuery = function getQuery() {
|
||||
if (!this.query && this.visualization) {
|
||||
@@ -26,6 +30,10 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
|
||||
};
|
||||
|
||||
WidgetResource.prototype.getQueryResult = function getQueryResult(force, maxAge) {
|
||||
return this.load(force, maxAge);
|
||||
};
|
||||
|
||||
WidgetResource.prototype.load = function load(force, maxAge) {
|
||||
if (!this.visualization) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -40,6 +48,10 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
|
||||
return this.queryResult;
|
||||
};
|
||||
|
||||
WidgetResource.prototype.loadPromise = function loadPromise(force, maxAge) {
|
||||
return this.load(force, maxAge).toPromise();
|
||||
};
|
||||
|
||||
WidgetResource.prototype.getName = function getName() {
|
||||
if (this.visualization) {
|
||||
return `${this.visualization.query.name} (${this.visualization.name})`;
|
||||
@@ -51,25 +63,29 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
|
||||
widget.width = 1; // Backward compatibility, user on back-end
|
||||
|
||||
const visualizationOptions = {
|
||||
autoHeight: false,
|
||||
sizeX: Math.round(dashboardGridOptions.columns / 2),
|
||||
sizeY: -1, // auto-height
|
||||
sizeY: dashboardGridOptions.defaultSizeY,
|
||||
minSizeX: dashboardGridOptions.minSizeX,
|
||||
maxSizeX: dashboardGridOptions.maxSizeX,
|
||||
minSizeY: dashboardGridOptions.minSizeY,
|
||||
maxSizeY: dashboardGridOptions.maxSizeY,
|
||||
};
|
||||
const visualization = widget.visualization ?
|
||||
Visualization.visualizations[widget.visualization.type] : null;
|
||||
const visualization = widget.visualization ? Visualization.visualizations[widget.visualization.type] : null;
|
||||
if (isObject(visualization)) {
|
||||
const options = extend({}, visualization.defaultOptions);
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(options, 'autoHeight')) {
|
||||
visualizationOptions.autoHeight = options.autoHeight;
|
||||
}
|
||||
|
||||
// Width constraints
|
||||
const minColumns = parseInt(options.minColumns, 10);
|
||||
if (isFinite(minColumns) && (minColumns >= 0)) {
|
||||
if (isFinite(minColumns) && minColumns >= 0) {
|
||||
visualizationOptions.minSizeX = minColumns;
|
||||
}
|
||||
const maxColumns = parseInt(options.maxColumns, 10);
|
||||
if (isFinite(maxColumns) && (maxColumns >= 0)) {
|
||||
if (isFinite(maxColumns) && maxColumns >= 0) {
|
||||
visualizationOptions.maxSizeX = Math.min(maxColumns, dashboardGridOptions.columns);
|
||||
}
|
||||
|
||||
@@ -84,17 +100,17 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
|
||||
visualizationOptions.minSizeY = minRows;
|
||||
}
|
||||
const maxRows = parseInt(options.maxRows, 10);
|
||||
if (isFinite(maxRows) && (maxRows >= 0)) {
|
||||
if (isFinite(maxRows) && maxRows >= 0) {
|
||||
visualizationOptions.maxSizeY = maxRows;
|
||||
}
|
||||
|
||||
// Default dimensions
|
||||
const defaultWidth = parseInt(options.defaultColumns, 10);
|
||||
if (isFinite(defaultWidth) && (defaultWidth > 0)) {
|
||||
if (isFinite(defaultWidth) && defaultWidth > 0) {
|
||||
visualizationOptions.sizeX = defaultWidth;
|
||||
}
|
||||
const defaultHeight = parseInt(options.defaultRows, 10);
|
||||
if (isFinite(defaultHeight) && (defaultHeight > 0)) {
|
||||
if (isFinite(defaultHeight) && defaultHeight > 0) {
|
||||
visualizationOptions.sizeY = defaultHeight;
|
||||
}
|
||||
}
|
||||
@@ -103,16 +119,24 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
|
||||
widget.options.position = extend(
|
||||
{},
|
||||
visualizationOptions,
|
||||
pick(widget.options.position, ['col', 'row', 'sizeX', 'sizeY']),
|
||||
pick(widget.options.position, ['col', 'row', 'sizeX', 'sizeY', 'autoHeight']),
|
||||
);
|
||||
|
||||
return new WidgetResource(widget);
|
||||
if (widget.options.position.sizeY < 0) {
|
||||
widget.options.position.autoHeight = true;
|
||||
}
|
||||
|
||||
const result = new WidgetResource(widget);
|
||||
|
||||
// Save original position (create a shallow copy)
|
||||
result.$originalPosition = extend({}, result.options.position);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return WidgetConstructor;
|
||||
}
|
||||
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.factory('Widget', Widget);
|
||||
}
|
||||
|
||||
@@ -176,9 +176,15 @@ export default function init(ngModule) {
|
||||
|
||||
const editTemplate = '<boxplot-editor></boxplot-editor>';
|
||||
|
||||
const defaultOptions = {
|
||||
defaultRows: 8,
|
||||
minRows: 5,
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'BOXPLOT',
|
||||
name: 'Boxplot (Deprecated)',
|
||||
defaultOptions,
|
||||
renderTemplate,
|
||||
editorTemplate: editTemplate,
|
||||
});
|
||||
|
||||
@@ -85,7 +85,6 @@ function ChartEditor(ColorPalette, clientConfig) {
|
||||
scope.options.seriesOptions[key].type = scope.options.globalSeriesType;
|
||||
});
|
||||
};
|
||||
scope.chartTypeChanged();
|
||||
|
||||
scope.showSizeColumnPicker = () => some(scope.options.seriesOptions, options => options.type === 'bubble');
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ const PlotlyChart = () => ({
|
||||
}
|
||||
}, true);
|
||||
|
||||
scope.handleResize = debounce(updateChartDimensions, 100);
|
||||
scope.handleResize = debounce(updateChartDimensions, 50);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,37 +3,37 @@ import {
|
||||
each, values, sortBy, pluck, identity, filter, map,
|
||||
} from 'underscore';
|
||||
import moment from 'moment';
|
||||
import createFormatter from '@/lib/value-format';
|
||||
import { createFormatter } from '@/lib/value-format';
|
||||
|
||||
// The following colors will be used if you pick "Automatic" color.
|
||||
const BaseColors = {
|
||||
Blue: '#4572A7',
|
||||
Red: '#AA4643',
|
||||
Green: '#89A54E',
|
||||
Purple: '#80699B',
|
||||
Cyan: '#3D96AE',
|
||||
Orange: '#DB843D',
|
||||
'Light Blue': '#92A8CD',
|
||||
Lilac: '#A47D7C',
|
||||
'Light Green': '#B5CA92',
|
||||
Brown: '#A52A2A',
|
||||
Blue: '#356AFF',
|
||||
Red: '#E92828',
|
||||
Green: '#3BD973',
|
||||
Purple: '#604FE9',
|
||||
Cyan: '#50F5ED',
|
||||
Orange: '#FB8D3D',
|
||||
'Light Blue': '#799CFF',
|
||||
Lilac: '#B554FF',
|
||||
'Light Green': '#8CFFB4',
|
||||
Brown: '#A55F2A',
|
||||
Black: '#000000',
|
||||
Gray: '#808080',
|
||||
Pink: '#FFC0CB',
|
||||
'Dark Blue': '#00008b',
|
||||
Gray: '#494949',
|
||||
Pink: '#FF7DE3',
|
||||
'Dark Blue': '#002FB4',
|
||||
};
|
||||
|
||||
// Additional colors for the user to choose from:
|
||||
export const ColorPalette = Object.assign({}, BaseColors, {
|
||||
'Indian Red': '#F8766D',
|
||||
'Green 2': '#53B400',
|
||||
'Green 3': '#00C094',
|
||||
'Indian Red': '#981717',
|
||||
'Green 2': '#17BF51',
|
||||
'Green 3': '#049235',
|
||||
DarkTurquoise: '#00B6EB',
|
||||
'Dark Violet': '#A58AFF',
|
||||
'Pink 2': '#FB61D7',
|
||||
'Pink 2': '#C63FA9',
|
||||
});
|
||||
|
||||
const formatNumber = createFormatter({ displayAs: 'number', numberFormat: '0,0[.]00' });
|
||||
const formatNumber = createFormatter({ displayAs: 'number', numberFormat: '0,0[.]00000' });
|
||||
const formatPercent = createFormatter({ displayAs: 'number', numberFormat: '0[.]00' });
|
||||
|
||||
const ColorPaletteArray = values(BaseColors);
|
||||
@@ -148,8 +148,10 @@ function calculateDimensions(series, options) {
|
||||
|
||||
const hasX = contains(values(options.columnMapping), 'x');
|
||||
const hasY2 = !!find(series, (serie) => {
|
||||
const serieOptions = options.seriesOptions[serie.name] || { type: options.globalSeriesType };
|
||||
return (serieOptions.yAxis === 1) && (options.series.stacking === null);
|
||||
const seriesOptions = options.seriesOptions[serie.name] || { type: options.globalSeriesType };
|
||||
return (seriesOptions.yAxis === 1) && (
|
||||
(options.series.stacking === null) || (seriesOptions.type === 'line')
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -191,7 +193,7 @@ function preparePieData(seriesList, options) {
|
||||
marker: { colors: ColorPaletteArray },
|
||||
text: serie.name,
|
||||
textposition: 'inside',
|
||||
textfont: { color: '#f5f5f5' },
|
||||
textfont: { color: '#ffffff' },
|
||||
name: serie.name,
|
||||
domain: {
|
||||
x: [xPosition, xPosition + cellWidth - xPadding],
|
||||
@@ -249,7 +251,10 @@ function prepareChartData(seriesList, options) {
|
||||
sourceData,
|
||||
};
|
||||
|
||||
if ((seriesOptions.yAxis === 1) && (options.series.stacking === null)) {
|
||||
if (
|
||||
(seriesOptions.yAxis === 1) &&
|
||||
((options.series.stacking === null) || (seriesOptions.type === 'line'))
|
||||
) {
|
||||
plotlySeries.yaxis = 'y2';
|
||||
}
|
||||
|
||||
@@ -261,6 +266,7 @@ function prepareChartData(seriesList, options) {
|
||||
};
|
||||
} else if (seriesOptions.type === 'box') {
|
||||
plotlySeries.boxpoints = 'outliers';
|
||||
plotlySeries.hoverinfo = false;
|
||||
plotlySeries.marker = {
|
||||
color: seriesColor,
|
||||
size: 3,
|
||||
@@ -326,6 +332,10 @@ export function prepareLayout(element, seriesList, options, data) {
|
||||
type: getScaleType(options.xAxis.type),
|
||||
};
|
||||
|
||||
if (options.sortX && result.xaxis.type === 'category') {
|
||||
result.xaxis.categoryorder = 'category ascending';
|
||||
}
|
||||
|
||||
if (!isUndefined(options.xAxis.labels)) {
|
||||
result.xaxis.showticklabels = options.xAxis.labels.enabled;
|
||||
}
|
||||
@@ -373,14 +383,18 @@ export function prepareLayout(element, seriesList, options, data) {
|
||||
function updateSeriesText(seriesList, options) {
|
||||
each(seriesList, (series) => {
|
||||
series.text = [];
|
||||
series.sourceData.forEach((item) => {
|
||||
let text = formatNumber(item.y);
|
||||
if (item.yError !== undefined) {
|
||||
text = `${text} \u00B1 ${formatNumber(item.yError)}`;
|
||||
}
|
||||
series.x.forEach((x) => {
|
||||
let text = null;
|
||||
const item = series.sourceData.get(x);
|
||||
if (item) {
|
||||
text = formatNumber(item.y);
|
||||
if (item.yError !== undefined) {
|
||||
text = `${text} \u00B1 ${formatNumber(item.yError)}`;
|
||||
}
|
||||
|
||||
if (options.series.percentValues) {
|
||||
text = `${formatPercent(Math.abs(item.yPercent))}% (${text})`;
|
||||
if (options.series.percentValues) {
|
||||
text = `${formatPercent(Math.abs(item.yPercent))}% (${text})`;
|
||||
}
|
||||
}
|
||||
|
||||
series.text.push(text);
|
||||
|
||||
251
client/app/visualizations/choropleth/choropleth-editor.html
Normal file
@@ -0,0 +1,251 @@
|
||||
<div>
|
||||
<ul class="tab-nav">
|
||||
<li ng-class="{active: currentTab == 'general'}">
|
||||
<a ng-click="changeTab('general')">General</a>
|
||||
</li>
|
||||
<li ng-class="{active: currentTab == 'colors'}">
|
||||
<a ng-click="changeTab('colors')">Colors</a>
|
||||
</li>
|
||||
<li ng-class="{active: currentTab == 'bounds'}">
|
||||
<a ng-click="changeTab('bounds')">Bounds</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div ng-if="currentTab == 'general'" class="m-t-10 m-b-10">
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="form-group">
|
||||
<label>Country code column</label>
|
||||
<select ng-options="name for name in queryResult.getColumnNames()"
|
||||
ng-model="options.countryCodeColumn" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-6">
|
||||
<div class="form-group">
|
||||
<label>Country code type</label>
|
||||
<select ng-options="key as value for (key, value) in countryCodeTypes"
|
||||
ng-model="options.countryCodeType" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="form-group">
|
||||
<label>Value column</label>
|
||||
<select ng-options="name for name in queryResult.getColumnNames()"
|
||||
ng-model="options.valueColumn" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-6">
|
||||
<div class="form-group">
|
||||
<label for="legend-value-format">
|
||||
Value format
|
||||
<span class="m-l-5"
|
||||
uib-popover-html="'Format <a href="http://numeraljs.com/" target="_blank">specs.</a>'"
|
||||
popover-trigger="'click outsideClick'"><i class="fa fa-question-circle"></i></span>
|
||||
</label>
|
||||
<input class="form-control" id="legend-value-format"
|
||||
ng-model="options.valueFormat" ng-model-options="{ allowInvalid: true, debounce: 200 }">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-6">
|
||||
<div class="form-group">
|
||||
<label for="legend-value-placeholder">Value placeholder</label>
|
||||
<input class="form-control" id="legend-value-placeholder"
|
||||
ng-model="options.noValuePlaceholder" ng-model-options="{ allowInvalid: true, debounce: 200 }">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" ng-model="options.legend.visible"> Show legend</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="form-group">
|
||||
<label for="legend-position">Legend position</label>
|
||||
<select class="form-control" id="legend-position"
|
||||
ng-options="key as value for (key, value) in legendPositions"
|
||||
ng-model="options.legend.position"
|
||||
ng-disabled="!options.legend.visible"
|
||||
></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-6">
|
||||
<div class="form-group">
|
||||
<label for="legend-position">Legend text alignment</label>
|
||||
<div class="btn-group d-flex">
|
||||
<button type="button" class="btn btn-default btn-md flex-fill"
|
||||
ng-click="options.legend.alignText = 'left'"
|
||||
ng-class="{active: options.legend.alignText == 'left'}"><i class="fa fa-align-left"></i></button>
|
||||
<button type="button" class="btn btn-default btn-md flex-fill"
|
||||
ng-click="options.legend.alignText = 'center'"
|
||||
ng-class="{active: options.legend.alignText == 'center'}"><i class="fa fa-align-center"></i></button>
|
||||
<button type="button" class="btn btn-default btn-md flex-fill"
|
||||
ng-click="options.legend.alignText = 'right'"
|
||||
ng-class="{active: options.legend.alignText == 'right'}"><i class="fa fa-align-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label><input type="checkbox" ng-model="options.tooltip.enabled"> Show tooltip</label>
|
||||
<div class="form-group">
|
||||
<label for="tooltip-template">Tooltip template</label>
|
||||
<input class="form-control" id="tooltip-template"
|
||||
ng-model="options.tooltip.template" ng-model-options="{ allowInvalid: true, debounce: 200 }"
|
||||
ng-disabled="!options.tooltip.enabled">
|
||||
</div>
|
||||
|
||||
<label><input type="checkbox" ng-model="options.popup.enabled"> Show popup</label>
|
||||
<div class="form-group">
|
||||
<label for="popup-template">Popup template</label>
|
||||
<textarea class="form-control resize-vertical" id="popup-template" rows="3"
|
||||
ng-model="options.popup.template" ng-model-options="{ allowInvalid: true, debounce: 200 }"
|
||||
ng-disabled="!options.popup.enabled"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="ui-sortable-bypass text-muted" style="font-weight: normal; cursor: pointer;"
|
||||
uib-popover-html="templateHint"
|
||||
popover-trigger="'click outsideClick'" popover-placement="top-left">
|
||||
Format specs <i class="fa fa-question-circle m-l-5"></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="currentTab == 'colors'" class="m-t-10 m-b-10">
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="form-group">
|
||||
<label>Steps</label>
|
||||
<input type="number" min="3" max="11" class="form-control"
|
||||
ng-model="options.steps">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-6">
|
||||
<div class="form-group">
|
||||
<label>Clustering mode</label>
|
||||
<select ng-options="key as value for (key, value) in clusteringModes"
|
||||
ng-model="options.clusteringMode" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="form-group">
|
||||
<label>Min color</label>
|
||||
<ui-select ng-model="options.colors.min">
|
||||
<ui-select-match>
|
||||
<color-box color="$select.selected.value"></color-box>
|
||||
<span ng-bind-html="$select.selected.key | capitalize"></span>
|
||||
</ui-select-match>
|
||||
<ui-select-choices repeat="color.value as (key, color) in colors">
|
||||
<color-box color="color.value"></color-box>
|
||||
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-6">
|
||||
<div class="form-group">
|
||||
<label>Max color</label>
|
||||
<ui-select ng-model="options.colors.max">
|
||||
<ui-select-match>
|
||||
<color-box color="$select.selected.value"></color-box>
|
||||
<span ng-bind-html="$select.selected.key | capitalize"></span>
|
||||
</ui-select-match>
|
||||
<ui-select-choices repeat="color.value as (key, color) in colors">
|
||||
<color-box color="color.value"></color-box>
|
||||
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-6">
|
||||
<div class="form-group">
|
||||
<label>No value color</label>
|
||||
<ui-select ng-model="options.colors.noValue">
|
||||
<ui-select-match>
|
||||
<color-box color="$select.selected.value"></color-box>
|
||||
<span ng-bind-html="$select.selected.key | capitalize"></span>
|
||||
</ui-select-match>
|
||||
<ui-select-choices repeat="color.value as (key, color) in colors">
|
||||
<color-box color="color.value"></color-box>
|
||||
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="form-group">
|
||||
<label>Background color</label>
|
||||
<ui-select ng-model="options.colors.background">
|
||||
<ui-select-match>
|
||||
<color-box color="$select.selected.value"></color-box>
|
||||
<span ng-bind-html="$select.selected.key | capitalize"></span>
|
||||
</ui-select-match>
|
||||
<ui-select-choices repeat="color.value as (key, color) in colors">
|
||||
<color-box color="color.value"></color-box>
|
||||
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-6">
|
||||
<div class="form-group">
|
||||
<label>Borders color</label>
|
||||
<ui-select ng-model="options.colors.borders">
|
||||
<ui-select-match>
|
||||
<color-box color="$select.selected.value"></color-box>
|
||||
<span ng-bind-html="$select.selected.key | capitalize"></span>
|
||||
</ui-select-match>
|
||||
<ui-select-choices repeat="color.value as (key, color) in colors">
|
||||
<color-box color="color.value"></color-box>
|
||||
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="currentTab == 'bounds'" class="m-t-10 m-b-10">
|
||||
<div class="form-group">
|
||||
<label>North-East latitude and longitude</label>
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<input class="form-control" type="text"
|
||||
ng-model="options.bounds[1][0]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
|
||||
</div>
|
||||
<div class="col-xs-6">
|
||||
<input class="form-control" type="text"
|
||||
ng-model="options.bounds[1][1]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>South-West latitude and longitude</label>
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<input class="form-control" type="text"
|
||||
ng-model="options.bounds[0][0]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
|
||||
</div>
|
||||
<div class="col-xs-6">
|
||||
<input class="form-control" type="text"
|
||||
ng-model="options.bounds[0][1]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
11
client/app/visualizations/choropleth/choropleth.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<div class="map-visualization-container">
|
||||
<div resize-event="handleResize()" ng-style="{ background: options.colors.background }"></div>
|
||||
<div ng-if="options.legend.visible && (legendItems.length > 0)"
|
||||
class="leaflet-bar map-custom-control" ng-class="options.legend.position"
|
||||
>
|
||||
<div ng-repeat="item in legendItems" class="d-flex align-items-center">
|
||||
<color-box color="item.color" class="m-0" style="line-height: 1px"></color-box>
|
||||
<div class="flex-fill text-{{ options.legend.alignText }}">{{ formatValue(item.limit) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
1
client/app/visualizations/choropleth/countries.geo.json
Normal file
311
client/app/visualizations/choropleth/index.js
Normal file
@@ -0,0 +1,311 @@
|
||||
import _ from 'underscore';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { formatSimpleTemplate } from '@/lib/value-format';
|
||||
import 'leaflet-fullscreen';
|
||||
import 'leaflet-fullscreen/dist/leaflet.fullscreen.css';
|
||||
|
||||
import {
|
||||
AdditionalColors,
|
||||
darkenColor,
|
||||
createNumberFormatter,
|
||||
prepareData,
|
||||
getValueForFeature,
|
||||
createScale,
|
||||
prepareFeatureProperties,
|
||||
getColorByValue,
|
||||
inferCountryCodeType,
|
||||
} from './utils';
|
||||
|
||||
import template from './choropleth.html';
|
||||
import editorTemplate from './choropleth-editor.html';
|
||||
|
||||
import countriesDataUrl from './countries.geo.json';
|
||||
|
||||
const loadCountriesData = _.bind(function loadCountriesData($http, url) {
|
||||
if (!this[url]) {
|
||||
this[url] = $http.get(url).then(response => response.data);
|
||||
}
|
||||
return this[url];
|
||||
}, {});
|
||||
|
||||
function choroplethRenderer($sanitize, $http) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template,
|
||||
scope: {
|
||||
queryResult: '=',
|
||||
options: '=?',
|
||||
},
|
||||
link($scope, $element) {
|
||||
let countriesData = null;
|
||||
let map = null;
|
||||
let choropleth = null;
|
||||
let updateBoundsLock = false;
|
||||
|
||||
function getBounds() {
|
||||
if (!updateBoundsLock) {
|
||||
const bounds = map.getBounds();
|
||||
$scope.options.bounds = [
|
||||
[bounds._southWest.lat, bounds._southWest.lng],
|
||||
[bounds._northEast.lat, bounds._northEast.lng],
|
||||
];
|
||||
$scope.$applyAsync();
|
||||
}
|
||||
}
|
||||
|
||||
function setBounds({ disableAnimation = false } = {}) {
|
||||
if (map && choropleth) {
|
||||
const bounds = $scope.options.bounds || choropleth.getBounds();
|
||||
const options = disableAnimation ? {
|
||||
animate: false,
|
||||
duration: 0,
|
||||
} : null;
|
||||
map.fitBounds(bounds, options);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (map) {
|
||||
map.remove();
|
||||
map = null;
|
||||
choropleth = null;
|
||||
}
|
||||
if (!countriesData) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.formatValue = createNumberFormatter(
|
||||
$scope.options.valueFormat,
|
||||
$scope.options.noValuePlaceholder,
|
||||
);
|
||||
|
||||
const data = prepareData(
|
||||
$scope.queryResult.getData(),
|
||||
$scope.options.countryCodeColumn,
|
||||
$scope.options.valueColumn,
|
||||
);
|
||||
|
||||
const { limits, colors, legend } = createScale(
|
||||
countriesData.features,
|
||||
data,
|
||||
$scope.options,
|
||||
);
|
||||
|
||||
// Update data for legend block
|
||||
$scope.legendItems = legend;
|
||||
|
||||
choropleth = L.geoJson(countriesData, {
|
||||
onEachFeature: (feature, layer) => {
|
||||
const value = getValueForFeature(feature, data, $scope.options.countryCodeType);
|
||||
const valueFormatted = $scope.formatValue(value);
|
||||
const featureData = prepareFeatureProperties(
|
||||
feature,
|
||||
valueFormatted,
|
||||
data,
|
||||
$scope.options.countryCodeType,
|
||||
);
|
||||
const color = getColorByValue(value, limits, colors, $scope.options.colors.noValue);
|
||||
|
||||
layer.setStyle({
|
||||
color: $scope.options.colors.borders,
|
||||
weight: 1,
|
||||
fillColor: color,
|
||||
fillOpacity: 1,
|
||||
});
|
||||
|
||||
if ($scope.options.tooltip.enabled) {
|
||||
layer.bindTooltip($sanitize(formatSimpleTemplate(
|
||||
$scope.options.tooltip.template,
|
||||
featureData,
|
||||
)));
|
||||
}
|
||||
|
||||
if ($scope.options.popup.enabled) {
|
||||
layer.bindPopup($sanitize(formatSimpleTemplate(
|
||||
$scope.options.popup.template,
|
||||
featureData,
|
||||
)));
|
||||
}
|
||||
|
||||
layer.on('mouseover', () => {
|
||||
layer.setStyle({
|
||||
weight: 2,
|
||||
fillColor: darkenColor(color),
|
||||
});
|
||||
});
|
||||
layer.on('mouseout', () => {
|
||||
layer.setStyle({
|
||||
weight: 1,
|
||||
fillColor: color,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const choroplethBounds = choropleth.getBounds();
|
||||
|
||||
map = L.map($element[0].children[0].children[0], {
|
||||
center: choroplethBounds.getCenter(),
|
||||
zoom: 1,
|
||||
zoomSnap: 0,
|
||||
layers: [choropleth],
|
||||
scrollWheelZoom: false,
|
||||
maxBounds: choroplethBounds,
|
||||
maxBoundsViscosity: 1,
|
||||
attributionControl: false,
|
||||
fullscreenControl: true,
|
||||
});
|
||||
|
||||
map.on('focus', () => { map.on('moveend', getBounds); });
|
||||
map.on('blur', () => { map.off('moveend', getBounds); });
|
||||
|
||||
setBounds({ disableAnimation: true });
|
||||
}
|
||||
|
||||
loadCountriesData($http, countriesDataUrl).then((data) => {
|
||||
if (_.isObject(data)) {
|
||||
countriesData = data;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.handleResize = _.debounce(() => {
|
||||
if (map) {
|
||||
map.invalidateSize(false);
|
||||
setBounds({ disableAnimation: true });
|
||||
}
|
||||
}, 50);
|
||||
|
||||
$scope.$watch('queryResult && queryResult.getData()', render);
|
||||
$scope.$watch(() => _.omit($scope.options, 'bounds'), render, true);
|
||||
$scope.$watch('options.bounds', () => {
|
||||
// Prevent infinite digest loop
|
||||
const savedLock = updateBoundsLock;
|
||||
updateBoundsLock = true;
|
||||
setBounds();
|
||||
updateBoundsLock = savedLock;
|
||||
}, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function choroplethEditor(ChoroplethPalette) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: editorTemplate,
|
||||
scope: {
|
||||
queryResult: '=',
|
||||
options: '=?',
|
||||
},
|
||||
link($scope) {
|
||||
$scope.currentTab = 'general';
|
||||
$scope.changeTab = (tab) => {
|
||||
$scope.currentTab = tab;
|
||||
};
|
||||
|
||||
$scope.colors = ChoroplethPalette;
|
||||
|
||||
$scope.clusteringModes = {
|
||||
q: 'quantile',
|
||||
e: 'equidistant',
|
||||
k: 'k-means',
|
||||
};
|
||||
|
||||
$scope.legendPositions = {
|
||||
'top-left': 'top / left',
|
||||
'top-right': 'top / right',
|
||||
'bottom-left': 'bottom / left',
|
||||
'bottom-right': 'bottom / right',
|
||||
};
|
||||
|
||||
$scope.countryCodeTypes = {
|
||||
name: 'Short name',
|
||||
name_long: 'Full name',
|
||||
abbrev: 'Abbreviated name',
|
||||
iso_a2: 'ISO code (2 letters)',
|
||||
iso_a3: 'ISO code (3 letters)',
|
||||
iso_n3: 'ISO code (3 digits)',
|
||||
};
|
||||
|
||||
$scope.templateHint = `
|
||||
<div class="p-b-5">All query result columns can be referenced using <code>{{ column_name }}</code> syntax.</div>
|
||||
<div class="p-b-5">Use special names to access additional properties:</div>
|
||||
<div><code>{{ @@value }}</code> formatted value;</div>
|
||||
<div><code>{{ @@name }}</code> short country name;</div>
|
||||
<div><code>{{ @@name_long }}</code> full country name;</div>
|
||||
<div><code>{{ @@abbrev }}</code> abbreviated country name;</div>
|
||||
<div><code>{{ @@iso_a2 }}</code> two-letter ISO country code;</div>
|
||||
<div><code>{{ @@iso_a3 }}</code> three-letter ISO country code;</div>
|
||||
<div><code>{{ @@iso_n3 }}</code> three-digit ISO country code.</div>
|
||||
<div class="p-t-5">This syntax is applicable to tooltip and popup templates.</div>
|
||||
`;
|
||||
|
||||
function updateCountryCodeType() {
|
||||
$scope.options.countryCodeType = inferCountryCodeType(
|
||||
$scope.queryResult.getData(),
|
||||
$scope.options.countryCodeColumn,
|
||||
) || $scope.options.countryCodeType;
|
||||
}
|
||||
|
||||
$scope.$watch('options.countryCodeColumn', updateCountryCodeType);
|
||||
$scope.$watch('queryResult.getData()', updateCountryCodeType);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.constant('ChoroplethPalette', {});
|
||||
ngModule.directive('choroplethRenderer', choroplethRenderer);
|
||||
ngModule.directive('choroplethEditor', choroplethEditor);
|
||||
ngModule.config((VisualizationProvider, ColorPalette, ChoroplethPalette) => {
|
||||
_.extend(ChoroplethPalette, AdditionalColors, ColorPalette);
|
||||
|
||||
const renderTemplate =
|
||||
'<choropleth-renderer options="visualization.options" query-result="queryResult"></choropleth-renderer>';
|
||||
|
||||
const editTemplate = '<choropleth-editor options="visualization.options" query-result="queryResult"></choropleth-editor>';
|
||||
|
||||
const defaultOptions = {
|
||||
defaultColumns: 3,
|
||||
defaultRows: 8,
|
||||
minColumns: 2,
|
||||
|
||||
countryCodeColumn: '',
|
||||
countryCodeType: 'iso_a3',
|
||||
valueColumn: '',
|
||||
clusteringMode: 'e',
|
||||
steps: 5,
|
||||
valueFormat: '0,0.00',
|
||||
noValuePlaceholder: 'N/A',
|
||||
colors: {
|
||||
min: ChoroplethPalette['Light Blue'],
|
||||
max: ChoroplethPalette['Dark Blue'],
|
||||
background: ChoroplethPalette.White,
|
||||
borders: ChoroplethPalette.White,
|
||||
noValue: ChoroplethPalette['Light Gray'],
|
||||
},
|
||||
legend: {
|
||||
visible: true,
|
||||
position: 'bottom-left',
|
||||
alignText: 'right',
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
template: '<b>{{ @@name }}</b>: {{ @@value }}',
|
||||
},
|
||||
popup: {
|
||||
enabled: true,
|
||||
template: 'Country: <b>{{ @@name_long }} ({{ @@iso_a2 }})</b>\n<br>\nValue: <b>{{ @@value }}</b>',
|
||||
},
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'CHOROPLETH',
|
||||
name: 'Map (Choropleth)',
|
||||
renderTemplate,
|
||||
editorTemplate: editTemplate,
|
||||
defaultOptions,
|
||||
});
|
||||
});
|
||||
}
|
||||
141
client/app/visualizations/choropleth/utils.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import chroma from 'chroma-js';
|
||||
import _ from 'underscore';
|
||||
import { createFormatter } from '@/lib/value-format';
|
||||
|
||||
export const AdditionalColors = {
|
||||
White: '#ffffff',
|
||||
Black: '#000000',
|
||||
'Light Gray': '#dddddd',
|
||||
};
|
||||
|
||||
export function darkenColor(color) {
|
||||
return chroma(color).darken().hex();
|
||||
}
|
||||
|
||||
export function createNumberFormatter(format, placeholder) {
|
||||
const formatter = createFormatter({
|
||||
displayAs: 'number',
|
||||
numberFormat: format,
|
||||
});
|
||||
return (value) => {
|
||||
if (_.isNumber(value) && isFinite(value)) {
|
||||
return formatter(value);
|
||||
}
|
||||
return placeholder;
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareData(data, countryCodeField, valueField) {
|
||||
if (!countryCodeField || !valueField) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result = {};
|
||||
_.each(data, (item) => {
|
||||
if (item[countryCodeField]) {
|
||||
const value = parseFloat(item[valueField]);
|
||||
result[item[countryCodeField]] = {
|
||||
code: item[countryCodeField],
|
||||
value: isFinite(value) ? value : undefined,
|
||||
item,
|
||||
};
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function prepareFeatureProperties(feature, valueFormatted, data, countryCodeType) {
|
||||
const result = {};
|
||||
_.each(feature.properties, (value, key) => {
|
||||
result['@@' + key] = value;
|
||||
});
|
||||
result['@@value'] = valueFormatted;
|
||||
const datum = data[feature.properties[countryCodeType]] || {};
|
||||
return _.extend(result, datum.item);
|
||||
}
|
||||
|
||||
export function getValueForFeature(feature, data, countryCodeType) {
|
||||
const code = feature.properties[countryCodeType];
|
||||
if (_.isString(code) && _.isObject(data[code])) {
|
||||
return data[code].value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getColorByValue(value, limits, colors, defaultColor) {
|
||||
if (_.isNumber(value) && isFinite(value)) {
|
||||
for (let i = 0; i < limits.length; i += 1) {
|
||||
if (value <= limits[i]) {
|
||||
return colors[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultColor;
|
||||
}
|
||||
|
||||
export function createScale(features, data, options) {
|
||||
// Calculate limits
|
||||
const values = _.uniq(_.filter(
|
||||
_.map(features, feature => getValueForFeature(feature, data, options.countryCodeType)),
|
||||
_.isNumber,
|
||||
));
|
||||
if (values.length === 0) {
|
||||
return {
|
||||
limits: [],
|
||||
colors: [],
|
||||
legend: [],
|
||||
};
|
||||
}
|
||||
const steps = Math.min(values.length, options.steps);
|
||||
if (steps === 1) {
|
||||
return {
|
||||
limits: values,
|
||||
colors: [options.colors.max],
|
||||
legend: [{
|
||||
color: options.colors.max,
|
||||
limit: _.first(values),
|
||||
}],
|
||||
};
|
||||
}
|
||||
const limits = chroma.limits(values, options.clusteringMode, steps - 1);
|
||||
|
||||
// Create color buckets
|
||||
const colors = chroma.scale([options.colors.min, options.colors.max])
|
||||
.colors(limits.length);
|
||||
|
||||
// Group values for legend
|
||||
const legend = _.map(colors, (color, index) => ({
|
||||
color,
|
||||
limit: limits[index],
|
||||
})).reverse();
|
||||
|
||||
return { limits, colors, legend };
|
||||
}
|
||||
|
||||
export function inferCountryCodeType(data, countryCodeField) {
|
||||
const regex = {
|
||||
iso_a2: /^[a-z]{2}$/i,
|
||||
iso_a3: /^[a-z]{3}$/i,
|
||||
iso_n3: /^[0-9]{3}$/i,
|
||||
};
|
||||
|
||||
const result = _.chain(data)
|
||||
.reduce((memo, item) => {
|
||||
const value = item[countryCodeField];
|
||||
if (_.isString(value)) {
|
||||
_.each(regex, (r, k) => {
|
||||
memo[k] += r.test(value) ? 1 : 0;
|
||||
});
|
||||
}
|
||||
return memo;
|
||||
}, {
|
||||
iso_a2: 0,
|
||||
iso_a3: 0,
|
||||
iso_n3: 0,
|
||||
})
|
||||
.pairs()
|
||||
.max(item => item[1])
|
||||
.value();
|
||||
|
||||
return (result[1] / data.length) >= 0.9 ? result[0] : null;
|
||||
}
|
||||
@@ -19,6 +19,9 @@ const DEFAULT_OPTIONS = {
|
||||
stageColumn: 'day_number',
|
||||
totalColumn: 'total',
|
||||
valueColumn: 'value',
|
||||
|
||||
autoHeight: true,
|
||||
defaultRows: 8,
|
||||
};
|
||||
|
||||
function groupData(sortedData) {
|
||||
@@ -221,16 +224,13 @@ export default function init(ngModule) {
|
||||
|
||||
ngModule.config((VisualizationProvider) => {
|
||||
const editTemplate = '<cohort-editor></cohort-editor>';
|
||||
const defaultOptions = {
|
||||
timeInterval: 'daily',
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'COHORT',
|
||||
name: 'Cohort',
|
||||
renderTemplate: '<cohort-renderer options="visualization.options" query-result="queryResult"></cohort-renderer>',
|
||||
editorTemplate: editTemplate,
|
||||
defaultOptions,
|
||||
defaultOptions: DEFAULT_OPTIONS,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
<label class="control-label">Visualization Type</label>
|
||||
|
||||
<select required ng-model="$ctrl.visualization.type"
|
||||
ng-options="type.type as type.name for type in $ctrl.visTypes" class="form-control"
|
||||
ng-change="$ctrl.typeChanged('{{$ctrl.visualization.type}}')"></select>
|
||||
ng-disabled="!$ctrl.canChangeType"
|
||||
ng-options="type.type as type.name for type in $ctrl.visTypes" class="form-control"
|
||||
ng-change="$ctrl.typeChanged('{{$ctrl.visualization.type}}')"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -19,6 +19,9 @@ const EditVisualizationDialog = {
|
||||
this.visualization = copy(this.originalVisualization);
|
||||
this.visTypes = Visualization.visualizationTypes;
|
||||
|
||||
// Don't allow to change type after creating visualization
|
||||
this.canChangeType = !(this.visualization && this.visualization.id);
|
||||
|
||||
this.newVisualization = () =>
|
||||
({
|
||||
type: Visualization.defaultVisualization.type,
|
||||
|
||||
43
client/app/visualizations/funnel/funnel-editor.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<div class="form-horizontal">
|
||||
<div style="margin-bottom: 20px;">
|
||||
This visualization constructs funnel chart. Please notice that value column only accept number for values.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Step Column Name</label>
|
||||
<div class="col-lg-6">
|
||||
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="visualization.options.stepCol.colName" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Step Column Display Name</label>
|
||||
<div class="col-lg-6">
|
||||
<input type="text" ng-model="visualization.options.stepCol.displayAs" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Funnel Value Column Name</label>
|
||||
<div class="col-lg-6">
|
||||
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="visualization.options.valueCol.colName" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Funnel Value Column Display Name</label>
|
||||
<div class="col-lg-6">
|
||||
<input type="text" ng-model="visualization.options.valueCol.displayAs" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Auto Sort Record By Value</label>
|
||||
<div class="col-lg-6">
|
||||
<input type="checkbox" ng-model="visualization.options.autoSort">
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="!visualization.options.autoSort">
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Funnel Value Columns Name</label>
|
||||
<div class="col-lg-6">
|
||||
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="visualization.options.sortKeyCol.colName" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
49
client/app/visualizations/funnel/funnel.less
Normal file
@@ -0,0 +1,49 @@
|
||||
.funnel-visualization-container {
|
||||
table {
|
||||
min-width: 450px;
|
||||
}
|
||||
.table-borderless td, .table-borderless th {
|
||||
border: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.step {
|
||||
max-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.step .step-name {
|
||||
visibility: hidden;
|
||||
width: inherit;
|
||||
padding: 3px 5px;
|
||||
background-color: white;
|
||||
border: 1px solid;
|
||||
border-radius: 3px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
white-space: initial;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.step:hover .step-name {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
div.bar {
|
||||
height: 30px;
|
||||
}
|
||||
div.bar.centered {
|
||||
margin: auto;
|
||||
}
|
||||
.value {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.container {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
211
client/app/visualizations/funnel/index.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import { debounce, sortBy, isNumber, every, difference } from 'underscore';
|
||||
import d3 from 'd3';
|
||||
import angular from 'angular';
|
||||
|
||||
import { ColorPalette, normalizeValue } from '@/visualizations/chart/plotly/utils';
|
||||
import editorTemplate from './funnel-editor.html';
|
||||
import './funnel.less';
|
||||
|
||||
function isNoneNaNNum(val) {
|
||||
if (!isNumber(val) || isNaN(val)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizePercentage(num) {
|
||||
if (num < 0.01) { return '<0.01%'; }
|
||||
if (num > 1000) { return '>1000%'; }
|
||||
return num.toFixed(2) + '%';
|
||||
}
|
||||
|
||||
function Funnel(scope, element) {
|
||||
this.element = element;
|
||||
this.watches = [];
|
||||
const vis = d3.select(element);
|
||||
const options = scope.visualization.options;
|
||||
|
||||
function drawFunnel(data) {
|
||||
const maxToPrevious = d3.max(data, d => d.pctPrevious);
|
||||
// Table
|
||||
const table = vis.append('table')
|
||||
.attr('class', 'table table-condensed table-hover table-borderless');
|
||||
|
||||
// Header
|
||||
const header = table.append('thead').append('tr');
|
||||
header.append('th').text(options.stepCol.displayAs);
|
||||
header.append('th').attr('class', 'text-center').text(options.valueCol.displayAs);
|
||||
header.append('th').attr('class', 'text-center').text('% Max');
|
||||
header.append('th').attr('class', 'text-center').text('% Previous');
|
||||
|
||||
// Body
|
||||
const trs = table.append('tbody')
|
||||
.selectAll('tr')
|
||||
.data(data)
|
||||
.enter()
|
||||
.append('tr');
|
||||
|
||||
// Steps row
|
||||
trs.append('td')
|
||||
.attr('class', 'col-xs-3 step')
|
||||
.text(d => d.step)
|
||||
.append('div')
|
||||
.attr('class', 'step-name')
|
||||
.text(d => d.step);
|
||||
|
||||
// Funnel bars
|
||||
const valContainers = trs.append('td')
|
||||
.attr('class', 'col-xs-5')
|
||||
.append('div')
|
||||
.attr('class', 'container');
|
||||
valContainers.append('div')
|
||||
.attr('class', 'bar centered')
|
||||
.style('background-color', ColorPalette.Cyan)
|
||||
.style('width', d => d.pctMax + '%');
|
||||
valContainers.append('div')
|
||||
.attr('class', 'value')
|
||||
.text(d => d.value.toLocaleString());
|
||||
|
||||
// pctMax
|
||||
trs.append('td')
|
||||
.attr('class', 'col-xs-2 text-center')
|
||||
.text(d => normalizePercentage(d.pctMax));
|
||||
|
||||
// pctPrevious
|
||||
const pctContainers = trs.append('td')
|
||||
.attr('class', 'col-xs-2')
|
||||
.append('div')
|
||||
.attr('class', 'container');
|
||||
pctContainers.append('div')
|
||||
.attr('class', 'bar')
|
||||
.style('background-color', ColorPalette.Gray)
|
||||
.style('opacity', '0.2')
|
||||
.style('width', d => (d.pctPrevious / maxToPrevious * 100.0) + '%');
|
||||
pctContainers.append('div')
|
||||
.attr('class', 'value')
|
||||
.text(d => normalizePercentage(d.pctPrevious));
|
||||
}
|
||||
|
||||
function createVisualization(data) {
|
||||
drawFunnel(data); // draw funnel
|
||||
}
|
||||
|
||||
function removeVisualization() {
|
||||
vis.selectAll('table').remove();
|
||||
}
|
||||
|
||||
function prepareData(queryData) {
|
||||
const data = queryData.map(row => ({
|
||||
step: normalizeValue(row[options.stepCol.colName]),
|
||||
value: Number(row[options.valueCol.colName]),
|
||||
sortVal: options.autoSort ? '' : row[options.sortKeyCol.colName],
|
||||
}), []);
|
||||
let sortedData;
|
||||
if (options.autoSort) {
|
||||
sortedData = sortBy(data, 'value').reverse();
|
||||
} else {
|
||||
sortedData = sortBy(data, 'sortVal');
|
||||
}
|
||||
|
||||
// Column validity
|
||||
if (sortedData[0].value === 0 || !every(sortedData, d => isNoneNaNNum(d.value))) {
|
||||
return;
|
||||
}
|
||||
const maxVal = d3.max(data, d => d.value);
|
||||
sortedData.forEach((d, i) => {
|
||||
d.pctMax = d.value / maxVal * 100.0;
|
||||
d.pctPrevious = i === 0 ? 100.0 : d.value / sortedData[i - 1].value * 100.0;
|
||||
});
|
||||
return sortedData.slice(0, 100);
|
||||
}
|
||||
|
||||
function invalidColNames() {
|
||||
const colNames = scope.queryResult.getColumnNames();
|
||||
const colToCheck = [options.stepCol.colName, options.valueCol.colName];
|
||||
if (!options.autoSort) { colToCheck.push(options.sortKeyCol.colName); }
|
||||
if (difference(colToCheck, colNames).length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
removeVisualization();
|
||||
if (invalidColNames()) { return; }
|
||||
|
||||
const queryData = scope.queryResult.getData();
|
||||
const data = prepareData(queryData, options);
|
||||
if (data) {
|
||||
createVisualization(data); // draw funnel
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
this.watches.push(scope.$watch('visualization.options', refresh, true));
|
||||
this.watches.push(scope.$watch('queryResult && queryResult.getData()', refresh));
|
||||
}
|
||||
|
||||
Funnel.prototype.remove = function remove() {
|
||||
this.watches.forEach((unregister) => {
|
||||
unregister();
|
||||
});
|
||||
angular.element(this.element).empty('.vis-container');
|
||||
};
|
||||
|
||||
function funnelRenderer() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<div class="funnel-visualization-container resize-event="handleResize()"></div>',
|
||||
link(scope, element) {
|
||||
const container = element[0].querySelector('.funnel-visualization-container');
|
||||
let funnel = new Funnel(scope, container);
|
||||
|
||||
function resize() {
|
||||
funnel.remove();
|
||||
funnel = new Funnel(scope, container);
|
||||
}
|
||||
|
||||
scope.handleResize = debounce(resize, 50);
|
||||
|
||||
scope.$watch('visualization.options', (oldValue, newValue) => {
|
||||
if (oldValue !== newValue) {
|
||||
resize();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function funnelEditor() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: editorTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('funnelRenderer', funnelRenderer);
|
||||
ngModule.directive('funnelEditor', funnelEditor);
|
||||
|
||||
ngModule.config((VisualizationProvider) => {
|
||||
const renderTemplate =
|
||||
'<funnel-renderer options="visualization.options" query-result="queryResult"></funnel-renderer>';
|
||||
|
||||
const editTemplate = '<funnel-editor></funnel-editor>';
|
||||
const defaultOptions = {
|
||||
stepCol: { colName: '', displayAs: 'Steps' },
|
||||
valueCol: { colName: '', displayAs: 'Value' },
|
||||
sortKeyCol: { colName: '' },
|
||||
autoSort: true,
|
||||
defaultRows: 10,
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'FUNNEL',
|
||||
name: 'Funnel',
|
||||
renderTemplate,
|
||||
editorTemplate: editTemplate,
|
||||
defaultOptions,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
|
||||
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||
import markerIconRetina from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||
import 'leaflet-fullscreen';
|
||||
import 'leaflet-fullscreen/dist/leaflet.fullscreen.css';
|
||||
|
||||
import template from './map.html';
|
||||
import editorTemplate from './map-editor.html';
|
||||
@@ -23,14 +25,16 @@ L.Icon.Default.mergeOptions({
|
||||
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
|
||||
|
||||
function mapRenderer() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template,
|
||||
link($scope, elm) {
|
||||
const colorScale = d3.scale.category10();
|
||||
const map = L.map(elm[0].children[0].children[0], { scrollWheelZoom: false });
|
||||
const map = L.map(elm[0].children[0].children[0], {
|
||||
scrollWheelZoom: false,
|
||||
fullscreenControl: true,
|
||||
});
|
||||
const mapControls = L.control.layers().addTo(map);
|
||||
const layers = {};
|
||||
const tileLayer = L.tileLayer('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
@@ -209,7 +213,6 @@ function mapRenderer() {
|
||||
|
||||
$scope.$watch('queryResult && queryResult.getData()', render);
|
||||
$scope.$watch('visualization.options', render, true);
|
||||
$scope.$watch('visualization.options.height', resize);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -297,7 +300,7 @@ export default function init(ngModule) {
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'MAP',
|
||||
name: 'Map',
|
||||
name: 'Map (Markers)',
|
||||
renderTemplate,
|
||||
editorTemplate: editTemplate,
|
||||
defaultOptions,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<li ng-class="{active: currentTab == 'map'}"><a ng-click="currentTab='map'">Map Settings</a></li>
|
||||
</ul>
|
||||
|
||||
<div ng-show="currentTab == 'general'">
|
||||
<div ng-show="currentTab == 'general'" class="m-t-10 m-b-10">
|
||||
<div class="form-group">
|
||||
<label class="control-label">Latitude Column Name</label>
|
||||
<ui-select name="form-control" required ng-model="visualization.options.latColName">
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-show="currentTab == 'groups'">
|
||||
<div ng-show="currentTab == 'groups'" class="m-b-10">
|
||||
<table class="table table-condensed col-table">
|
||||
<thead>
|
||||
<th>Name</th>
|
||||
@@ -50,14 +50,14 @@
|
||||
<tr ng-repeat="(name, options) in visualization.options.groups">
|
||||
<td>{{name}}</td>
|
||||
<td>
|
||||
<input class="form-control" type="color" ng-model="options.color"/>
|
||||
<input class="form-control" type="color" ng-model="options.color"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div ng-show="currentTab == 'map'">
|
||||
<div ng-show="currentTab == 'map'" class="m-t-10 m-b-10">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" ng-model="visualization.options.clusterMarkers">
|
||||
@@ -67,8 +67,8 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label">Map Tiles</label>
|
||||
<select ng-options="tile.url as tile.name for tile in mapTiles" ng-model="visualization.options.mapTileUrl"
|
||||
class="form-control"></select>
|
||||
<select ng-options="tile.url as tile.name for tile in mapTiles"
|
||||
ng-model="visualization.options.mapTileUrl" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<div class="map-visualization-container">
|
||||
<div resize-event="handleResize()" style="width:100%; height:100%;"></div>
|
||||
<div resize-event="handleResize()"></div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import _ from 'underscore';
|
||||
import { getColumnCleanName } from '@/services/query-result';
|
||||
import createFormatter from '@/lib/value-format';
|
||||
import { createFormatter } from '@/lib/value-format';
|
||||
import template from './table.html';
|
||||
import editorTemplate from './table-editor.html';
|
||||
import './table-editor.less';
|
||||
@@ -19,8 +19,9 @@ const DISPLAY_AS_OPTIONS = [
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
itemsPerPage: 15,
|
||||
autoHeight: true,
|
||||
defaultRows: 14,
|
||||
defaultColumns: 4,
|
||||
defaultColumns: 3,
|
||||
minColumns: 2,
|
||||
};
|
||||
|
||||
@@ -47,7 +48,7 @@ function getDefaultColumnsOptions(columns) {
|
||||
allowSearch: false,
|
||||
alignContent: getColumnContentAlignment(col.type),
|
||||
// `string` cell options
|
||||
allowHTML: false,
|
||||
allowHTML: true,
|
||||
highlightLinks: false,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -97,12 +97,17 @@ export default function init(ngModule) {
|
||||
ngModule.directive('wordCloudEditor', wordCloudEditor);
|
||||
ngModule.directive('wordCloudRenderer', wordCloudRenderer);
|
||||
|
||||
const defaultOptions = {
|
||||
defaultRows: 8,
|
||||
};
|
||||
|
||||
ngModule.config((VisualizationProvider) => {
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'WORD_CLOUD',
|
||||
name: 'Word Cloud',
|
||||
renderTemplate: '<word-cloud-renderer options="visualization.options" query-result="queryResult"></word-cloud-renderer>',
|
||||
editorTemplate: '<word-cloud-editor></word-cloud-editor>',
|
||||
defaultOptions,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,11 +33,11 @@ services:
|
||||
WORKERS_COUNT: 2
|
||||
redis:
|
||||
image: redis:3.0-alpine
|
||||
restart: always
|
||||
restart: unless-stopped
|
||||
postgres:
|
||||
image: postgres:9.5.6-alpine
|
||||
# The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3
|
||||
# improvement on my personal machine). We should consider moving this into a dedicated Docker Compose configuration for
|
||||
# tests.
|
||||
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
|
||||
restart: always
|
||||
restart: unless-stopped
|
||||
|
||||
1315
package-lock.json
generated
11
package.json
@@ -6,8 +6,10 @@
|
||||
"scripts": {
|
||||
"start": "webpack-dev-server",
|
||||
"dev": "REDASH_BACKEND=https://dev.redashapp.com npm start",
|
||||
"build": "rm -rf ./client/dist/ && NODE_ENV=production node node_modules/.bin/webpack",
|
||||
"watch": "webpack --watch --progress --colors -d"
|
||||
"build": "rm -rf ./client/dist/ && NODE_ENV=production webpack",
|
||||
"watch": "webpack --watch --progress --colors -d",
|
||||
"analyze": "rm -rf ./client/dist/ && BUNDLE_ANALYZER=on webpack",
|
||||
"analyze:build": "rm -rf ./client/dist/ && NODE_ENV=production BUNDLE_ANALYZER=on webpack"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -26,7 +28,6 @@
|
||||
"dependencies": {
|
||||
"angular": "~1.5.8",
|
||||
"angular-base64-upload": "^0.1.23",
|
||||
"angular-gridster": "^0.13.14",
|
||||
"angular-messages": "~1.5.8",
|
||||
"angular-moment": "^1.1.0",
|
||||
"angular-resizable": "^1.2.0",
|
||||
@@ -39,16 +40,19 @@
|
||||
"angular-vs-repeat": "^1.1.7",
|
||||
"bootstrap": "^3.3.7",
|
||||
"brace": "^0.10.0",
|
||||
"chroma-js": "^1.3.6",
|
||||
"core-js": "https://registry.npmjs.org/core-js/-/core-js-2.4.1.tgz",
|
||||
"cornelius": "git+https://github.com/restorando/cornelius.git",
|
||||
"d3": "^3.5.17",
|
||||
"d3-cloud": "^1.2.4",
|
||||
"debug": "^3.1.0",
|
||||
"font-awesome": "^4.7.0",
|
||||
"gridstack": "^0.3.0",
|
||||
"jquery": "^3.2.1",
|
||||
"jquery-ui": "^1.12.1",
|
||||
"leaflet": "^1.2.0",
|
||||
"leaflet.markercluster": "^1.1.0",
|
||||
"leaflet-fullscreen": "^1.0.2",
|
||||
"markdown": "0.5.0",
|
||||
"material-design-iconic-font": "^2.2.0",
|
||||
"moment": "^2.19.3",
|
||||
@@ -88,6 +92,7 @@
|
||||
"url-loader": "^0.5.9",
|
||||
"webpack": "^3.6.0",
|
||||
"webpack-build-notifier": "^0.1.16",
|
||||
"webpack-bundle-analyzer": "^2.11.1",
|
||||
"webpack-dev-server": "^2.9.1",
|
||||
"webpack-manifest-plugin": "^1.3.2"
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ from redash.query_runner import import_query_runners
|
||||
from redash.destinations import import_destinations
|
||||
|
||||
|
||||
__version__ = '4.0.0-beta'
|
||||
__version__ = '4.0.0'
|
||||
|
||||
|
||||
def setup_logging():
|
||||
@@ -91,7 +91,7 @@ class SlugConverter(BaseConverter):
|
||||
|
||||
|
||||
def create_app(load_admin=True):
|
||||
from redash import handlers
|
||||
from redash import extensions, handlers
|
||||
from redash.handlers.webpack import configure_webpack
|
||||
from redash.admin import init_admin
|
||||
from redash.models import db
|
||||
@@ -137,5 +137,5 @@ def create_app(load_admin=True):
|
||||
limiter.init_app(app)
|
||||
handlers.init_app(app)
|
||||
configure_webpack(app)
|
||||
|
||||
extensions.init_extensions(app)
|
||||
return app
|
||||
|
||||