mirror of
https://github.com/getredash/redash.git
synced 2025-12-20 01:47:39 -05:00
Compare commits
152 Commits
v9.0.0-bet
...
query-base
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19343a0520 | ||
|
|
c1ed8848f0 | ||
|
|
b40070d7f5 | ||
|
|
fa2b57a209 | ||
|
|
132fed64b3 | ||
|
|
bd9ce68f68 | ||
|
|
0c0b62ae1a | ||
|
|
08bcdf77d0 | ||
|
|
aa2064b1ab | ||
|
|
d0a787cab1 | ||
|
|
a741341938 | ||
|
|
53385fa24b | ||
|
|
fa7ecca485 | ||
|
|
8f484706b1 | ||
|
|
e2e8714155 | ||
|
|
c6bf8a1c55 | ||
|
|
12f71925c2 | ||
|
|
cae088f35b | ||
|
|
a3c79f26b9 | ||
|
|
c7c92a3192 | ||
|
|
55cf17aa47 | ||
|
|
8dd76a00c5 | ||
|
|
e242ac2b10 | ||
|
|
66463aedd4 | ||
|
|
8a6524c1ba | ||
|
|
9097feb100 | ||
|
|
db4e97fa6f | ||
|
|
0d4615a482 | ||
|
|
ff008a076b | ||
|
|
8d548ecbac | ||
|
|
2992c382d1 | ||
|
|
f4dcb2918a | ||
|
|
c821cab4cb | ||
|
|
4fb77867b0 | ||
|
|
a473611cb0 | ||
|
|
210008c714 | ||
|
|
aa5d4f5f4e | ||
|
|
6b811c5245 | ||
|
|
83726da48a | ||
|
|
72dc157bbe | ||
|
|
1b8ff8e810 | ||
|
|
31ddd0fb79 | ||
|
|
5cabf7a724 | ||
|
|
59b135ace7 | ||
|
|
32b41e4112 | ||
|
|
2e31b91054 | ||
|
|
205915e6db | ||
|
|
b7c245f925 | ||
|
|
681b2f1abd | ||
|
|
a31196aef8 | ||
|
|
596e5bee3a | ||
|
|
84d516bfd1 | ||
|
|
2cc3bd3d54 | ||
|
|
ac652c20bf | ||
|
|
1bc6cd8f41 | ||
|
|
4c70b5ce8e | ||
|
|
de052ff02b | ||
|
|
a596d6558c | ||
|
|
fc71acdc09 | ||
|
|
b326d36ae8 | ||
|
|
378cc57d42 | ||
|
|
83c6a6bcd2 | ||
|
|
5afd0554d0 | ||
|
|
eb603f63f0 | ||
|
|
6c00f7c4e3 | ||
|
|
f56f4c4899 | ||
|
|
d3b639a68a | ||
|
|
3332b656ac | ||
|
|
24c95379ca | ||
|
|
93b4be672f | ||
|
|
f3a47a9658 | ||
|
|
7804dfd68e | ||
|
|
2dacd08bea | ||
|
|
fd76a2ecfb | ||
|
|
7f98d7b694 | ||
|
|
a1255b4144 | ||
|
|
6c349ea70a | ||
|
|
95c28c47ad | ||
|
|
48924de700 | ||
|
|
41a691328a | ||
|
|
cb97364771 | ||
|
|
d12691dc2a | ||
|
|
6f9e79c641 | ||
|
|
461f98bbfc | ||
|
|
81e7c72d48 | ||
|
|
328f0f3f0c | ||
|
|
ecb9adf903 | ||
|
|
87e09f676e | ||
|
|
6fc5c803e0 | ||
|
|
6c57aa448e | ||
|
|
878b297601 | ||
|
|
9c0450c84e | ||
|
|
74f206614f | ||
|
|
2f26cf791c | ||
|
|
c6be5758ad | ||
|
|
8341592b05 | ||
|
|
a7edbf1e8d | ||
|
|
217f41b586 | ||
|
|
a8bd07e293 | ||
|
|
332c16b130 | ||
|
|
7940d36616 | ||
|
|
68b70ed63b | ||
|
|
e0297835df | ||
|
|
004bc7a2ac | ||
|
|
efcf22079f | ||
|
|
a83cb18cc5 | ||
|
|
1ecdf7b853 | ||
|
|
90024ebc92 | ||
|
|
a37b7babbf | ||
|
|
8f4ac958b1 | ||
|
|
637d9837f4 | ||
|
|
bdd3c3e735 | ||
|
|
6fc35510d3 | ||
|
|
6f842ef94a | ||
|
|
a563900f0a | ||
|
|
ee3930c64d | ||
|
|
10bff8b3b1 | ||
|
|
a8510d1ad5 | ||
|
|
3a543a4ab2 | ||
|
|
2b1ba1ee33 | ||
|
|
4a54ad9d06 | ||
|
|
676f560830 | ||
|
|
98a5154345 | ||
|
|
4c324ddc80 | ||
|
|
05c2233782 | ||
|
|
0ac24e38a1 | ||
|
|
d036df0ca1 | ||
|
|
56df870f39 | ||
|
|
05540164e1 | ||
|
|
bdb62365b1 | ||
|
|
6a12168f40 | ||
|
|
f396c96457 | ||
|
|
8bfcbf21e3 | ||
|
|
8a1640c4e7 | ||
|
|
a37e7f93dc | ||
|
|
cc34e781d3 | ||
|
|
6aa0ea715e | ||
|
|
6c27619671 | ||
|
|
6eeb3b3eb2 | ||
|
|
d40edb81c2 | ||
|
|
f128b4b85f | ||
|
|
264fb5798d | ||
|
|
90023ac435 | ||
|
|
df755fbc17 | ||
|
|
e555642844 | ||
|
|
bdd7b146ae | ||
|
|
b7478defec | ||
|
|
bb0d7830c9 | ||
|
|
137aa22dd4 | ||
|
|
9cf396599a | ||
|
|
b70f0fa921 | ||
|
|
5e3613d6cb |
@@ -1,12 +1,12 @@
|
|||||||
FROM cypress/browsers:chrome67
|
FROM cypress/browsers:node14.0.0-chrome84
|
||||||
|
|
||||||
ENV APP /usr/src/app
|
ENV APP /usr/src/app
|
||||||
WORKDIR $APP
|
WORKDIR $APP
|
||||||
|
|
||||||
COPY package.json $APP/package.json
|
COPY package.json package-lock.json $APP/
|
||||||
RUN npm run cypress:install > /dev/null
|
COPY viz-lib $APP/viz-lib
|
||||||
|
RUN npm ci > /dev/null
|
||||||
|
|
||||||
COPY client/cypress $APP/client/cypress
|
COPY . $APP
|
||||||
COPY cypress.json $APP/cypress.json
|
|
||||||
|
|
||||||
RUN ./node_modules/.bin/cypress verify
|
RUN ./node_modules/.bin/cypress verify
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ jobs:
|
|||||||
- store_artifacts:
|
- store_artifacts:
|
||||||
path: coverage.xml
|
path: coverage.xml
|
||||||
frontend-lint:
|
frontend-lint:
|
||||||
|
environment:
|
||||||
|
CYPRESS_INSTALL_BINARY: 0
|
||||||
|
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/node:12
|
- image: circleci/node:12
|
||||||
steps:
|
steps:
|
||||||
@@ -67,6 +70,9 @@ jobs:
|
|||||||
- store_test_results:
|
- store_test_results:
|
||||||
path: /tmp/test-results
|
path: /tmp/test-results
|
||||||
frontend-unit-tests:
|
frontend-unit-tests:
|
||||||
|
environment:
|
||||||
|
CYPRESS_INSTALL_BINARY: 0
|
||||||
|
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/node:12
|
- image: circleci/node:12
|
||||||
steps:
|
steps:
|
||||||
@@ -90,11 +96,20 @@ jobs:
|
|||||||
PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA==
|
PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA==
|
||||||
CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th
|
CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th
|
||||||
CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx
|
CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx
|
||||||
|
CYPRESS_INSTALL_BINARY: 0
|
||||||
|
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/node:12
|
- image: circleci/node:12
|
||||||
steps:
|
steps:
|
||||||
- setup_remote_docker
|
- setup_remote_docker
|
||||||
- checkout
|
- checkout
|
||||||
|
- run:
|
||||||
|
name: Enable Code Coverage report for master branch
|
||||||
|
command: |
|
||||||
|
if [ "$CIRCLE_BRANCH" = "master" ]; then
|
||||||
|
echo 'export CODE_COVERAGE=true' >> $BASH_ENV
|
||||||
|
source $BASH_ENV
|
||||||
|
fi
|
||||||
- run:
|
- run:
|
||||||
name: Install npm dependencies
|
name: Install npm dependencies
|
||||||
command: |
|
command: |
|
||||||
@@ -108,6 +123,18 @@ jobs:
|
|||||||
- run:
|
- run:
|
||||||
name: Execute Cypress tests
|
name: Execute Cypress tests
|
||||||
command: npm run cypress run-ci
|
command: npm run cypress run-ci
|
||||||
|
- run:
|
||||||
|
name: "Failure: output container logs to console"
|
||||||
|
command: |
|
||||||
|
docker-compose logs
|
||||||
|
when: on_fail
|
||||||
|
- run:
|
||||||
|
name: Copy Code Coverage results
|
||||||
|
command: |
|
||||||
|
docker cp cypress:/usr/src/app/coverage ./coverage || true
|
||||||
|
when: always
|
||||||
|
- store_artifacts:
|
||||||
|
path: coverage
|
||||||
build-docker-image: *build-docker-image-job
|
build-docker-image: *build-docker-image-job
|
||||||
build-preview-docker-image: *build-docker-image-job
|
build-preview-docker-image: *build-docker-image-job
|
||||||
workflows:
|
workflows:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
version: '3'
|
version: '2.2'
|
||||||
services:
|
services:
|
||||||
redash:
|
redash:
|
||||||
build: ../
|
build: ../
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
version: '3'
|
version: "2.2"
|
||||||
|
x-redash-service: &redash-service
|
||||||
|
build:
|
||||||
|
context: ../
|
||||||
|
args:
|
||||||
|
skip_dev_deps: "true"
|
||||||
|
skip_ds_deps: "true"
|
||||||
|
code_coverage: ${CODE_COVERAGE}
|
||||||
|
x-redash-environment: &redash-environment
|
||||||
|
REDASH_LOG_LEVEL: "INFO"
|
||||||
|
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||||
|
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||||
|
REDASH_RATELIMIT_ENABLED: "false"
|
||||||
|
REDASH_ENFORCE_CSRF: "true"
|
||||||
services:
|
services:
|
||||||
server:
|
server:
|
||||||
build: ../
|
<<: *redash-service
|
||||||
command: server
|
command: server
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
@@ -9,29 +22,25 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
environment:
|
environment:
|
||||||
|
<<: *redash-environment
|
||||||
PYTHONUNBUFFERED: 0
|
PYTHONUNBUFFERED: 0
|
||||||
REDASH_LOG_LEVEL: "INFO"
|
|
||||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
|
||||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
|
||||||
REDASH_RATELIMIT_ENABLED: "false"
|
|
||||||
scheduler:
|
scheduler:
|
||||||
build: ../
|
<<: *redash-service
|
||||||
command: scheduler
|
command: scheduler
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
environment:
|
environment:
|
||||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
<<: *redash-environment
|
||||||
worker:
|
worker:
|
||||||
build: ../
|
<<: *redash-service
|
||||||
command: worker
|
command: worker
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
environment:
|
environment:
|
||||||
|
<<: *redash-environment
|
||||||
PYTHONUNBUFFERED: 0
|
PYTHONUNBUFFERED: 0
|
||||||
REDASH_LOG_LEVEL: "INFO"
|
|
||||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
|
||||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
|
||||||
cypress:
|
cypress:
|
||||||
|
ipc: host
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: .circleci/Dockerfile.cypress
|
dockerfile: .circleci/Dockerfile.cypress
|
||||||
@@ -41,6 +50,7 @@ services:
|
|||||||
- scheduler
|
- scheduler
|
||||||
environment:
|
environment:
|
||||||
CYPRESS_baseUrl: "http://server:5000"
|
CYPRESS_baseUrl: "http://server:5000"
|
||||||
|
CYPRESS_coverage: ${CODE_COVERAGE}
|
||||||
PERCY_TOKEN: ${PERCY_TOKEN}
|
PERCY_TOKEN: ${PERCY_TOKEN}
|
||||||
PERCY_BRANCH: ${CIRCLE_BRANCH}
|
PERCY_BRANCH: ${CIRCLE_BRANCH}
|
||||||
PERCY_COMMIT: ${CIRCLE_SHA1}
|
PERCY_COMMIT: ${CIRCLE_SHA1}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,6 +5,8 @@ venv/
|
|||||||
.coveralls.yml
|
.coveralls.yml
|
||||||
.idea
|
.idea
|
||||||
*.pyc
|
*.pyc
|
||||||
|
.nyc_output
|
||||||
|
coverage
|
||||||
.coverage
|
.coverage
|
||||||
coverage.xml
|
coverage.xml
|
||||||
client/dist
|
client/dist
|
||||||
|
|||||||
211
CHANGELOG.md
211
CHANGELOG.md
@@ -1,5 +1,149 @@
|
|||||||
# Change Log
|
# Change Log
|
||||||
|
|
||||||
|
## v9.0.0-beta - 2020-06-11
|
||||||
|
|
||||||
|
This release was long time in the making and has several major changes:
|
||||||
|
|
||||||
|
- Our backend code was updated to support Python 3 and we no longer support Python 2. If you're using our Docker images, this should be a transparent change for you.
|
||||||
|
- We replaced Celery with RQ for background jobs processing. This will require some setup updates -- see instructions below.
|
||||||
|
- The frontend code is now 100% React and we removed all the Angular dependencies.
|
||||||
|
|
||||||
|
This release was made possible by contributions from over 50 people: @ari-e, @ariarijp, @arihantsurana, @arikfr, @atharvai, @cemremengu, @chulucninh09, @citrin, @daniellangnet, @DavidHernandez, @deecay, @dmudro, @erans, @erels, @ezkl, @gabrieldutra, @gstaykov, @ialeinikov, @ikenji, @Jakdaw, @jezdez, @juanvasquezreyes, @koooge, @kravets-levko, @kykrueger, @leibowitz, @leosunmo, @lihan, @loganprice, @mickeey2525, @mnoorenberghe, @monicagangwar, @NicolasLM, @p-yang, @Ralnoc, @ranbena, @randyzwitch, @rauchy, @rxin, @saravananselvamohan, @satyamkrishna, @shinsuke-nara, @stefan-mees, @stevebuckingham, @susodapop, @taminif, @thewarpaint, @tsuyoshizawa, @uncletimmy3, @wengkham.
|
||||||
|
|
||||||
|
### Upgrading
|
||||||
|
|
||||||
|
Typically, if you are running your own instance of Redash and wish to upgrade, you would simply modify the Docker tag in your `docker-compose.yml` file. Since RQ has replaced Celery in this version, there are a couple extra modifications that need to be done in your `docker-compose.yml`:
|
||||||
|
|
||||||
|
1. Under `services/scheduler/environment`, omit `QUEUES` and `WORKERS_COUNT` (and omit `environment` altogether if it is empty).
|
||||||
|
2. Under `services`, add a new service for general RQ jobs:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
worker:
|
||||||
|
<<: *redash-service
|
||||||
|
command: worker
|
||||||
|
environment:
|
||||||
|
QUEUES: "periodic emails default"
|
||||||
|
WORKERS_COUNT: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Following that, force a recreation of your containers with `docker-compose up --force-recreate --build` and you should be good to go.
|
||||||
|
|
||||||
|
### UX
|
||||||
|
|
||||||
|
- Redesigned Query Results page:
|
||||||
|
- Completely new layout is easier to read for non-technical Redash users.
|
||||||
|
- Empty query results are clearly displayed. User is now prompted to edit or execute the query.
|
||||||
|
- Mobile Experience Improvements:
|
||||||
|
- UI element spacing has been redesigned for clarity
|
||||||
|
- Admin pages now honor max-width. Tables scroll independent of the top menu.
|
||||||
|
- Large legends no longer shrink the visualization on small screens.
|
||||||
|
- Fix: it was sometimes impossible to scroll pages with dashboards because the visualizations captured every touch event.
|
||||||
|
- Fix: Visualizations on small screens would not always show horizontal scroll bars.
|
||||||
|
- Dashboards can now be un-archived using the API.
|
||||||
|
- Dashboard UI performance was improved.
|
||||||
|
- List pages were changed to show a user's name instead of avatar.
|
||||||
|
- Search-enabled tables now show a prompt for which columns will be searched.
|
||||||
|
- In the visualization editor, the settings pane now scrolls independent of the visualization preview.
|
||||||
|
- Tokens in the schema viewer now sort alphabetically.
|
||||||
|
- Links to settings panes that require Admin privileges are now hidden from non-Admins.
|
||||||
|
- The Admin page now remembers which tab you were viewing after a page reload.
|
||||||
|
|
||||||
|
### Visualizations
|
||||||
|
|
||||||
|
- Feature: Allow bubble size control with either coefficient or sizemode.
|
||||||
|
- Feature: Table visualization now treats Unix timestamps in query results as timestamps.
|
||||||
|
- Feature: It's now possible to provide a description to each Table column, appearing in UI as a tooltip.
|
||||||
|
- Feature: Added tooltip and popover templating to the map with markers visualization.
|
||||||
|
- Feature: Added an organization setting to hide the Plotly mode bar on all visualizations.
|
||||||
|
- Feature: Cohort visualization now has appearance settings.
|
||||||
|
- Feature: Add option to explicitly set Chart legend position.
|
||||||
|
- Change: Deprecated visualizations are now hidden.
|
||||||
|
- Change: Table settings editor now extends vertically instead of horizontally.
|
||||||
|
- Change: The maximum table pagination is now 500.
|
||||||
|
- Change: Pie chart labels maintain contrast against lighter slices.
|
||||||
|
- Fix: Chart series switched places when picking Y axis.
|
||||||
|
- Fix: Third column was not selectable for Bubble and Heatmap charts.
|
||||||
|
- Fix: On the counter visualizations, the “count rows” option showed an empty string instead of 0.
|
||||||
|
- Fix: Table visualization with column named "children" rendered +/- buttons.
|
||||||
|
- Fix: Sankey visualization now correctly occupies all available area even with fewer stages.
|
||||||
|
- Fix: Pie chart ignores series labels.
|
||||||
|
|
||||||
|
### Data Sources
|
||||||
|
|
||||||
|
- New Data Sources: Amazon Cloudwatch, Amazon CloudWatch Logs Insights, Azure Kusto, Exasol.
|
||||||
|
- Athena:
|
||||||
|
- Added the option to specify a base cost in settings, displaying a price for each query when executed.
|
||||||
|
- BigQuery:
|
||||||
|
- Fix: large jobs continued running after the user clicked “Cancel” query execution.
|
||||||
|
- Cassandra:
|
||||||
|
- Updated driver to 3.21.0 which dramatically reduces Docker build times.
|
||||||
|
- SSL options are now available.
|
||||||
|
- Clickhouse:
|
||||||
|
- You can now choose whether to verify the SSL certificate.
|
||||||
|
- Databricks:
|
||||||
|
- Databricks now use an ODBC-based connector.
|
||||||
|
- Fix: Date column was coerced to DateTime in the front-end.
|
||||||
|
- Druid:
|
||||||
|
- Added username and password authentication option.
|
||||||
|
- Microsoft SQL Server
|
||||||
|
- Added support for ODBC connections via pyodbc. There are now two MSSQL data source types. One using TDS. The other is using ODBC.
|
||||||
|
- MongoDB:
|
||||||
|
- Added support for running queries on secondary in replicaset mode.
|
||||||
|
- Fix: Connection test always succeeded.
|
||||||
|
- Oracle:
|
||||||
|
- Fix: Connection would fail if username or password contained special characters.
|
||||||
|
- Fix: Comparisons would fail if scale was None.
|
||||||
|
- RDS:
|
||||||
|
- Updated rds-combined-ca-bundle.pem to the latest CA.
|
||||||
|
- Redshift:
|
||||||
|
- Added the ability to use IAM Roles and Users.
|
||||||
|
- Fix: Redshift was unable to have its schema refreshed.
|
||||||
|
- Rockset:
|
||||||
|
- Fix: Allow Redash to load collections in all workspaces.
|
||||||
|
- Snowflake:
|
||||||
|
- You can now refresh the snowflake schema without waking the cluster.
|
||||||
|
- Added support for all of Snowflake’s datetime types. Otherwise certain timestamps would only appear as strings in the front-end.
|
||||||
|
- TreasureData:
|
||||||
|
- Fix: API calls would fail when setting a non-default region.
|
||||||
|
|
||||||
|
### Alerts
|
||||||
|
|
||||||
|
- Feature: Added ability to mute alerts without deleting them.
|
||||||
|
- Fix: numerical comparisons failed if value from query was a string.
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
- Added Last x Days options for date range parameters.
|
||||||
|
- Fix: Parameters added in empty queries were always added as text parameters
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: Alembic migration schema was preventing v4 users from upgrading. In v5 we started encrypting data source credentials in the database.
|
||||||
|
- Fix: System admin dashboard would not show correct database size if non-default name was used.
|
||||||
|
- Fix: refresh_queries job would break if any query had a bad schedule object.
|
||||||
|
- Fix: Orgs with LDAP enabled couldn’t disable password login.
|
||||||
|
- Fix: SSL mode was sometimes sent as an empty string to the database instead of omitted entirely.
|
||||||
|
- Fix: When creating new Map visualization with clustering disabled, map would crash on save.
|
||||||
|
- Fix: It was possible on the New Query page to click “Save” multiple times, causing multiple new query records to be created.
|
||||||
|
- Fix: Visualization render errors on a dashboard would crash the entire page.
|
||||||
|
- Fix: A scheduled execution failure would modify the query’s “updated_at” timestamp.
|
||||||
|
- Fix: Parameter UI would wrap awkwardly during some drag operations.
|
||||||
|
- Fix: In dashboard edit mode, users couldn’t modify widgets.
|
||||||
|
- Fix: Frontend error when parsing a NaN float.
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- Added TSV as a download format (in addition to CSV and Excel).
|
||||||
|
- Added maildev settings (helps with automated settings).
|
||||||
|
- Refine permissions usage in Redash to allow for guest users
|
||||||
|
- The query results API now explicitly handles 404 errors.
|
||||||
|
- Forked queries now retain the tags of the original query.
|
||||||
|
- We now allow setting custom Sentry environments.
|
||||||
|
- Started using Black linter for our Python source code
|
||||||
|
- Added CLI command to re-encrypt data source details with new secret key.
|
||||||
|
- Favorites list is now loaded on menu click instead of on page load.
|
||||||
|
- Administrators can now allow connections to private IP addresses.
|
||||||
|
|
||||||
## v8.0.0 - 2019-10-27
|
## v8.0.0 - 2019-10-27
|
||||||
|
|
||||||
There were no changes in this release since `v8.0.0-beta.2`. This is just to mark a stable release.
|
There were no changes in this release since `v8.0.0-beta.2`. This is just to mark a stable release.
|
||||||
@@ -8,24 +152,23 @@ There were no changes in this release since `v8.0.0-beta.2`. This is just to mar
|
|||||||
|
|
||||||
This is an update to the previous beta release, which includes:
|
This is an update to the previous beta release, which includes:
|
||||||
|
|
||||||
* Add options for users to share anonymous usage information with us (see [docs](https://redash.io/help/open-source/admin-guide/usage-data) for details).
|
- Add options for users to share anonymous usage information with us (see [docs](https://redash.io/help/open-source/admin-guide/usage-data) for details).
|
||||||
* Visualizations:
|
- Visualizations:
|
||||||
- Allow the user to decide how to handle null values in charts.
|
- Allow the user to decide how to handle null values in charts.
|
||||||
* Upgrade Sentry-SDK to latest version.
|
- Upgrade Sentry-SDK to latest version.
|
||||||
* Make horizontal table scroll visible in dashboard widgets without scrolling.
|
- Make horizontal table scroll visible in dashboard widgets without scrolling.
|
||||||
* Data Sources:
|
- Data Sources:
|
||||||
* Add support for Azure Data Explorer (Kusto).
|
- Add support for Azure Data Explorer (Kusto).
|
||||||
* MySQL: fix connections without SSL configuration failing.
|
- MySQL: fix connections without SSL configuration failing.
|
||||||
* Amazon Redshift: option to set query group for adhoc/scheduled queries.
|
- Amazon Redshift: option to set query group for adhoc/scheduled queries.
|
||||||
* Hive: make error message more friendly.
|
- Hive: make error message more friendly.
|
||||||
* Qubole: add support to run Quantum queries.
|
- Qubole: add support to run Quantum queries.
|
||||||
* Display data source icon in query editor.
|
- Display data source icon in query editor.
|
||||||
* Fix: allow users with view only acces to use the queries in Query Results
|
- Fix: allow users with view only acces to use the queries in Query Results
|
||||||
* Dashboard: when updating parameters refersh only widgets that use those parameters.
|
- Dashboard: when updating parameters refersh only widgets that use those parameters.
|
||||||
|
|
||||||
This release had contributions from 12 people: @arikfr, @cclauss, @gabrieldutra, @justinclift, @kravets-levko, @ranbena, @rauchy, @sandeepV2, @shinsuke-nara, @spacentropy, @sphenlee, @swfz.
|
This release had contributions from 12 people: @arikfr, @cclauss, @gabrieldutra, @justinclift, @kravets-levko, @ranbena, @rauchy, @sandeepV2, @shinsuke-nara, @spacentropy, @sphenlee, @swfz.
|
||||||
|
|
||||||
|
|
||||||
## v8.0.0-beta - 2019-08-18
|
## v8.0.0-beta - 2019-08-18
|
||||||
|
|
||||||
After months of being heads down with hard work, it's finally time to wrap up the V8 release 🤩 This release includes many long awaited improvements to parameters, UX improvements, further React migration and other changes, fixes and improvements.
|
After months of being heads down with hard work, it's finally time to wrap up the V8 release 🤩 This release includes many long awaited improvements to parameters, UX improvements, further React migration and other changes, fixes and improvements.
|
||||||
@@ -39,10 +182,10 @@ This release was made possible by contributions from over 40 people: @aidarbek,
|
|||||||
### Parameters
|
### Parameters
|
||||||
|
|
||||||
- Parameter UI improvements:
|
- Parameter UI improvements:
|
||||||
- Support for multi-select in dropdown (and query dropdown) parameters.
|
- Support for multi-select in dropdown (and query dropdown) parameters.
|
||||||
- Support for dynamic values in date and date-range parameters.
|
- Support for dynamic values in date and date-range parameters.
|
||||||
- Search dropdown parameter values.
|
- Search dropdown parameter values.
|
||||||
- New UX for applying parameter changes in queries and dashboards.
|
- New UX for applying parameter changes in queries and dashboards.
|
||||||
- Allow using Safe Parameters in visualization embeds and public dashboards. Safe Parameters are any parameter type except for the a text parameter (dropdowns are safe).
|
- Allow using Safe Parameters in visualization embeds and public dashboards. Safe Parameters are any parameter type except for the a text parameter (dropdowns are safe).
|
||||||
|
|
||||||
### Data Sources
|
### Data Sources
|
||||||
@@ -52,19 +195,19 @@ This release was made possible by contributions from over 40 people: @aidarbek,
|
|||||||
- Snowflake: update connector to latest version.
|
- Snowflake: update connector to latest version.
|
||||||
- PostgreSQL: show only accessible tables in schema.
|
- PostgreSQL: show only accessible tables in schema.
|
||||||
- BigQuery:
|
- BigQuery:
|
||||||
- Correctly handle NaN values.
|
- Correctly handle NaN values.
|
||||||
- Treat repeated fields as rrays.
|
- Treat repeated fields as rrays.
|
||||||
- [BigQuery] Fix: in some queries there is no mode field
|
- [BigQuery] Fix: in some queries there is no mode field
|
||||||
- DynamoDB:
|
- DynamoDB:
|
||||||
- Support for Unicode in queries.
|
- Support for Unicode in queries.
|
||||||
- Safe loading of schema.
|
- Safe loading of schema.
|
||||||
- Rockset: better handling of query errors.
|
- Rockset: better handling of query errors.
|
||||||
- Google Sheets:
|
- Google Sheets:
|
||||||
- Support for Team Drive.
|
- Support for Team Drive.
|
||||||
- Friendlier error message in case of an API error and more reliable test connection.
|
- Friendlier error message in case of an API error and more reliable test connection.
|
||||||
- MySQL:
|
- MySQL:
|
||||||
- Support for calling Stored Procedures and better handling of query cancellation.
|
- Support for calling Stored Procedures and better handling of query cancellation.
|
||||||
- Switch to using `mysqlclient` (a maintained fork of `Python-MySQL`).
|
- Switch to using `mysqlclient` (a maintained fork of `Python-MySQL`).
|
||||||
- MongoDB: Support serializing Decimal128 values.
|
- MongoDB: Support serializing Decimal128 values.
|
||||||
- Presto: support for passwords in connection settings.
|
- Presto: support for passwords in connection settings.
|
||||||
- Amazon Athena: allow to specify custom work group.
|
- Amazon Athena: allow to specify custom work group.
|
||||||
@@ -75,15 +218,15 @@ This release was made possible by contributions from over 40 people: @aidarbek,
|
|||||||
### Visualizations
|
### Visualizations
|
||||||
|
|
||||||
- Charts:
|
- Charts:
|
||||||
- Fix: legend overlapping chart on small screens.
|
- Fix: legend overlapping chart on small screens.
|
||||||
- Fix: Pie chart not rendering when series doesn't exist in options.
|
- Fix: Pie chart not rendering when series doesn't exist in options.
|
||||||
- Pie Chart: add option to set direction of slices.
|
- Pie Chart: add option to set direction of slices.
|
||||||
- WordCloud: rewritten to support new options (provide frequency in query, limits), scale when resizing, handle long words and more.
|
- WordCloud: rewritten to support new options (provide frequency in query, limits), scale when resizing, handle long words and more.
|
||||||
- Pivot Table: support hiding totals.
|
- Pivot Table: support hiding totals.
|
||||||
- Counters: apply formatting to target value.
|
- Counters: apply formatting to target value.
|
||||||
- Maps:
|
- Maps:
|
||||||
- Ability to customize marker icon and color.
|
- Ability to customize marker icon and color.
|
||||||
- Customization options for Choropleth maps.
|
- Customization options for Choropleth maps.
|
||||||
- New Visualization: Details View.
|
- New Visualization: Details View.
|
||||||
|
|
||||||
### **UX**
|
### **UX**
|
||||||
|
|||||||
24
Dockerfile
24
Dockerfile
@@ -3,15 +3,25 @@ FROM node:12 as frontend-builder
|
|||||||
# Controls whether to build the frontend assets
|
# Controls whether to build the frontend assets
|
||||||
ARG skip_frontend_build
|
ARG skip_frontend_build
|
||||||
|
|
||||||
|
ENV CYPRESS_INSTALL_BINARY=0
|
||||||
|
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
|
||||||
|
|
||||||
|
RUN useradd -m -d /frontend redash
|
||||||
|
USER redash
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
COPY package.json package-lock.json /frontend/
|
COPY --chown=redash package.json package-lock.json /frontend/
|
||||||
COPY viz-lib /frontend/viz-lib
|
COPY --chown=redash viz-lib /frontend/viz-lib
|
||||||
|
|
||||||
|
# Controls whether to instrument code for coverage information
|
||||||
|
ARG code_coverage
|
||||||
|
ENV BABEL_ENV=${code_coverage:+test}
|
||||||
|
|
||||||
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi
|
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi
|
||||||
|
|
||||||
COPY client /frontend/client
|
COPY --chown=redash client /frontend/client
|
||||||
COPY webpack.config.js /frontend/
|
COPY --chown=redash webpack.config.js /frontend/
|
||||||
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
|
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
|
||||||
|
|
||||||
FROM python:3.7-slim
|
FROM python:3.7-slim
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
@@ -58,7 +68,7 @@ RUN apt-get update && \
|
|||||||
ARG databricks_odbc_driver_url=https://databricks.com/wp-content/uploads/2.6.10.1010-2/SimbaSparkODBC-2.6.10.1010-2-Debian-64bit.zip
|
ARG databricks_odbc_driver_url=https://databricks.com/wp-content/uploads/2.6.10.1010-2/SimbaSparkODBC-2.6.10.1010-2-Debian-64bit.zip
|
||||||
ADD $databricks_odbc_driver_url /tmp/simba_odbc.zip
|
ADD $databricks_odbc_driver_url /tmp/simba_odbc.zip
|
||||||
RUN unzip /tmp/simba_odbc.zip -d /tmp/ \
|
RUN unzip /tmp/simba_odbc.zip -d /tmp/ \
|
||||||
&& dpkg -i /tmp/SimbaSparkODBC-2.6.10.1010-2-Debian-64bit/simbaspark_2.6.10.1010-2_amd64.deb \
|
&& dpkg -i /tmp/SimbaSparkODBC-*/*.deb \
|
||||||
&& echo "[Simba]\nDriver = /opt/simba/spark/lib/64/libsparkodbc_sb64.so" >> /etc/odbcinst.ini \
|
&& echo "[Simba]\nDriver = /opt/simba/spark/lib/64/libsparkodbc_sb64.so" >> /etc/odbcinst.ini \
|
||||||
&& rm /tmp/simba_odbc.zip \
|
&& rm /tmp/simba_odbc.zip \
|
||||||
&& rm -rf /tmp/SimbaSparkODBC*
|
&& rm -rf /tmp/SimbaSparkODBC*
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -35,7 +35,7 @@ backend-unit-tests: up test_db
|
|||||||
docker-compose run --rm --name tests server tests
|
docker-compose run --rm --name tests server tests
|
||||||
|
|
||||||
frontend-unit-tests: bundle
|
frontend-unit-tests: bundle
|
||||||
npm ci
|
CYPRESS_INSTALL_BINARY=0 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 npm ci
|
||||||
npm run bundle
|
npm run bundle
|
||||||
npm test
|
npm test
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from pathlib import Path
|
|||||||
from shutil import copy
|
from shutil import copy
|
||||||
from collections import OrderedDict as odict
|
from collections import OrderedDict as odict
|
||||||
|
|
||||||
from importlib_metadata import entry_points
|
import importlib_metadata
|
||||||
from importlib_resources import contents, is_resource, path
|
import importlib_resources
|
||||||
|
|
||||||
# Name of the subdirectory
|
# Name of the subdirectory
|
||||||
BUNDLE_DIRECTORY = "bundle"
|
BUNDLE_DIRECTORY = "bundle"
|
||||||
@@ -25,18 +25,6 @@ if not extensions_directory.exists():
|
|||||||
os.environ["EXTENSIONS_DIRECTORY"] = str(extensions_relative_path)
|
os.environ["EXTENSIONS_DIRECTORY"] = str(extensions_relative_path)
|
||||||
|
|
||||||
|
|
||||||
def resource_isdir(module, resource):
|
|
||||||
"""Whether a given resource is a directory in the given module
|
|
||||||
|
|
||||||
https://importlib-resources.readthedocs.io/en/latest/migration.html#pkg-resources-resource-isdir
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return resource in contents(module) and not is_resource(module, resource)
|
|
||||||
except (ImportError, TypeError):
|
|
||||||
# module isn't a package, so can't have a subdirectory/-package
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def entry_point_module(entry_point):
|
def entry_point_module(entry_point):
|
||||||
"""Returns the dotted module path for the given entry point"""
|
"""Returns the dotted module path for the given entry point"""
|
||||||
return entry_point.pattern.match(entry_point.value).group("module")
|
return entry_point.pattern.match(entry_point.value).group("module")
|
||||||
@@ -77,18 +65,28 @@ def load_bundles():
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
bundles = odict()
|
bundles = odict()
|
||||||
for entry_point in entry_points().get("redash.bundles", []):
|
for entry_point in importlib_metadata.entry_points().get("redash.bundles", []):
|
||||||
logger.info('Loading Redash bundle "%s".', entry_point.name)
|
logger.info('Loading Redash bundle "%s".', entry_point.name)
|
||||||
module = entry_point_module(entry_point)
|
module = entry_point_module(entry_point)
|
||||||
# Try to get a list of bundle files
|
# Try to get a list of bundle files
|
||||||
if not resource_isdir(module, BUNDLE_DIRECTORY):
|
try:
|
||||||
|
bundle_dir = importlib_resources.files(module).joinpath(BUNDLE_DIRECTORY)
|
||||||
|
except (ImportError, TypeError):
|
||||||
|
# Module isn't a package, so can't have a subdirectory/-package
|
||||||
logger.error(
|
logger.error(
|
||||||
'Redash bundle directory "%s" could not be found.', entry_point.name
|
'Redash bundle module "%s" could not be imported: "%s"',
|
||||||
|
entry_point.name,
|
||||||
|
module,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
with path(module, BUNDLE_DIRECTORY) as bundle_dir:
|
if not bundle_dir.is_dir():
|
||||||
bundles[entry_point.name] = list(bundle_dir.rglob("*"))
|
logger.error(
|
||||||
|
'Redash bundle directory "%s" could not be found or is not a directory: "%s"',
|
||||||
|
entry_point.name,
|
||||||
|
bundle_dir,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
bundles[entry_point.name] = list(bundle_dir.rglob("*"))
|
||||||
return bundles
|
return bundles
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ worker() {
|
|||||||
|
|
||||||
export WORKERS_COUNT=${WORKERS_COUNT:-2}
|
export WORKERS_COUNT=${WORKERS_COUNT:-2}
|
||||||
export QUEUES=${QUEUES:-}
|
export QUEUES=${QUEUES:-}
|
||||||
|
|
||||||
supervisord -c worker.conf
|
exec supervisord -c worker.conf
|
||||||
}
|
}
|
||||||
|
|
||||||
dev_worker() {
|
dev_worker() {
|
||||||
@@ -126,4 +126,3 @@ case "$1" in
|
|||||||
exec "$@"
|
exec "$@"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
{
|
{
|
||||||
"presets": [
|
"presets": [
|
||||||
["@babel/preset-env", {
|
[
|
||||||
"exclude": [
|
"@babel/preset-env",
|
||||||
"@babel/plugin-transform-async-to-generator",
|
{
|
||||||
"@babel/plugin-transform-arrow-functions"
|
"exclude": ["@babel/plugin-transform-async-to-generator", "@babel/plugin-transform-arrow-functions"],
|
||||||
],
|
"corejs": "2",
|
||||||
"useBuiltIns": "usage"
|
"useBuiltIns": "usage"
|
||||||
}],
|
}
|
||||||
"@babel/preset-react"
|
],
|
||||||
|
"@babel/preset-react",
|
||||||
|
"@babel/preset-typescript"
|
||||||
],
|
],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@babel/plugin-proposal-class-properties",
|
"@babel/plugin-proposal-class-properties",
|
||||||
"@babel/plugin-transform-object-assign",
|
"@babel/plugin-transform-object-assign",
|
||||||
["babel-plugin-transform-builtin-extend", {
|
[
|
||||||
"globals": ["Error"]
|
"babel-plugin-transform-builtin-extend",
|
||||||
}]
|
{
|
||||||
]
|
"globals": ["Error"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"test": {
|
||||||
|
"plugins": ["istanbul"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,57 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
extends: ["react-app", "plugin:compat/recommended", "prettier"],
|
parser: "@typescript-eslint/parser",
|
||||||
plugins: ["jest", "compat", "no-only-tests"],
|
extends: [
|
||||||
|
"react-app",
|
||||||
|
"plugin:compat/recommended",
|
||||||
|
"prettier",
|
||||||
|
// Remove any typescript-eslint rules that would conflict with prettier
|
||||||
|
"prettier/@typescript-eslint",
|
||||||
|
],
|
||||||
|
plugins: ["jest", "compat", "no-only-tests", "@typescript-eslint"],
|
||||||
settings: {
|
settings: {
|
||||||
"import/resolver": "webpack"
|
"import/resolver": "webpack",
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
node: true
|
node: true,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
// allow debugger during development
|
// allow debugger during development
|
||||||
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
|
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
|
||||||
"jsx-a11y/anchor-is-valid": "off",
|
"jsx-a11y/anchor-is-valid": "off",
|
||||||
}
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
paths: [
|
||||||
|
{
|
||||||
|
name: "antd",
|
||||||
|
message: "Please use 'import XXX from antd/lib/XXX' import instead.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "antd/lib",
|
||||||
|
message: "Please use 'import XXX from antd/lib/XXX' import instead.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
// Only run typescript-eslint on TS files
|
||||||
|
files: ["*.ts", "*.tsx", ".*.ts", ".*.tsx"],
|
||||||
|
extends: ["plugin:@typescript-eslint/recommended"],
|
||||||
|
rules: {
|
||||||
|
// Do not require functions (especially react components) to have explicit returns
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
// Do not require to type every import from a JS file to speed up development
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
// Do not complain about useless contructors in declaration files
|
||||||
|
"no-useless-constructor": "off",
|
||||||
|
"@typescript-eslint/no-useless-constructor": "error",
|
||||||
|
// Many API fields and generated types use camelcase
|
||||||
|
"@typescript-eslint/camelcase": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -16,7 +16,6 @@
|
|||||||
@import "~antd/lib/pagination/style/index";
|
@import "~antd/lib/pagination/style/index";
|
||||||
@import "~antd/lib/table/style/index";
|
@import "~antd/lib/table/style/index";
|
||||||
@import "~antd/lib/popover/style/index";
|
@import "~antd/lib/popover/style/index";
|
||||||
@import "~antd/lib/icon/style/index";
|
|
||||||
@import "~antd/lib/tag/style/index";
|
@import "~antd/lib/tag/style/index";
|
||||||
@import "~antd/lib/grid/style/index";
|
@import "~antd/lib/grid/style/index";
|
||||||
@import "~antd/lib/switch/style/index";
|
@import "~antd/lib/switch/style/index";
|
||||||
@@ -31,6 +30,7 @@
|
|||||||
@import "~antd/lib/badge/style/index";
|
@import "~antd/lib/badge/style/index";
|
||||||
@import "~antd/lib/card/style/index";
|
@import "~antd/lib/card/style/index";
|
||||||
@import "~antd/lib/spin/style/index";
|
@import "~antd/lib/spin/style/index";
|
||||||
|
@import "~antd/lib/skeleton/style/index";
|
||||||
@import "~antd/lib/tabs/style/index";
|
@import "~antd/lib/tabs/style/index";
|
||||||
@import "~antd/lib/notification/style/index";
|
@import "~antd/lib/notification/style/index";
|
||||||
@import "~antd/lib/collapse/style/index";
|
@import "~antd/lib/collapse/style/index";
|
||||||
@@ -401,3 +401,14 @@
|
|||||||
.@{checkbox-prefix-cls} + span {
|
.@{checkbox-prefix-cls} + span {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// make sure Multiple select has room for icons
|
||||||
|
.@{select-prefix-cls}-multiple {
|
||||||
|
&.@{select-prefix-cls}-show-arrow,
|
||||||
|
&.@{select-prefix-cls}-show-search,
|
||||||
|
&.@{select-prefix-cls}-loading {
|
||||||
|
.@{select-prefix-cls}-selector {
|
||||||
|
padding-right: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-form-item-explain {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.alert-last-triggered {
|
.alert-last-triggered {
|
||||||
color: @headings-color;
|
color: @headings-color;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,17 +32,6 @@ body {
|
|||||||
#application-root {
|
#application-root {
|
||||||
padding-bottom: 15px;
|
padding-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.headless {
|
|
||||||
#application-root {
|
|
||||||
padding-top: 10px;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header-wrapper {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#application-root {
|
#application-root {
|
||||||
@@ -89,46 +78,16 @@ strong {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed width layout for specific pages
|
.settings-screen,
|
||||||
@media (min-width: 768px) {
|
.home-page,
|
||||||
.settings-screen,
|
.page-dashboard-list,
|
||||||
.home-page,
|
.page-queries-list,
|
||||||
.page-dashboard-list,
|
.page-alerts-list,
|
||||||
.page-queries-list,
|
.alert-page,
|
||||||
.page-alerts-list,
|
.admin-page-layout {
|
||||||
.alert-page,
|
.container {
|
||||||
.fixed-container {
|
width: 100%;
|
||||||
.container {
|
max-width: none;
|
||||||
width: 750px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 992px) {
|
|
||||||
.settings-screen,
|
|
||||||
.home-page,
|
|
||||||
.page-dashboard-list,
|
|
||||||
.page-queries-list,
|
|
||||||
.page-alerts-list,
|
|
||||||
.alert-page,
|
|
||||||
.fixed-container {
|
|
||||||
.container {
|
|
||||||
width: 970px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
.settings-screen,
|
|
||||||
.home-page,
|
|
||||||
.page-dashboard-list,
|
|
||||||
.page-queries-list,
|
|
||||||
.page-alerts-list,
|
|
||||||
.alert-page,
|
|
||||||
.fixed-container {
|
|
||||||
.container {
|
|
||||||
width: 1170px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +214,6 @@ text.slicetext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header-wrapper,
|
|
||||||
.page-header--new {
|
.page-header--new {
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0.2em 0;
|
margin: 0.2em 0;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ div.table-name {
|
|||||||
padding: 2px 22px 2px 10px;
|
padding: 2px 22px 2px 10px;
|
||||||
border-radius: @redash-radius;
|
border-radius: @redash-radius;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
height: 22px;
|
||||||
|
|
||||||
.copy-to-editor {
|
.copy-to-editor {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -27,13 +28,19 @@ div.table-name {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schema-browser {
|
.schema-browser {
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
overflow-x: hidden;
|
|
||||||
border: none;
|
border: none;
|
||||||
margin-top: 10px;
|
padding-top: 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
|
.schema-loading-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.collapse.in {
|
.collapse.in {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
@@ -57,6 +64,14 @@ div.table-name {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
height: 18px;
|
||||||
|
|
||||||
|
.column-type {
|
||||||
|
color: fade(@text-color, 80%);
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.copy-to-editor {
|
.copy-to-editor {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -1,149 +1,153 @@
|
|||||||
.table {
|
.table {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
||||||
th.sortable-column {
|
th.sortable-column {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.table-striped) > thead > tr > th {
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*="bg-"] {
|
||||||
|
& > tr > th {
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 0;
|
||||||
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(.table-striped) > thead > tr > th {
|
& + tbody > tr:first-child > td {
|
||||||
background-color: #FAFAFA;
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
[class*="bg-"] {
|
|
||||||
& > tr > th {
|
& > thead > tr > th {
|
||||||
color: #fff;
|
vertical-align: middle;
|
||||||
border-bottom: 0;
|
font-weight: 500;
|
||||||
background: transparent !important;
|
color: #333;
|
||||||
}
|
border-width: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
& + tbody > tr:first-child > td {
|
padding: 15px 10px;
|
||||||
border-top: 0;
|
}
|
||||||
}
|
|
||||||
}
|
& > thead > tr,
|
||||||
|
& > tbody > tr,
|
||||||
& > thead > tr > th {
|
& > tfoot > tr {
|
||||||
vertical-align: middle;
|
& > th,
|
||||||
font-weight: 500;
|
& > td {
|
||||||
color: #333;
|
&:first-child {
|
||||||
border-width: 1px;
|
padding-left: 30px;
|
||||||
text-transform: uppercase;
|
}
|
||||||
padding: 15px 10px;
|
|
||||||
}
|
&:last-child {
|
||||||
|
padding-right: 30px;
|
||||||
& > thead > tr,
|
}
|
||||||
& > tbody > tr,
|
|
||||||
& > tfoot > tr {
|
|
||||||
|
|
||||||
& > th, & > td {
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
padding-left: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
padding-right: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody > tr:last-child > td {
|
|
||||||
padding-bottom: 20px;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody > tr:last-child > td {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-bordered {
|
.table-bordered {
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
||||||
& > tbody > tr {
|
& > tbody > tr {
|
||||||
& > td, & > th {
|
& > td,
|
||||||
border-bottom: 0;
|
& > th {
|
||||||
border-left: 0;
|
border-bottom: 0;
|
||||||
|
border-left: 0;
|
||||||
&:last-child {
|
|
||||||
border-right: 0;
|
&:last-child {
|
||||||
}
|
border-right: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
& > thead > tr > th {
|
|
||||||
border-left: 0;
|
& > thead > tr > th {
|
||||||
|
border-left: 0;
|
||||||
&:last-child {
|
|
||||||
border-right: 0;
|
&:last-child {
|
||||||
}
|
border-right: 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-vmiddle {
|
.table-vmiddle {
|
||||||
td {
|
td {
|
||||||
vertical-align: middle !important;
|
vertical-align: middle !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-responsive {
|
.table-responsive {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile .table {
|
.tile .table {
|
||||||
|
& > thead:not([class*="bg-"]) > tr > th {
|
||||||
& > thead:not([class*="bg-"]) > tr > th {
|
border-top: 1px solid @table-border-color;
|
||||||
border-top: 1px solid @table-border-color;
|
}
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-hover > tbody > tr:hover {
|
.table-hover > tbody > tr:hover {
|
||||||
background-color: #f4f4f4;
|
background-color: #f4f4f4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-data {
|
.table-data {
|
||||||
tbody > tr > td {
|
thead > tr > th {
|
||||||
padding-top: 5px !important;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-favourite, .btn-archive {
|
tbody > tr > td {
|
||||||
font-size: 15px;
|
padding-top: 5px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-favourite,
|
||||||
|
.btn-archive {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-main-title {
|
.table-main-title {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.7 !important;
|
line-height: 1.7 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-favourite {
|
.btn-favourite {
|
||||||
color: #d4d4d4;
|
color: #d4d4d4;
|
||||||
transition: all .25s ease-in-out;
|
transition: all 0.25s ease-in-out;
|
||||||
|
|
||||||
&:hover, &:focus {
|
&:hover,
|
||||||
color: @yellow-darker;
|
&:focus {
|
||||||
cursor: pointer;
|
color: @yellow-darker;
|
||||||
}
|
cursor: pointer;
|
||||||
|
}
|
||||||
.fa-star {
|
|
||||||
color: @yellow-darker;
|
.fa-star {
|
||||||
}
|
color: @yellow-darker;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-archive {
|
.btn-archive {
|
||||||
color: #d4d4d4;
|
color: #d4d4d4;
|
||||||
transition: all .25s ease-in-out;
|
transition: all 0.25s ease-in-out;
|
||||||
|
|
||||||
&:hover, &:focus {
|
&:hover,
|
||||||
color: @gray-light;
|
&:focus {
|
||||||
}
|
color: @gray-light;
|
||||||
|
}
|
||||||
.fa-archive {
|
|
||||||
color: @gray-light;
|
.fa-archive {
|
||||||
}
|
color: @gray-light;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table > thead > tr > th {
|
.table > thead > tr > th {
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-data .label-tag {
|
.table-data .label-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 135px;
|
max-width: 135px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
/** Load Vendors Dependencies **/
|
/** Load Vendors Dependencies **/
|
||||||
@import "~font-awesome/less/font-awesome";
|
@import "~font-awesome/less/font-awesome";
|
||||||
@import "~material-design-iconic-font/dist/css/material-design-iconic-font.css";
|
@import "~material-design-iconic-font/dist/css/material-design-iconic-font.css";
|
||||||
@import "~pace-progress/themes/blue/pace-theme-minimal.css";
|
|
||||||
|
|
||||||
@import "inc/variables";
|
@import "inc/variables";
|
||||||
@import "inc/mixins";
|
@import "inc/mixins";
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ body.fixed-layout {
|
|||||||
|
|
||||||
#application-root {
|
#application-root {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
|
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
||||||
> div {
|
.application-layout-content > div {
|
||||||
flex-grow: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,9 +72,6 @@ body.fixed-layout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.embed__vis {
|
|
||||||
}
|
|
||||||
|
|
||||||
.query__vis {
|
.query__vis {
|
||||||
table {
|
table {
|
||||||
border: 1px solid #f0f0f0;
|
border: 1px solid #f0f0f0;
|
||||||
@@ -94,6 +90,7 @@ body.fixed-layout {
|
|||||||
.embed__vis {
|
.embed__vis {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column;
|
flex-flow: column;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.embed-heading {
|
.embed-heading {
|
||||||
@@ -140,14 +137,11 @@ a.label-tag {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.schema-browser {
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.query-page-wrapper {
|
.query-page-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.query-fullscreen {
|
.query-fullscreen {
|
||||||
@@ -156,7 +150,6 @@ a.label-tag {
|
|||||||
box-shadow: rgba(102, 136, 153, 0.15) 0 4px 9px -3px;
|
box-shadow: rgba(102, 136, 153, 0.15) 0 4px 9px -3px;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100vw;
|
|
||||||
|
|
||||||
.resizable-component.react-resizable {
|
.resizable-component.react-resizable {
|
||||||
.react-resizable-handle-horizontal {
|
.react-resizable-handle-horizontal {
|
||||||
@@ -486,13 +479,6 @@ nav .rg-bottom {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.query-page-wrapper {
|
|
||||||
.container {
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.datasource-small {
|
.datasource-small {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-separator {
|
||||||
|
margin: 4px 3px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import { isEmpty, template } from "lodash";
|
|
||||||
|
|
||||||
import Dropdown from "antd/lib/dropdown";
|
|
||||||
import Icon from "antd/lib/icon";
|
|
||||||
import Menu from "antd/lib/menu";
|
|
||||||
|
|
||||||
import HelpTrigger from "@/components/HelpTrigger";
|
|
||||||
|
|
||||||
export default function FavoritesDropdown({ fetch, urlTemplate }) {
|
|
||||||
const [items, setItems] = useState();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const noItems = isEmpty(items);
|
|
||||||
const urlCompiled = useMemo(() => template(urlTemplate), [urlTemplate]);
|
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
|
||||||
(showLoadingState = true) => {
|
|
||||||
setLoading(showLoadingState);
|
|
||||||
fetch()
|
|
||||||
.then(({ results }) => {
|
|
||||||
setItems(results);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[fetch]
|
|
||||||
);
|
|
||||||
|
|
||||||
// fetch items on init
|
|
||||||
useEffect(() => {
|
|
||||||
fetchItems(false);
|
|
||||||
}, [fetchItems]);
|
|
||||||
|
|
||||||
// fetch items on click
|
|
||||||
const onVisibleChange = visible => visible && fetchItems();
|
|
||||||
|
|
||||||
const menu = (
|
|
||||||
<Menu className="favorites-dropdown">
|
|
||||||
{noItems ? (
|
|
||||||
<Menu.Item>
|
|
||||||
<span className="btn-favourite m-r-5">
|
|
||||||
<i className="fa fa-star" />
|
|
||||||
</span>
|
|
||||||
No favorites selected yet <HelpTrigger type="FAVORITES" />
|
|
||||||
</Menu.Item>
|
|
||||||
) : (
|
|
||||||
items.map(item => (
|
|
||||||
<Menu.Item key={item.id}>
|
|
||||||
<a href={urlCompiled(item)}>
|
|
||||||
<span className="btn-favourite m-r-5">
|
|
||||||
<i className="fa fa-star" />
|
|
||||||
</span>
|
|
||||||
{item.name}
|
|
||||||
</a>
|
|
||||||
</Menu.Item>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
disabled={loading}
|
|
||||||
trigger={["click"]}
|
|
||||||
placement="bottomLeft"
|
|
||||||
onVisibleChange={onVisibleChange}
|
|
||||||
overlay={menu}>
|
|
||||||
{loading ? <Icon type="loading" spin /> : <Icon type="down" />}
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
FavoritesDropdown.propTypes = {
|
|
||||||
fetch: PropTypes.func.isRequired,
|
|
||||||
urlTemplate: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
/* eslint-disable no-template-curly-in-string */
|
|
||||||
|
|
||||||
import React, { useCallback, useRef } from "react";
|
|
||||||
|
|
||||||
import Dropdown from "antd/lib/dropdown";
|
|
||||||
import Button from "antd/lib/button";
|
|
||||||
import Icon from "antd/lib/icon";
|
|
||||||
import Menu from "antd/lib/menu";
|
|
||||||
import Input from "antd/lib/input";
|
|
||||||
import Tooltip from "antd/lib/tooltip";
|
|
||||||
|
|
||||||
import HelpTrigger from "@/components/HelpTrigger";
|
|
||||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
|
||||||
import navigateTo from "@/components/ApplicationArea/navigateTo";
|
|
||||||
|
|
||||||
import { currentUser, Auth, clientConfig } from "@/services/auth";
|
|
||||||
import { Dashboard } from "@/services/dashboard";
|
|
||||||
import { Query } from "@/services/query";
|
|
||||||
import frontendVersion from "@/version.json";
|
|
||||||
import logoUrl from "@/assets/images/redash_icon_small.png";
|
|
||||||
|
|
||||||
import FavoritesDropdown from "./FavoritesDropdown";
|
|
||||||
import "./index.less";
|
|
||||||
|
|
||||||
function onSearch(q) {
|
|
||||||
navigateTo(`queries?q=${encodeURIComponent(q)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DesktopNavbar() {
|
|
||||||
const showCreateDashboardDialog = useCallback(() => {
|
|
||||||
CreateDashboardDialog.showModal();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app-header" data-platform="desktop">
|
|
||||||
<div>
|
|
||||||
<Menu mode="horizontal" selectable={false}>
|
|
||||||
{currentUser.hasPermission("list_dashboards") && (
|
|
||||||
<Menu.Item key="dashboards" className="dropdown-menu-item">
|
|
||||||
<Button href="dashboards">Dashboards</Button>
|
|
||||||
<FavoritesDropdown fetch={Dashboard.favorites} urlTemplate="dashboard/${slug}" />
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{currentUser.hasPermission("view_query") && (
|
|
||||||
<Menu.Item key="queries" className="dropdown-menu-item">
|
|
||||||
<Button href="queries">Queries</Button>
|
|
||||||
<FavoritesDropdown fetch={Query.favorites} urlTemplate="queries/${id}" />
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{currentUser.hasPermission("list_alerts") && (
|
|
||||||
<Menu.Item key="alerts">
|
|
||||||
<Button href="alerts">Alerts</Button>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
{currentUser.canCreate() && (
|
|
||||||
<Dropdown
|
|
||||||
trigger={["click"]}
|
|
||||||
overlay={
|
|
||||||
<Menu>
|
|
||||||
{currentUser.hasPermission("create_query") && (
|
|
||||||
<Menu.Item key="new-query">
|
|
||||||
<a href="queries/new">New Query</a>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{currentUser.hasPermission("create_dashboard") && (
|
|
||||||
<Menu.Item key="new-dashboard">
|
|
||||||
<a onMouseUp={showCreateDashboardDialog}>New Dashboard</a>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{currentUser.hasPermission("list_alerts") && (
|
|
||||||
<Menu.Item key="new-alert">
|
|
||||||
<a href="alerts/new">New Alert</a>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
}>
|
|
||||||
<Button type="primary" data-test="CreateButton">
|
|
||||||
Create <Icon type="down" />
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="header-logo">
|
|
||||||
<a href="./">
|
|
||||||
<img src={logoUrl} alt="Redash" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Input.Search
|
|
||||||
className="searchbar"
|
|
||||||
placeholder="Search queries..."
|
|
||||||
data-test="AppHeaderSearch"
|
|
||||||
onSearch={onSearch}
|
|
||||||
/>
|
|
||||||
<Menu mode="horizontal" selectable={false}>
|
|
||||||
<Menu.Item key="help">
|
|
||||||
<HelpTrigger type="HOME" className="menu-item-button" />
|
|
||||||
</Menu.Item>
|
|
||||||
{currentUser.isAdmin && (
|
|
||||||
<Menu.Item key="settings">
|
|
||||||
<Tooltip title="Settings">
|
|
||||||
<Button href="data_sources" className="menu-item-button">
|
|
||||||
<i className="fa fa-sliders" />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
<Menu.Item key="profile">
|
|
||||||
<Dropdown
|
|
||||||
overlayStyle={{ minWidth: 200 }}
|
|
||||||
placement="bottomRight"
|
|
||||||
trigger={["click"]}
|
|
||||||
overlay={
|
|
||||||
<Menu>
|
|
||||||
<Menu.Item key="profile">
|
|
||||||
<a href="users/me">Edit Profile</a>
|
|
||||||
</Menu.Item>
|
|
||||||
{currentUser.hasPermission("super_admin") && <Menu.Divider />}
|
|
||||||
{currentUser.isAdmin && (
|
|
||||||
<Menu.Item key="datasources">
|
|
||||||
<a href="data_sources">Data Sources</a>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{currentUser.hasPermission("list_users") && (
|
|
||||||
<Menu.Item key="groups">
|
|
||||||
<a href="groups">Groups</a>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{currentUser.hasPermission("list_users") && (
|
|
||||||
<Menu.Item key="users">
|
|
||||||
<a href="users">Users</a>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{currentUser.hasPermission("create_query") && (
|
|
||||||
<Menu.Item key="snippets">
|
|
||||||
<a href="query_snippets">Query Snippets</a>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{currentUser.isAdmin && (
|
|
||||||
<Menu.Item key="destinations">
|
|
||||||
<a href="destinations">Alert Destinations</a>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{currentUser.hasPermission("super_admin") && <Menu.Divider />}
|
|
||||||
{currentUser.hasPermission("super_admin") && (
|
|
||||||
<Menu.Item key="status">
|
|
||||||
<a href="admin/status">System Status</a>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
<Menu.Divider />
|
|
||||||
<Menu.Item key="logout" onClick={() => Auth.logout()}>
|
|
||||||
Log out
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Divider />
|
|
||||||
<Menu.Item key="version" disabled>
|
|
||||||
Version: {clientConfig.version}
|
|
||||||
{frontendVersion !== clientConfig.version && ` (${frontendVersion.substring(0, 8)})`}
|
|
||||||
{clientConfig.newVersionAvailable && currentUser.hasPermission("super_admin") && (
|
|
||||||
<Tooltip title="Update Available" placement="rightTop">
|
|
||||||
{" "}
|
|
||||||
{/* eslint-disable react/jsx-no-target-blank */}
|
|
||||||
<a
|
|
||||||
href="https://version.redash.io/"
|
|
||||||
className="update-available"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener">
|
|
||||||
<i className="fa fa-arrow-circle-down" />
|
|
||||||
</a>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu>
|
|
||||||
}>
|
|
||||||
<Button data-test="ProfileDropdown" className="profile-dropdown">
|
|
||||||
<img src={currentUser.profile_image_url} alt={currentUser.name} />
|
|
||||||
<span>{currentUser.name}</span>
|
|
||||||
<Icon type="down" />
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileNavbar() {
|
|
||||||
const ref = useRef();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app-header" data-platform="mobile" ref={ref}>
|
|
||||||
<div className="header-logo">
|
|
||||||
<a href="./">
|
|
||||||
<img src={logoUrl} alt="Redash" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Dropdown
|
|
||||||
overlayStyle={{ minWidth: 200 }}
|
|
||||||
trigger={["click"]}
|
|
||||||
getPopupContainer={() => ref.current} // so the overlay menu stays with the fixed header when page scrolls
|
|
||||||
overlay={
|
|
||||||
<Menu mode="vertical" selectable={false}>
|
|
||||||
{currentUser.hasPermission("list_dashboards") && (
|
|
||||||
<Menu.Item key="dashboards">
|
|
||||||
<a href="dashboards">Dashboards</a>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{currentUser.hasPermission("view_query") && (
|
|
||||||
<Menu.Item key="queries">
|
|
||||||
<a href="queries">Queries</a>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{currentUser.hasPermission("list_alerts") && (
|
|
||||||
<Menu.Item key="alerts">
|
|
||||||
<a href="alerts">Alerts</a>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
<Menu.Item key="profile">
|
|
||||||
<a href="users/me">Edit Profile</a>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Divider />
|
|
||||||
{currentUser.isAdmin && (
|
|
||||||
<Menu.Item key="settings">
|
|
||||||
<a href="data_sources">Settings</a>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{currentUser.hasPermission("super_admin") && (
|
|
||||||
<Menu.Item key="status">
|
|
||||||
<a href="admin/status">System Status</a>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{currentUser.hasPermission("super_admin") && <Menu.Divider />}
|
|
||||||
<Menu.Item key="help">
|
|
||||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
|
||||||
<a href="https://redash.io/help" target="_blank" rel="noopener">
|
|
||||||
Help
|
|
||||||
</a>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item key="logout" onClick={() => Auth.logout()}>
|
|
||||||
Log out
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu>
|
|
||||||
}>
|
|
||||||
<Button>
|
|
||||||
<Icon type="menu" />
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ApplicationHeader() {
|
|
||||||
return (
|
|
||||||
<nav className="app-header-wrapper">
|
|
||||||
<DesktopNavbar />
|
|
||||||
<MobileNavbar />
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
@mobileBreakpoint: ~"(max-width: 767px)";
|
|
||||||
|
|
||||||
nav .app-header {
|
|
||||||
height: 49px;
|
|
||||||
padding-bottom: 1px;
|
|
||||||
box-sizing: content-box;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background: white;
|
|
||||||
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
|
|
||||||
|
|
||||||
.darker {
|
|
||||||
color: #333 !important;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #2196f3 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-platform="mobile"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item-button {
|
|
||||||
padding: 0 15px;
|
|
||||||
font-size: 18px;
|
|
||||||
.darker();
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-menu-root {
|
|
||||||
margin: 0 10px;
|
|
||||||
line-height: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-btn {
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
.anticon {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-platform="desktop"] .ant-btn:not(.ant-btn-primary) {
|
|
||||||
border: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
height: 40px;
|
|
||||||
line-height: 40px;
|
|
||||||
background-color: transparent; //so it doesn't interfere with click animation of adjacent buttons
|
|
||||||
.darker();
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-menu-item {
|
|
||||||
padding: 0;
|
|
||||||
height: 52px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.anticon-down {
|
|
||||||
font-size: 13px !important;
|
|
||||||
transform: none;
|
|
||||||
position: relative;
|
|
||||||
top: 2px;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
transition: transform 0.2s cubic-bezier(0.75, 0, 0.25, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-dropdown-open .anticon-down svg,
|
|
||||||
.anticon-down.ant-dropdown-open svg {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu-item {
|
|
||||||
.ant-btn {
|
|
||||||
padding-right: 5px;
|
|
||||||
padding-left: 5px;
|
|
||||||
margin-right: 30px;
|
|
||||||
margin-left: 10px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is a trick to get the dropdown menu to be placed at the bottom left
|
|
||||||
// of the menu item and not the dropdown trigger
|
|
||||||
.ant-dropdown-trigger {
|
|
||||||
position: absolute;
|
|
||||||
top: 5px;
|
|
||||||
right: 0;
|
|
||||||
left: 10px;
|
|
||||||
bottom: 5px;
|
|
||||||
text-align: right;
|
|
||||||
padding-top: 14px;
|
|
||||||
padding-right: 10px;
|
|
||||||
margin-right: 0;
|
|
||||||
user-select: none; // or else double clicking it causes the header logo to get selected
|
|
||||||
.darker();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-logo img {
|
|
||||||
height: 40px;
|
|
||||||
width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchbar {
|
|
||||||
width: 185px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-dropdown {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
span {
|
|
||||||
max-width: 130px; // arbitrary, prevents layout mess up if username long
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
height: 20px;
|
|
||||||
width: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
|
||||||
.ant-btn,
|
|
||||||
.menu-item-button {
|
|
||||||
padding: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-menu-root {
|
|
||||||
margin: 0 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-dropdown {
|
|
||||||
span {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
|
||||||
.searchbar {
|
|
||||||
width: 140px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media @mobileBreakpoint {
|
|
||||||
&[data-platform="desktop"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-platform="mobile"] {
|
|
||||||
display: flex;
|
|
||||||
padding: 0 15px;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media @mobileBreakpoint {
|
|
||||||
.app-header-wrapper {
|
|
||||||
margin-top: 59px !important; // compensate for app header fixed position
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.update-available {
|
|
||||||
display: inline !important;
|
|
||||||
|
|
||||||
.fa {
|
|
||||||
color: #52c41a;
|
|
||||||
vertical-align: text-bottom;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-dropdown-menu-item .help-trigger {
|
|
||||||
display: inline;
|
|
||||||
color: #2196f3;
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-dropdown-menu.favorites-dropdown {
|
|
||||||
margin-left: -10px;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { first } from "lodash";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import Button from "antd/lib/button";
|
||||||
|
import Menu from "antd/lib/menu";
|
||||||
|
import Link from "@/components/Link";
|
||||||
|
import HelpTrigger from "@/components/HelpTrigger";
|
||||||
|
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||||
|
import { Auth, currentUser } from "@/services/auth";
|
||||||
|
import settingsMenu from "@/services/settingsMenu";
|
||||||
|
import logoUrl from "@/assets/images/redash_icon_small.png";
|
||||||
|
|
||||||
|
import DesktopOutlinedIcon from "@ant-design/icons/DesktopOutlined";
|
||||||
|
import CodeOutlinedIcon from "@ant-design/icons/CodeOutlined";
|
||||||
|
import AlertOutlinedIcon from "@ant-design/icons/AlertOutlined";
|
||||||
|
import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined";
|
||||||
|
import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined";
|
||||||
|
import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined";
|
||||||
|
import MenuUnfoldOutlinedIcon from "@ant-design/icons/MenuUnfoldOutlined";
|
||||||
|
import MenuFoldOutlinedIcon from "@ant-design/icons/MenuFoldOutlined";
|
||||||
|
|
||||||
|
import VersionInfo from "./VersionInfo";
|
||||||
|
import "./DesktopNavbar.less";
|
||||||
|
|
||||||
|
function NavbarSection({ inlineCollapsed, children, ...props }) {
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
selectable={false}
|
||||||
|
mode={inlineCollapsed ? "inline" : "vertical"}
|
||||||
|
inlineCollapsed={inlineCollapsed}
|
||||||
|
theme="dark"
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DesktopNavbar() {
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
|
||||||
|
const firstSettingsTab = first(settingsMenu.getAvailableItems());
|
||||||
|
|
||||||
|
const canCreateQuery = currentUser.hasPermission("create_query");
|
||||||
|
const canCreateDashboard = currentUser.hasPermission("create_dashboard");
|
||||||
|
const canCreateAlert = currentUser.hasPermission("list_alerts");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="desktop-navbar">
|
||||||
|
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-logo">
|
||||||
|
<div>
|
||||||
|
<Link href="./">
|
||||||
|
<img src={logoUrl} alt="Redash" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</NavbarSection>
|
||||||
|
|
||||||
|
<NavbarSection inlineCollapsed={collapsed}>
|
||||||
|
{currentUser.hasPermission("list_dashboards") && (
|
||||||
|
<Menu.Item key="dashboards">
|
||||||
|
<Link href="dashboards">
|
||||||
|
<DesktopOutlinedIcon />
|
||||||
|
<span>Dashboards</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{currentUser.hasPermission("view_query") && (
|
||||||
|
<Menu.Item key="queries">
|
||||||
|
<Link href="queries">
|
||||||
|
<CodeOutlinedIcon />
|
||||||
|
<span>Queries</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{currentUser.hasPermission("list_alerts") && (
|
||||||
|
<Menu.Item key="alerts">
|
||||||
|
<Link href="alerts">
|
||||||
|
<AlertOutlinedIcon />
|
||||||
|
<span>Alerts</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</NavbarSection>
|
||||||
|
|
||||||
|
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-spacer">
|
||||||
|
{(canCreateQuery || canCreateDashboard || canCreateAlert) && <Menu.Divider />}
|
||||||
|
{(canCreateQuery || canCreateDashboard || canCreateAlert) && (
|
||||||
|
<Menu.SubMenu
|
||||||
|
key="create"
|
||||||
|
popupClassName="desktop-navbar-submenu"
|
||||||
|
title={
|
||||||
|
<React.Fragment>
|
||||||
|
<span data-test="CreateButton">
|
||||||
|
<PlusOutlinedIcon />
|
||||||
|
<span>Create</span>
|
||||||
|
</span>
|
||||||
|
</React.Fragment>
|
||||||
|
}>
|
||||||
|
{canCreateQuery && (
|
||||||
|
<Menu.Item key="new-query">
|
||||||
|
<Link href="queries/new" data-test="CreateQueryMenuItem">
|
||||||
|
New Query
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{canCreateDashboard && (
|
||||||
|
<Menu.Item key="new-dashboard">
|
||||||
|
<a data-test="CreateDashboardMenuItem" onMouseUp={() => CreateDashboardDialog.showModal()}>
|
||||||
|
New Dashboard
|
||||||
|
</a>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{canCreateAlert && (
|
||||||
|
<Menu.Item key="new-alert">
|
||||||
|
<Link data-test="CreateAlertMenuItem" href="alerts/new">
|
||||||
|
New Alert
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</Menu.SubMenu>
|
||||||
|
)}
|
||||||
|
</NavbarSection>
|
||||||
|
|
||||||
|
<NavbarSection inlineCollapsed={collapsed}>
|
||||||
|
<Menu.Item key="help">
|
||||||
|
<HelpTrigger showTooltip={false} type="HOME">
|
||||||
|
<QuestionCircleOutlinedIcon />
|
||||||
|
<span>Help</span>
|
||||||
|
</HelpTrigger>
|
||||||
|
</Menu.Item>
|
||||||
|
{firstSettingsTab && (
|
||||||
|
<Menu.Item key="settings">
|
||||||
|
<Link href={firstSettingsTab.path} data-test="SettingsLink">
|
||||||
|
<SettingOutlinedIcon />
|
||||||
|
<span>Settings</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
<Menu.Divider />
|
||||||
|
</NavbarSection>
|
||||||
|
|
||||||
|
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-profile-menu">
|
||||||
|
<Menu.SubMenu
|
||||||
|
key="profile"
|
||||||
|
popupClassName="desktop-navbar-submenu"
|
||||||
|
title={
|
||||||
|
<span data-test="ProfileDropdown" className="desktop-navbar-profile-menu-title">
|
||||||
|
<img className="profile__image_thumb" src={currentUser.profile_image_url} alt={currentUser.name} />
|
||||||
|
<span>{currentUser.name}</span>
|
||||||
|
</span>
|
||||||
|
}>
|
||||||
|
<Menu.Item key="profile">
|
||||||
|
<Link href="users/me">Profile</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
{currentUser.hasPermission("super_admin") && (
|
||||||
|
<Menu.Item key="status">
|
||||||
|
<Link href="admin/status">System Status</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item key="logout">
|
||||||
|
<a data-test="LogOutButton" onClick={() => Auth.logout()}>
|
||||||
|
Log out
|
||||||
|
</a>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item key="version" disabled className="version-info">
|
||||||
|
<VersionInfo />
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.SubMenu>
|
||||||
|
</NavbarSection>
|
||||||
|
|
||||||
|
<Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button">
|
||||||
|
{collapsed ? <MenuUnfoldOutlinedIcon /> : <MenuFoldOutlinedIcon />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
@backgroundColor: #001529;
|
||||||
|
@dividerColor: rgba(255, 255, 255, 0.5);
|
||||||
|
@textColor: rgba(255, 255, 255, 0.75);
|
||||||
|
|
||||||
|
.desktop-navbar {
|
||||||
|
background: @backgroundColor;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&-spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-logo.ant-menu {
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 40px;
|
||||||
|
transition: all 270ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-menu-inline-collapsed {
|
||||||
|
img {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-trigger {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-menu {
|
||||||
|
&:not(.ant-menu-inline-collapsed) {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-menu-inline-collapsed > .ant-menu-submenu-title span img + span,
|
||||||
|
&.ant-menu-inline-collapsed > .ant-menu-item i + span {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-menu-item-divider {
|
||||||
|
background: @dividerColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-menu-item,
|
||||||
|
.ant-menu-submenu {
|
||||||
|
font-weight: 500;
|
||||||
|
color: @textColor;
|
||||||
|
|
||||||
|
&.ant-menu-submenu-open,
|
||||||
|
&.ant-menu-submenu-active,
|
||||||
|
&:hover,
|
||||||
|
&:active {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
span,
|
||||||
|
.anticon {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-menu-submenu-arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn.desktop-navbar-collapse-button {
|
||||||
|
background-color: @backgroundColor;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
color: @textColor;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
animation: 0s !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-navbar-profile-menu {
|
||||||
|
.desktop-navbar-profile-menu-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.profile__image_thumb {
|
||||||
|
margin: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile__image_thumb + span {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
margin-left: 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
// styles from Antd
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
|
||||||
|
margin-left 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-menu-inline-collapsed {
|
||||||
|
.ant-menu-submenu-title {
|
||||||
|
padding-left: 16px !important;
|
||||||
|
padding-right: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-navbar-profile-menu-title {
|
||||||
|
.profile__image_thumb + span {
|
||||||
|
opacity: 0;
|
||||||
|
max-width: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-navbar-submenu {
|
||||||
|
.ant-menu {
|
||||||
|
.ant-menu-item-divider {
|
||||||
|
background: @dividerColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-menu-item {
|
||||||
|
font-weight: 500;
|
||||||
|
color: @textColor;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
span,
|
||||||
|
.anticon {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zmdi,
|
||||||
|
.fa {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.version-info {
|
||||||
|
height: auto;
|
||||||
|
line-height: normal;
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active {
|
||||||
|
color: rgba(255, 255, 255, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { first } from "lodash";
|
||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import Button from "antd/lib/button";
|
||||||
|
import MenuOutlinedIcon from "@ant-design/icons/MenuOutlined";
|
||||||
|
import Dropdown from "antd/lib/dropdown";
|
||||||
|
import Menu from "antd/lib/menu";
|
||||||
|
import Link from "@/components/Link";
|
||||||
|
import { Auth, currentUser } from "@/services/auth";
|
||||||
|
import settingsMenu from "@/services/settingsMenu";
|
||||||
|
import logoUrl from "@/assets/images/redash_icon_small.png";
|
||||||
|
|
||||||
|
import "./MobileNavbar.less";
|
||||||
|
|
||||||
|
export default function MobileNavbar({ getPopupContainer }) {
|
||||||
|
const firstSettingsTab = first(settingsMenu.getAvailableItems());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-navbar">
|
||||||
|
<div className="mobile-navbar-logo">
|
||||||
|
<Link href="./">
|
||||||
|
<img src={logoUrl} alt="Redash" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Dropdown
|
||||||
|
overlayStyle={{ minWidth: 200 }}
|
||||||
|
trigger={["click"]}
|
||||||
|
getPopupContainer={getPopupContainer} // so the overlay menu stays with the fixed header when page scrolls
|
||||||
|
overlay={
|
||||||
|
<Menu mode="vertical" theme="dark" selectable={false} className="mobile-navbar-menu">
|
||||||
|
{currentUser.hasPermission("list_dashboards") && (
|
||||||
|
<Menu.Item key="dashboards">
|
||||||
|
<Link href="dashboards">Dashboards</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{currentUser.hasPermission("view_query") && (
|
||||||
|
<Menu.Item key="queries">
|
||||||
|
<Link href="queries">Queries</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{currentUser.hasPermission("list_alerts") && (
|
||||||
|
<Menu.Item key="alerts">
|
||||||
|
<Link href="alerts">Alerts</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
<Menu.Item key="profile">
|
||||||
|
<Link href="users/me">Edit Profile</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
{firstSettingsTab && (
|
||||||
|
<Menu.Item key="settings">
|
||||||
|
<Link href={firstSettingsTab.path}>Settings</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{currentUser.hasPermission("super_admin") && (
|
||||||
|
<Menu.Item key="status">
|
||||||
|
<Link href="admin/status">System Status</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{currentUser.hasPermission("super_admin") && <Menu.Divider />}
|
||||||
|
<Menu.Item key="help">
|
||||||
|
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||||
|
<Link href="https://redash.io/help" target="_blank" rel="noopener">
|
||||||
|
Help
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item key="logout" onClick={() => Auth.logout()}>
|
||||||
|
Log out
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
}>
|
||||||
|
<Button className="mobile-navbar-toggle-button" ghost>
|
||||||
|
<MenuOutlinedIcon />
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MobileNavbar.propTypes = {
|
||||||
|
getPopupContainer: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
MobileNavbar.defaultProps = {
|
||||||
|
getPopupContainer: null,
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@backgroundColor: #001529;
|
||||||
|
@dividerColor: rgba(255, 255, 255, 0.5);
|
||||||
|
@textColor: rgba(255, 255, 255, 0.75);
|
||||||
|
|
||||||
|
.mobile-navbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: @backgroundColor;
|
||||||
|
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
|
||||||
|
padding: 0 15px;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&-logo {
|
||||||
|
img {
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn.mobile-navbar-toggle-button {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navbar-menu {
|
||||||
|
.ant-dropdown-menu-item {
|
||||||
|
font-weight: 500;
|
||||||
|
color: @textColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-dropdown-menu-item-divider {
|
||||||
|
background: @dividerColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Link from "@/components/Link";
|
||||||
|
import { clientConfig, currentUser } from "@/services/auth";
|
||||||
|
import frontendVersion from "@/version.json";
|
||||||
|
|
||||||
|
export default function VersionInfo() {
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<div>
|
||||||
|
Version: {clientConfig.version}
|
||||||
|
{frontendVersion !== clientConfig.version && ` (${frontendVersion.substring(0, 8)})`}
|
||||||
|
</div>
|
||||||
|
{clientConfig.newVersionAvailable && currentUser.hasPermission("super_admin") && (
|
||||||
|
<div className="m-t-10">
|
||||||
|
{/* eslint-disable react/jsx-no-target-blank */}
|
||||||
|
<Link href="https://version.redash.io/" className="update-available" target="_blank" rel="noopener">
|
||||||
|
Update Available
|
||||||
|
<i className="fa fa-external-link m-l-5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { useRef, useCallback } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
|
import DesktopNavbar from "./DesktopNavbar";
|
||||||
|
import MobileNavbar from "./MobileNavbar";
|
||||||
|
|
||||||
|
import "./index.less";
|
||||||
|
|
||||||
|
export default function ApplicationLayout({ children }) {
|
||||||
|
const mobileNavbarContainerRef = useRef();
|
||||||
|
|
||||||
|
const getMobileNavbarPopupContainer = useCallback(() => mobileNavbarContainerRef.current, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<DynamicComponent name="ApplicationWrapper">
|
||||||
|
<div className="application-layout-side-menu">
|
||||||
|
<DynamicComponent name="ApplicationDesktopNavbar">
|
||||||
|
<DesktopNavbar />
|
||||||
|
</DynamicComponent>
|
||||||
|
</div>
|
||||||
|
<div className="application-layout-content">
|
||||||
|
<nav className="application-layout-top-menu" ref={mobileNavbarContainerRef}>
|
||||||
|
<DynamicComponent name="ApplicationMobileNavbar" getPopupContainer={getMobileNavbarPopupContainer}>
|
||||||
|
<MobileNavbar getPopupContainer={getMobileNavbarPopupContainer} />
|
||||||
|
</DynamicComponent>
|
||||||
|
</nav>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</DynamicComponent>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplicationLayout.propTypes = {
|
||||||
|
children: PropTypes.node,
|
||||||
|
};
|
||||||
|
|
||||||
|
ApplicationLayout.defaultProps = {
|
||||||
|
children: null,
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
@mobileBreakpoint: ~"(max-width: 767px)";
|
||||||
|
|
||||||
|
body #application-root {
|
||||||
|
@topMenuHeight: 49px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: stretch;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
.application-layout-side-menu {
|
||||||
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
@media @mobileBreakpoint {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-layout-top-menu {
|
||||||
|
height: @topMenuHeight;
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
@media @mobileBreakpoint {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-layout-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
|
||||||
|
@media @mobileBreakpoint {
|
||||||
|
margin-top: @topMenuHeight; // compensate for app header fixed position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.fixed-layout #application-root {
|
||||||
|
.application-layout-content {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.headless #application-root {
|
||||||
|
.application-layout-side-menu,
|
||||||
|
.application-layout-top-menu {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-layout-content {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixes for proper snapshots in Percy (move vertical scroll to body level
|
||||||
|
// to capture entire page, otherwise it wll be cut by viewport)
|
||||||
|
@media only percy {
|
||||||
|
body #application-root {
|
||||||
|
height: auto;
|
||||||
|
|
||||||
|
.application-layout-side-menu {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-layout-content {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { isObject, get } from "lodash";
|
import { get, isObject } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
import "./ErrorMessage.less";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
|
import { ErrorMessageDetails } from "@/components/ApplicationArea/ErrorMessageDetails";
|
||||||
|
|
||||||
function getErrorMessageByStatus(status, defaultMessage) {
|
function getErrorMessageByStatus(status, defaultMessage) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 404:
|
case 404:
|
||||||
@@ -14,50 +18,45 @@ function getErrorMessageByStatus(status, defaultMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getErrorMessage(
|
function getErrorMessage(error) {
|
||||||
error,
|
const message = "It seems like we encountered an error. Try refreshing this page or contact your administrator.";
|
||||||
defaultMessage = "It seems like we encountered an error. Try refreshing this page or contact your administrator."
|
|
||||||
) {
|
|
||||||
if (isObject(error)) {
|
if (isObject(error)) {
|
||||||
// HTTP errors
|
// HTTP errors
|
||||||
if (error.isAxiosError && isObject(error.response)) {
|
if (error.isAxiosError && isObject(error.response)) {
|
||||||
const errorData = get(error, "response.data", {});
|
return getErrorMessageByStatus(error.response.status, get(error, "response.data.message", message));
|
||||||
|
|
||||||
// handle cases where the message is an object as { "message": msg } or { "error": msg }
|
|
||||||
const errorMessage = errorData.message || errorData.error || defaultMessage;
|
|
||||||
return getErrorMessageByStatus(error.response.status, errorMessage);
|
|
||||||
}
|
}
|
||||||
// Router errors
|
// Router errors
|
||||||
if (error.status) {
|
if (error.status) {
|
||||||
return getErrorMessageByStatus(error.status, defaultMessage);
|
return getErrorMessageByStatus(error.status, message);
|
||||||
}
|
|
||||||
// Other Error instances
|
|
||||||
if (error.message) {
|
|
||||||
return error.message;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return defaultMessage;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ErrorMessage({ error }) {
|
export default function ErrorMessage({ error, message }) {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
|
const errorDetailsProps = {
|
||||||
|
error,
|
||||||
|
message: message || getErrorMessage(error),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed-container" data-test="ErrorMessage">
|
<div className="error-message-container" data-test="ErrorMessage" role="alert">
|
||||||
<div className="container">
|
<div className="error-state bg-white tiled">
|
||||||
<div className="col-md-8 col-md-push-2">
|
<div className="error-state__icon">
|
||||||
<div className="error-state bg-white tiled">
|
<i className="zmdi zmdi-alert-circle-o" />
|
||||||
<div className="error-state__icon">
|
</div>
|
||||||
<i className="zmdi zmdi-alert-circle-o" />
|
<div className="error-state__details">
|
||||||
</div>
|
<DynamicComponent
|
||||||
<div className="error-state__details">
|
name="ErrorMessageDetails"
|
||||||
<h4>{getErrorMessage(error)}</h4>
|
fallback={<ErrorMessageDetails {...errorDetailsProps} />}
|
||||||
</div>
|
{...errorDetailsProps}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,4 +65,5 @@ export default function ErrorMessage({ error }) {
|
|||||||
|
|
||||||
ErrorMessage.propTypes = {
|
ErrorMessage.propTypes = {
|
||||||
error: PropTypes.object.isRequired,
|
error: PropTypes.object.isRequired,
|
||||||
|
message: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|||||||
17
client/app/components/ApplicationArea/ErrorMessage.less
Normal file
17
client/app/components/ApplicationArea/ErrorMessage.less
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
.error-message-container {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
max-width: 1200px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
width: 65%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
client/app/components/ApplicationArea/ErrorMessage.test.js
Normal file
51
client/app/components/ApplicationArea/ErrorMessage.test.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { mount } from "enzyme";
|
||||||
|
import ErrorMessage from "./ErrorMessage";
|
||||||
|
|
||||||
|
const ErrorMessages = {
|
||||||
|
UNAUTHORIZED: "It seems like you don’t have permission to see this page.",
|
||||||
|
NOT_FOUND: "It seems like the page you're looking for cannot be found.",
|
||||||
|
GENERIC: "It seems like we encountered an error. Try refreshing this page or contact your administrator.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockAxiosError(status = 500, response = {}) {
|
||||||
|
const error = new Error(`Failed with code ${status}.`);
|
||||||
|
error.isAxiosError = true;
|
||||||
|
error.response = { status, ...response };
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Error Message", () => {
|
||||||
|
const spyError = jest.spyOn(console, "error");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
spyError.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function expectErrorMessageToBe(error, errorMessage) {
|
||||||
|
const component = mount(<ErrorMessage error={error} />);
|
||||||
|
|
||||||
|
expect(component.find(".error-state__details h4").text()).toBe(errorMessage);
|
||||||
|
expect(spyError).toHaveBeenCalledWith(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("displays a generic message on adhoc errors", () => {
|
||||||
|
expectErrorMessageToBe(new Error("technical information"), ErrorMessages.GENERIC);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("displays a not found message on axios errors with 404 code", () => {
|
||||||
|
expectErrorMessageToBe(mockAxiosError(404), ErrorMessages.NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("displays a unauthorized message on axios errors with 401 code", () => {
|
||||||
|
expectErrorMessageToBe(mockAxiosError(401), ErrorMessages.UNAUTHORIZED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("displays a unauthorized message on axios errors with 403 code", () => {
|
||||||
|
expectErrorMessageToBe(mockAxiosError(403), ErrorMessages.UNAUTHORIZED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("displays a generic message on axios errors with 500 code", () => {
|
||||||
|
expectErrorMessageToBe(mockAxiosError(500), ErrorMessages.GENERIC);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
export function ErrorMessageDetails(props) {
|
||||||
|
return <h4>{props.message}</h4>;
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorMessageDetails.propTypes = {
|
||||||
|
error: PropTypes.instanceOf(Error).isRequired,
|
||||||
|
message: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isFunction, startsWith, trimStart, trimEnd } from "lodash";
|
import { isFunction, startsWith, trimStart, trimEnd } from "lodash";
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef, useContext } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import UniversalRouter from "universal-router";
|
import UniversalRouter from "universal-router";
|
||||||
import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary";
|
import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary";
|
||||||
@@ -14,6 +14,12 @@ function generateRouteKey() {
|
|||||||
.substr(2);
|
.substr(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CurrentRouteContext = React.createContext(null);
|
||||||
|
|
||||||
|
export function useCurrentRoute() {
|
||||||
|
return useContext(CurrentRouteContext);
|
||||||
|
}
|
||||||
|
|
||||||
export function stripBase(href) {
|
export function stripBase(href) {
|
||||||
// Resolve provided link and '' (root) relative to document's base.
|
// Resolve provided link and '' (root) relative to document's base.
|
||||||
// If provided href is not related to current document (does not
|
// If provided href is not related to current document (does not
|
||||||
@@ -53,7 +59,7 @@ export default function Router({ routes, onRouteChange }) {
|
|||||||
errorHandlerRef.current.reset();
|
errorHandlerRef.current.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathname = stripBase(location.path);
|
const pathname = stripBase(location.path) || "/";
|
||||||
|
|
||||||
// This is a optimization for route resolver: if current route was already resolved
|
// This is a optimization for route resolver: if current route was already resolved
|
||||||
// from this path - do nothing. It also prevents router from using outdated route in a case
|
// from this path - do nothing. It also prevents router from using outdated route in a case
|
||||||
@@ -95,6 +101,7 @@ export default function Router({ routes, onRouteChange }) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isAbandoned = true;
|
isAbandoned = true;
|
||||||
|
currentPathRef.current = null;
|
||||||
unlisten();
|
unlisten();
|
||||||
};
|
};
|
||||||
}, [routes]);
|
}, [routes]);
|
||||||
@@ -108,9 +115,11 @@ export default function Router({ routes, onRouteChange }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}>
|
<CurrentRouteContext.Provider value={currentRoute}>
|
||||||
{currentRoute.render(currentRoute)}
|
<ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}>
|
||||||
</ErrorBoundary>
|
{currentRoute.render(currentRoute)}
|
||||||
|
</ErrorBoundary>
|
||||||
|
</CurrentRouteContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function handleNavigationIntent(event) {
|
|||||||
}
|
}
|
||||||
element = element.parentNode;
|
element = element.parentNode;
|
||||||
}
|
}
|
||||||
if (!element || !element.hasAttribute("href") || element.hasAttribute("download")) {
|
if (!element || !element.hasAttribute("href") || element.hasAttribute("download") || element.dataset.skipRouter) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import routes from "@/pages";
|
import routes from "@/services/routes";
|
||||||
import Router from "./Router";
|
import Router from "./Router";
|
||||||
import handleNavigationIntent from "./handleNavigationIntent";
|
import handleNavigationIntent from "./handleNavigationIntent";
|
||||||
import ErrorMessage from "./ErrorMessage";
|
import ErrorMessage from "./ErrorMessage";
|
||||||
@@ -33,5 +33,5 @@ export default function ApplicationArea() {
|
|||||||
return <ErrorMessage error={unhandledError} />;
|
return <ErrorMessage error={unhandledError} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Router routes={routes} onRouteChange={setCurrentRoute} />;
|
return <Router routes={routes.items} onRouteChange={setCurrentRoute} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState, useContext } from "react";
|
import React, { useEffect, useState, useContext } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
import { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||||
import { Auth } from "@/services/auth";
|
import { Auth, clientConfig } from "@/services/auth";
|
||||||
|
|
||||||
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
|
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
|
||||||
// that contains:
|
// that contains:
|
||||||
@@ -33,7 +33,7 @@ function ApiKeySessionWrapper({ apiKey, currentRoute, renderChildren }) {
|
|||||||
};
|
};
|
||||||
}, [apiKey]);
|
}, [apiKey]);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated || clientConfig.disablePublicUrls) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
|
||||||
import { Auth } from "@/services/auth";
|
|
||||||
import organizationStatus from "@/services/organizationStatus";
|
|
||||||
import ApplicationHeader from "./ApplicationHeader";
|
|
||||||
import ErrorMessage from "./ErrorMessage";
|
|
||||||
|
|
||||||
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
|
|
||||||
// that contains:
|
|
||||||
// - `currentRoute.routeParams`
|
|
||||||
// - `pageTitle` field which is equal to `currentRoute.title`
|
|
||||||
// - `onError` field which is a `handleError` method of nearest error boundary
|
|
||||||
|
|
||||||
function UserSessionWrapper({ bodyClass, currentRoute, renderChildren }) {
|
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(!!Auth.isAuthenticated());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isCancelled = false;
|
|
||||||
Promise.all([Auth.requireSession(), organizationStatus.refresh()])
|
|
||||||
.then(() => {
|
|
||||||
if (!isCancelled) {
|
|
||||||
setIsAuthenticated(!!Auth.isAuthenticated());
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!isCancelled) {
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
isCancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (bodyClass) {
|
|
||||||
document.body.classList.toggle(bodyClass, true);
|
|
||||||
return () => {
|
|
||||||
document.body.classList.toggle(bodyClass, false);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [bodyClass]);
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<ApplicationHeader />
|
|
||||||
<React.Fragment key={currentRoute.key}>
|
|
||||||
<ErrorBoundary renderError={error => <ErrorMessage error={error} />}>
|
|
||||||
<ErrorBoundaryContext.Consumer>
|
|
||||||
{({ handleError }) =>
|
|
||||||
renderChildren({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
|
|
||||||
}
|
|
||||||
</ErrorBoundaryContext.Consumer>
|
|
||||||
</ErrorBoundary>
|
|
||||||
</React.Fragment>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
UserSessionWrapper.propTypes = {
|
|
||||||
bodyClass: PropTypes.string,
|
|
||||||
renderChildren: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
UserSessionWrapper.defaultProps = {
|
|
||||||
bodyClass: null,
|
|
||||||
renderChildren: () => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function routeWithUserSession({ render, bodyClass, ...rest }) {
|
|
||||||
return {
|
|
||||||
...rest,
|
|
||||||
render: currentRoute => (
|
|
||||||
<UserSessionWrapper bodyClass={bodyClass} currentRoute={currentRoute} renderChildren={render} />
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
108
client/app/components/ApplicationArea/routeWithUserSession.tsx
Normal file
108
client/app/components/ApplicationArea/routeWithUserSession.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
// @ts-expect-error (Must be removed after adding @redash/viz typing)
|
||||||
|
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||||
|
import { Auth } from "@/services/auth";
|
||||||
|
import { policy } from "@/services/policy";
|
||||||
|
import { CurrentRoute } from "@/services/routes";
|
||||||
|
import organizationStatus from "@/services/organizationStatus";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
|
import ApplicationLayout from "./ApplicationLayout";
|
||||||
|
import ErrorMessage from "./ErrorMessage";
|
||||||
|
|
||||||
|
export type UserSessionWrapperRenderChildrenProps<P> = {
|
||||||
|
pageTitle?: string;
|
||||||
|
onError: (error: Error) => void;
|
||||||
|
} & P;
|
||||||
|
|
||||||
|
export interface UserSessionWrapperProps<P> {
|
||||||
|
render: (props: UserSessionWrapperRenderChildrenProps<P>) => React.ReactNode;
|
||||||
|
currentRoute: CurrentRoute<P>;
|
||||||
|
bodyClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
|
||||||
|
// that contains:
|
||||||
|
// - `currentRoute.routeParams`
|
||||||
|
// - `pageTitle` field which is equal to `currentRoute.title`
|
||||||
|
// - `onError` field which is a `handleError` method of nearest error boundary
|
||||||
|
|
||||||
|
export function UserSessionWrapper<P>({ bodyClass, currentRoute, render }: UserSessionWrapperProps<P>) {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(!!Auth.isAuthenticated());
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false;
|
||||||
|
Promise.all([Auth.requireSession(), organizationStatus.refresh(), policy.refresh()])
|
||||||
|
.then(() => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setIsAuthenticated(!!Auth.isAuthenticated());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bodyClass) {
|
||||||
|
document.body.classList.toggle(bodyClass, true);
|
||||||
|
return () => {
|
||||||
|
document.body.classList.toggle(bodyClass, false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [bodyClass]);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ApplicationLayout>
|
||||||
|
<React.Fragment key={currentRoute.key}>
|
||||||
|
<ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}>
|
||||||
|
<ErrorBoundaryContext.Consumer>
|
||||||
|
{({ handleError }: { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] }) =>
|
||||||
|
render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
|
||||||
|
}
|
||||||
|
</ErrorBoundaryContext.Consumer>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</React.Fragment>
|
||||||
|
</ApplicationLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RouteWithUserSessionOptions<P> = {
|
||||||
|
render: (props: UserSessionWrapperRenderChildrenProps<P>) => React.ReactNode;
|
||||||
|
bodyClass?: string;
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserSessionWrapperDynamicComponentName = "UserSessionWrapper";
|
||||||
|
|
||||||
|
export default function routeWithUserSession<P extends {} = {}>({
|
||||||
|
render: originalRender,
|
||||||
|
bodyClass,
|
||||||
|
...rest
|
||||||
|
}: RouteWithUserSessionOptions<P>) {
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
render: (currentRoute: CurrentRoute<P>) => {
|
||||||
|
const props = {
|
||||||
|
render: originalRender,
|
||||||
|
bodyClass,
|
||||||
|
currentRoute,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<DynamicComponent
|
||||||
|
{...props}
|
||||||
|
name={UserSessionWrapperDynamicComponentName}
|
||||||
|
fallback={<UserSessionWrapper {...props} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import Card from "antd/lib/card";
|
|||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Typography from "antd/lib/typography";
|
import Typography from "antd/lib/typography";
|
||||||
import { clientConfig } from "@/services/auth";
|
import { clientConfig } from "@/services/auth";
|
||||||
|
import Link from "@/components/Link";
|
||||||
import HelpTrigger from "@/components/HelpTrigger";
|
import HelpTrigger from "@/components/HelpTrigger";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import OrgSettings from "@/services/organizationSettings";
|
import OrgSettings from "@/services/organizationSettings";
|
||||||
@@ -65,8 +66,8 @@ function BeaconConsent() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="m-t-15">
|
<div className="m-t-15">
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
You can change this setting anytime from the <a href="settings/organization">Organization Settings</a>{" "}
|
You can change this setting anytime from the{" "}
|
||||||
page.
|
<Link href="settings/organization">Organization Settings</Link> page.
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from "react";
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Tooltip from "antd/lib/tooltip";
|
import Tooltip from "antd/lib/tooltip";
|
||||||
|
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
|
||||||
import "./CodeBlock.less";
|
import "./CodeBlock.less";
|
||||||
|
|
||||||
export default class CodeBlock extends React.Component {
|
export default class CodeBlock extends React.Component {
|
||||||
@@ -59,7 +60,7 @@ export default class CodeBlock extends React.Component {
|
|||||||
|
|
||||||
const copyButton = (
|
const copyButton = (
|
||||||
<Tooltip title={this.state.copied || "Copy"}>
|
<Tooltip title={this.state.copied || "Copy"}>
|
||||||
<Button icon="copy" type="dashed" size="small" onClick={this.copy} />
|
<Button icon={<CopyOutlinedIcon />} type="dashed" size="small" onClick={this.copy} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { isEmpty, toUpper, includes } from "lodash";
|
import { isEmpty, toUpper, includes, get } from "lodash";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import List from "antd/lib/list";
|
import List from "antd/lib/list";
|
||||||
import Modal from "antd/lib/modal";
|
import Modal from "antd/lib/modal";
|
||||||
import Input from "antd/lib/input";
|
import Input from "antd/lib/input";
|
||||||
import Steps from "antd/lib/steps";
|
import Steps from "antd/lib/steps";
|
||||||
import { getErrorMessage } from "@/components/ApplicationArea/ErrorMessage";
|
|
||||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||||
|
import Link from "@/components/Link";
|
||||||
import { PreviewCard } from "@/components/PreviewCard";
|
import { PreviewCard } from "@/components/PreviewCard";
|
||||||
import EmptyState from "@/components/items-list/components/EmptyState";
|
import EmptyState from "@/components/items-list/components/EmptyState";
|
||||||
import DynamicForm from "@/components/dynamic-form/DynamicForm";
|
import DynamicForm from "@/components/dynamic-form/DynamicForm";
|
||||||
@@ -67,7 +67,7 @@ class CreateSourceDialog extends React.Component {
|
|||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
this.setState({ savingSource: false, currentStep: StepEnum.CONFIGURE_IT });
|
this.setState({ savingSource: false, currentStep: StepEnum.CONFIGURE_IT });
|
||||||
errorCallback(getErrorMessage(error, "Failed saving."));
|
errorCallback(get(error, "response.data.message", "Failed saving."));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -119,9 +119,9 @@ class CreateSourceDialog extends React.Component {
|
|||||||
{selectedType.type === "databricks" && (
|
{selectedType.type === "databricks" && (
|
||||||
<small>
|
<small>
|
||||||
By using the Databricks Data Source you agree to the Databricks JDBC/ODBC{" "}
|
By using the Databricks Data Source you agree to the Databricks JDBC/ODBC{" "}
|
||||||
<a href="https://databricks.com/spark/odbc-driver-download" target="_blank" rel="noopener noreferrer">
|
<Link href="https://databricks.com/spark/odbc-driver-download" target="_blank" rel="noopener noreferrer">
|
||||||
Driver Download Terms and Conditions
|
Driver Download Terms and Conditions
|
||||||
</a>
|
</Link>
|
||||||
.
|
.
|
||||||
</small>
|
</small>
|
||||||
)}
|
)}
|
||||||
@@ -155,7 +155,7 @@ class CreateSourceDialog extends React.Component {
|
|||||||
footer={
|
footer={
|
||||||
currentStep === StepEnum.SELECT_TYPE
|
currentStep === StepEnum.SELECT_TYPE
|
||||||
? [
|
? [
|
||||||
<Button key="cancel" onClick={() => dialog.dismiss()}>
|
<Button key="cancel" onClick={() => dialog.dismiss()} data-test="CreateSourceCancelButton">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button key="submit" type="primary" disabled>
|
<Button key="submit" type="primary" disabled>
|
||||||
@@ -172,7 +172,7 @@ class CreateSourceDialog extends React.Component {
|
|||||||
form="sourceForm"
|
form="sourceForm"
|
||||||
type="primary"
|
type="primary"
|
||||||
loading={savingSource}
|
loading={savingSource}
|
||||||
data-test="CreateSourceButton">
|
data-test="CreateSourceSaveButton">
|
||||||
Create
|
Create
|
||||||
</Button>,
|
</Button>,
|
||||||
]
|
]
|
||||||
|
|||||||
30
client/app/components/DialogWrapper.d.ts
vendored
Normal file
30
client/app/components/DialogWrapper.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ModalProps } from "antd/lib/modal/Modal";
|
||||||
|
|
||||||
|
export interface DialogProps<ROk, RCancel> {
|
||||||
|
props: ModalProps;
|
||||||
|
close: (result: ROk) => void;
|
||||||
|
dismiss: (result: RCancel) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DialogWrapperChildProps<ROk, RCancel> = {
|
||||||
|
dialog: DialogProps<ROk, RCancel>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DialogComponentType<ROk = void, P = {}, RCancel = void> = React.ComponentType<
|
||||||
|
DialogWrapperChildProps<ROk, RCancel> & P
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function wrap<ROk = void, P = {}, RCancel = void>(
|
||||||
|
DialogComponent: DialogComponentType<ROk, P, RCancel>
|
||||||
|
): {
|
||||||
|
Component: DialogComponentType<ROk, P, RCancel>;
|
||||||
|
showModal: (
|
||||||
|
props?: P
|
||||||
|
) => {
|
||||||
|
update: (props: P) => void;
|
||||||
|
onClose: (handler: (result: ROk) => Promise<void> | void) => void;
|
||||||
|
onDismiss: (handler: (result: RCancel) => Promise<void> | void) => void;
|
||||||
|
close: (result: ROk) => void;
|
||||||
|
dismiss: (result: RCancel) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isFunction, isString } from "lodash";
|
import { isFunction, isString, isUndefined } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ export function unregisterComponent(name) {
|
|||||||
export default class DynamicComponent extends React.Component {
|
export default class DynamicComponent extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
|
fallback: PropTypes.node,
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,10 +41,11 @@ export default class DynamicComponent extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { name, children, ...props } = this.props;
|
const { name, children, fallback, ...props } = this.props;
|
||||||
const RealComponent = componentsRegistry.get(name);
|
const RealComponent = componentsRegistry.get(name);
|
||||||
if (!RealComponent) {
|
if (!RealComponent) {
|
||||||
return children;
|
// return fallback if any, otherwise return children
|
||||||
|
return isUndefined(fallback) ? children : fallback;
|
||||||
}
|
}
|
||||||
return <RealComponent {...props}>{children}</RealComponent>;
|
return <RealComponent {...props}>{children}</RealComponent>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { includes, words, capitalize, clone, isNull } from "lodash";
|
import { includes, words, capitalize, clone, isNull, map, get, find } from "lodash";
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Checkbox from "antd/lib/checkbox";
|
import Checkbox from "antd/lib/checkbox";
|
||||||
import Modal from "antd/lib/modal";
|
import Modal from "antd/lib/modal";
|
||||||
@@ -11,6 +11,8 @@ import Divider from "antd/lib/divider";
|
|||||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||||
import QuerySelector from "@/components/QuerySelector";
|
import QuerySelector from "@/components/QuerySelector";
|
||||||
import { Query } from "@/services/query";
|
import { Query } from "@/services/query";
|
||||||
|
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
||||||
|
import QueryBasedParameterMappingTable from "./query-based-parameter/QueryBasedParameterMappingTable";
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||||
@@ -69,17 +71,27 @@ NameInput.propTypes = {
|
|||||||
function EditParameterSettingsDialog(props) {
|
function EditParameterSettingsDialog(props) {
|
||||||
const [param, setParam] = useState(clone(props.parameter));
|
const [param, setParam] = useState(clone(props.parameter));
|
||||||
const [isNameValid, setIsNameValid] = useState(true);
|
const [isNameValid, setIsNameValid] = useState(true);
|
||||||
const [initialQuery, setInitialQuery] = useState();
|
const [paramQuery, setParamQuery] = useState();
|
||||||
|
const mappingParameters = useMemo(
|
||||||
|
() =>
|
||||||
|
map(paramQuery && paramQuery.getParametersDefs(), mappingParam => ({
|
||||||
|
mappingParam,
|
||||||
|
existingMapping: get(param.parameterMapping, mappingParam.name, {
|
||||||
|
mappingType: QueryBasedParameterMappingType.UNDEFINED,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
[param.parameterMapping, paramQuery]
|
||||||
|
);
|
||||||
|
|
||||||
const isNew = !props.parameter.name;
|
const isNew = !props.parameter.name;
|
||||||
|
|
||||||
// fetch query by id
|
// fetch query by id
|
||||||
|
const initialQueryId = useRef(props.parameter.queryId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const queryId = props.parameter.queryId;
|
if (initialQueryId.current) {
|
||||||
if (queryId) {
|
Query.get({ id: initialQueryId.current }).then(setParamQuery);
|
||||||
Query.get({ id: queryId }).then(setInitialQuery);
|
|
||||||
}
|
}
|
||||||
}, [props.parameter.queryId]);
|
}, []);
|
||||||
|
|
||||||
function isFulfilled() {
|
function isFulfilled() {
|
||||||
// name
|
// name
|
||||||
@@ -93,14 +105,20 @@ function EditParameterSettingsDialog(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// query
|
// query
|
||||||
if (param.type === "query" && !param.queryId) {
|
if (param.type === "query") {
|
||||||
return false;
|
if (!param.queryId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (find(mappingParameters, { existingMapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED } })) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onConfirm(e) {
|
function onConfirm() {
|
||||||
// update title to default
|
// update title to default
|
||||||
if (!param.title) {
|
if (!param.title) {
|
||||||
// forced to do this cause param won't update in time for save
|
// forced to do this cause param won't update in time for save
|
||||||
@@ -109,8 +127,6 @@ function EditParameterSettingsDialog(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
props.dialog.close(param);
|
props.dialog.close(param);
|
||||||
|
|
||||||
e.preventDefault(); // stops form redirect
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,7 +148,7 @@ function EditParameterSettingsDialog(props) {
|
|||||||
{isNew ? "Add Parameter" : "OK"}
|
{isNew ? "Add Parameter" : "OK"}
|
||||||
</Button>,
|
</Button>,
|
||||||
]}>
|
]}>
|
||||||
<Form layout="horizontal" onSubmit={onConfirm} id="paramForm">
|
<Form layout="horizontal" onFinish={onConfirm} id="paramForm">
|
||||||
{isNew && (
|
{isNew && (
|
||||||
<NameInput
|
<NameInput
|
||||||
name={param.name}
|
name={param.name}
|
||||||
@@ -142,7 +158,7 @@ function EditParameterSettingsDialog(props) {
|
|||||||
type={param.type}
|
type={param.type}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Form.Item label="Title" {...formItemProps}>
|
<Form.Item required label="Title" {...formItemProps}>
|
||||||
<Input
|
<Input
|
||||||
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
|
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
|
||||||
onChange={e => setParam({ ...param, title: e.target.value })}
|
onChange={e => setParam({ ...param, title: e.target.value })}
|
||||||
@@ -189,14 +205,28 @@ function EditParameterSettingsDialog(props) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
{param.type === "query" && (
|
{param.type === "query" && (
|
||||||
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
|
<Form.Item label="Query" help="Select query to load dropdown values from" required {...formItemProps}>
|
||||||
<QuerySelector
|
<QuerySelector
|
||||||
selectedQuery={initialQuery}
|
selectedQuery={paramQuery}
|
||||||
onChange={q => setParam({ ...param, queryId: q && q.id })}
|
onChange={q => {
|
||||||
|
if (q) {
|
||||||
|
setParamQuery(q);
|
||||||
|
setParam({ ...param, queryId: q.id, parameterMapping: {} });
|
||||||
|
}
|
||||||
|
}}
|
||||||
type="select"
|
type="select"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
{param.type === "query" && paramQuery && paramQuery.hasParameters() && (
|
||||||
|
<Form.Item className="m-t-15 m-b-5" label="Parameters" required {...formItemProps}>
|
||||||
|
<QueryBasedParameterMappingTable
|
||||||
|
param={param}
|
||||||
|
mappingParameters={mappingParameters}
|
||||||
|
onChangeParam={setParam}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
{(param.type === "enum" || param.type === "query") && (
|
{(param.type === "enum" || param.type === "query") && (
|
||||||
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useReducer } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { values } from "lodash";
|
||||||
|
import Button from "antd/lib/button";
|
||||||
|
import Tooltip from "antd/lib/tooltip";
|
||||||
|
import Radio from "antd/lib/radio";
|
||||||
|
import Typography from "antd/lib/typography/Typography";
|
||||||
|
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||||
|
import InputPopover from "@/components/InputPopover";
|
||||||
|
import Form from "antd/lib/form";
|
||||||
|
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
||||||
|
|
||||||
|
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
||||||
|
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||||
|
export default function QueryBasedParameterMappingEditor({ parameter, mapping, searchAvailable, onChange }) {
|
||||||
|
const [showPopover, setShowPopover] = useState(false);
|
||||||
|
const [newMapping, setNewMapping] = useReducer((prevState, updates) => ({ ...prevState, ...updates }), mapping);
|
||||||
|
|
||||||
|
const newMappingRef = useRef(newMapping);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
mapping.mappingType !== newMappingRef.current.mappingType ||
|
||||||
|
mapping.staticValue !== newMappingRef.current.staticValue
|
||||||
|
) {
|
||||||
|
setNewMapping(mapping);
|
||||||
|
}
|
||||||
|
}, [mapping]);
|
||||||
|
|
||||||
|
const parameterRef = useRef(parameter);
|
||||||
|
useEffect(() => {
|
||||||
|
parameterRef.current.setValue(mapping.staticValue);
|
||||||
|
}, [mapping.staticValue]);
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
setNewMapping(mapping);
|
||||||
|
setShowPopover(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOk = () => {
|
||||||
|
onChange(newMapping);
|
||||||
|
setShowPopover(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentState = <Text type="secondary">Pick a type</Text>;
|
||||||
|
if (mapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH) {
|
||||||
|
currentState = "Dropdown Search";
|
||||||
|
} else if (mapping.mappingType === QueryBasedParameterMappingType.STATIC) {
|
||||||
|
currentState = `Value: ${mapping.staticValue}`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{currentState}
|
||||||
|
<InputPopover
|
||||||
|
placement="left"
|
||||||
|
trigger="click"
|
||||||
|
header="Edit Parameter Source"
|
||||||
|
okButtonProps={{
|
||||||
|
disabled: newMapping.mappingType === QueryBasedParameterMappingType.STATIC && parameter.isEmpty,
|
||||||
|
}}
|
||||||
|
onOk={onOk}
|
||||||
|
onCancel={onCancel}
|
||||||
|
content={
|
||||||
|
<Form>
|
||||||
|
<Form.Item className="m-b-15" label="Source" {...formItemProps}>
|
||||||
|
<Radio.Group
|
||||||
|
value={newMapping.mappingType}
|
||||||
|
onChange={({ target }) => setNewMapping({ mappingType: target.value })}>
|
||||||
|
<Radio
|
||||||
|
className="radio"
|
||||||
|
value={QueryBasedParameterMappingType.DROPDOWN_SEARCH}
|
||||||
|
disabled={!searchAvailable || parameter.type !== "text"}>
|
||||||
|
Dropdown Search{" "}
|
||||||
|
{(!searchAvailable || parameter.type !== "text") && (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
parameter.type !== "text"
|
||||||
|
? "Dropdown Search is only available for Text Parameters"
|
||||||
|
: "There is already a parameter mapped with the Dropdown Search type."
|
||||||
|
}>
|
||||||
|
<QuestionCircleFilledIcon />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Radio>
|
||||||
|
<Radio className="radio" value={QueryBasedParameterMappingType.STATIC}>
|
||||||
|
Static Value
|
||||||
|
</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
{newMapping.mappingType === QueryBasedParameterMappingType.STATIC && (
|
||||||
|
<Form.Item label="Value" required {...formItemProps}>
|
||||||
|
<ParameterValueInput
|
||||||
|
type={parameter.type}
|
||||||
|
value={parameter.normalizedValue}
|
||||||
|
enumOptions={parameter.enumOptions}
|
||||||
|
queryId={parameter.queryId}
|
||||||
|
parameter={parameter}
|
||||||
|
onSelect={value => {
|
||||||
|
parameter.setValue(value);
|
||||||
|
setNewMapping({ staticValue: parameter.getExecutionValue({ joinListValues: true }) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
}
|
||||||
|
visible={showPopover}
|
||||||
|
onVisibleChange={setShowPopover}>
|
||||||
|
<Button className="m-l-5" size="small" type="dashed">
|
||||||
|
<EditOutlinedIcon />
|
||||||
|
</Button>
|
||||||
|
</InputPopover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBasedParameterMappingEditor.propTypes = {
|
||||||
|
parameter: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
|
mapping: PropTypes.shape({
|
||||||
|
mappingType: PropTypes.oneOf(values(QueryBasedParameterMappingType)),
|
||||||
|
staticValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
|
}),
|
||||||
|
searchAvailable: PropTypes.bool,
|
||||||
|
onChange: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
QueryBasedParameterMappingEditor.defaultProps = {
|
||||||
|
mapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED, staticValue: undefined },
|
||||||
|
searchAvailable: false,
|
||||||
|
onChange: () => {},
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { findKey } from "lodash";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import Table from "antd/lib/table";
|
||||||
|
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
||||||
|
import QueryBasedParameterMappingEditor from "./QueryBasedParameterMappingEditor";
|
||||||
|
|
||||||
|
export default function QueryBasedParameterMappingTable({ param, mappingParameters, onChangeParam }) {
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
dataSource={mappingParameters}
|
||||||
|
size="middle"
|
||||||
|
pagination={false}
|
||||||
|
rowKey={({ mappingParam }) => `param${mappingParam.name}`}>
|
||||||
|
<Table.Column title="Title" key="title" render={({ mappingParam }) => mappingParam.getTitle()} />
|
||||||
|
<Table.Column
|
||||||
|
title="Keyword"
|
||||||
|
key="keyword"
|
||||||
|
className="keyword"
|
||||||
|
render={({ mappingParam }) => <code>{`{{ ${mappingParam.name} }}`}</code>}
|
||||||
|
/>
|
||||||
|
<Table.Column
|
||||||
|
title="Value Source"
|
||||||
|
key="source"
|
||||||
|
render={({ mappingParam, existingMapping }) => (
|
||||||
|
<QueryBasedParameterMappingEditor
|
||||||
|
parameter={mappingParam.setValue(existingMapping.staticValue)}
|
||||||
|
mapping={existingMapping}
|
||||||
|
searchAvailable={
|
||||||
|
!findKey(param.parameterMapping, {
|
||||||
|
mappingType: QueryBasedParameterMappingType.DROPDOWN_SEARCH,
|
||||||
|
}) || existingMapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH
|
||||||
|
}
|
||||||
|
onChange={mapping =>
|
||||||
|
onChangeParam({
|
||||||
|
...param,
|
||||||
|
parameterMapping: { ...param.parameterMapping, [mappingParam.name]: mapping },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBasedParameterMappingTable.propTypes = {
|
||||||
|
param: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
|
mappingParameters: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
|
||||||
|
onChangeParam: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
QueryBasedParameterMappingTable.defaultProps = {
|
||||||
|
mappingParameters: [],
|
||||||
|
onChangeParam: () => {},
|
||||||
|
};
|
||||||
@@ -3,7 +3,13 @@ import PropTypes from "prop-types";
|
|||||||
import Dropdown from "antd/lib/dropdown";
|
import Dropdown from "antd/lib/dropdown";
|
||||||
import Menu from "antd/lib/menu";
|
import Menu from "antd/lib/menu";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Icon from "antd/lib/icon";
|
import { clientConfig } from "@/services/auth";
|
||||||
|
|
||||||
|
import PlusCircleFilledIcon from "@ant-design/icons/PlusCircleFilled";
|
||||||
|
import ShareAltOutlinedIcon from "@ant-design/icons/ShareAltOutlined";
|
||||||
|
import FileOutlinedIcon from "@ant-design/icons/FileOutlined";
|
||||||
|
import FileExcelOutlinedIcon from "@ant-design/icons/FileExcelOutlined";
|
||||||
|
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
|
||||||
|
|
||||||
import QueryResultsLink from "./QueryResultsLink";
|
import QueryResultsLink from "./QueryResultsLink";
|
||||||
|
|
||||||
@@ -13,14 +19,14 @@ export default function QueryControlDropdown(props) {
|
|||||||
{!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && (
|
{!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && (
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}>
|
<a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}>
|
||||||
<Icon type="plus-circle" theme="filled" /> Add to Dashboard
|
<PlusCircleFilledIcon /> Add to Dashboard
|
||||||
</a>
|
</a>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
{!props.query.isNew() && (
|
{!clientConfig.disablePublicUrls && !props.query.isNew() && (
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton">
|
<a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton">
|
||||||
<Icon type="share-alt" /> Embed Elsewhere
|
<ShareAltOutlinedIcon /> Embed Elsewhere
|
||||||
</a>
|
</a>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
@@ -32,7 +38,7 @@ export default function QueryControlDropdown(props) {
|
|||||||
queryResult={props.queryResult}
|
queryResult={props.queryResult}
|
||||||
embed={props.embed}
|
embed={props.embed}
|
||||||
apiKey={props.apiKey}>
|
apiKey={props.apiKey}>
|
||||||
<Icon type="file" /> Download as CSV File
|
<FileOutlinedIcon /> Download as CSV File
|
||||||
</QueryResultsLink>
|
</QueryResultsLink>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
@@ -43,7 +49,7 @@ export default function QueryControlDropdown(props) {
|
|||||||
queryResult={props.queryResult}
|
queryResult={props.queryResult}
|
||||||
embed={props.embed}
|
embed={props.embed}
|
||||||
apiKey={props.apiKey}>
|
apiKey={props.apiKey}>
|
||||||
<Icon type="file" /> Download as TSV File
|
<FileOutlinedIcon /> Download as TSV File
|
||||||
</QueryResultsLink>
|
</QueryResultsLink>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
@@ -54,7 +60,7 @@ export default function QueryControlDropdown(props) {
|
|||||||
queryResult={props.queryResult}
|
queryResult={props.queryResult}
|
||||||
embed={props.embed}
|
embed={props.embed}
|
||||||
apiKey={props.apiKey}>
|
apiKey={props.apiKey}>
|
||||||
<Icon type="file-excel" /> Download as Excel File
|
<FileExcelOutlinedIcon /> Download as Excel File
|
||||||
</QueryResultsLink>
|
</QueryResultsLink>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu>
|
</Menu>
|
||||||
@@ -63,7 +69,7 @@ export default function QueryControlDropdown(props) {
|
|||||||
return (
|
return (
|
||||||
<Dropdown trigger={["click"]} overlay={menu} overlayClassName="query-control-dropdown-overlay">
|
<Dropdown trigger={["click"]} overlay={menu} overlayClassName="query-control-dropdown-overlay">
|
||||||
<Button data-test="QueryControlDropdownButton">
|
<Button data-test="QueryControlDropdownButton">
|
||||||
<Icon type="ellipsis" rotate={90} />
|
<EllipsisOutlinedIcon rotate={90} />
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
import Link from "@/components/Link";
|
||||||
|
|
||||||
export default function QueryResultsLink(props) {
|
export default function QueryResultsLink(props) {
|
||||||
let href = "";
|
let href = "";
|
||||||
@@ -17,9 +18,9 @@ export default function QueryResultsLink(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a target="_blank" rel="noopener noreferrer" disabled={props.disabled} href={href} download>
|
<Link target="_blank" rel="noopener noreferrer" disabled={props.disabled} href={href} download>
|
||||||
{props.children}
|
{props.children}
|
||||||
</a>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Icon from "antd/lib/icon";
|
import FormOutlinedIcon from "@ant-design/icons/FormOutlined";
|
||||||
|
|
||||||
export default function EditVisualizationButton(props) {
|
export default function EditVisualizationButton(props) {
|
||||||
return (
|
return (
|
||||||
@@ -9,7 +9,7 @@ export default function EditVisualizationButton(props) {
|
|||||||
data-test="EditVisualization"
|
data-test="EditVisualization"
|
||||||
className="edit-visualization"
|
className="edit-visualization"
|
||||||
onClick={() => props.openVisualizationEditor(props.selectedTab)}>
|
onClick={() => props.openVisualizationEditor(props.selectedTab)}>
|
||||||
<Icon type="form" />
|
<FormOutlinedIcon />
|
||||||
<span className="hidden-xs hidden-s hidden-m">Edit Visualization</span>
|
<span className="hidden-xs hidden-s hidden-m">Edit Visualization</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { startsWith, get } from "lodash";
|
import { startsWith, get, some, mapValues } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import Tooltip from "antd/lib/tooltip";
|
import Tooltip from "antd/lib/tooltip";
|
||||||
import Drawer from "antd/lib/drawer";
|
import Drawer from "antd/lib/drawer";
|
||||||
import Icon from "antd/lib/icon";
|
import Link from "@/components/Link";
|
||||||
|
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
||||||
import BigMessage from "@/components/BigMessage";
|
import BigMessage from "@/components/BigMessage";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent, { registerComponent } from "@/components/DynamicComponent";
|
||||||
|
|
||||||
import "./HelpTrigger.less";
|
import "./HelpTrigger.less";
|
||||||
|
|
||||||
@@ -15,204 +16,242 @@ const HELP_PATH = "/help";
|
|||||||
const IFRAME_TIMEOUT = 20000;
|
const IFRAME_TIMEOUT = 20000;
|
||||||
const IFRAME_URL_UPDATE_MESSAGE = "iframe_url";
|
const IFRAME_URL_UPDATE_MESSAGE = "iframe_url";
|
||||||
|
|
||||||
export const TYPES = {
|
export const TYPES = mapValues(
|
||||||
HOME: ["", "Help"],
|
{
|
||||||
VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"],
|
HOME: ["", "Help"],
|
||||||
SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"],
|
VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"],
|
||||||
AUTHENTICATION_OPTIONS: ["/user-guide/users/authentication-options", "Guide: Authentication Options"],
|
SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"],
|
||||||
USAGE_DATA_SHARING: ["/open-source/admin-guide/usage-data", "Help: Anonymous Usage Data Sharing"],
|
AUTHENTICATION_OPTIONS: ["/user-guide/users/authentication-options", "Guide: Authentication Options"],
|
||||||
DS_ATHENA: ["/data-sources/amazon-athena-setup", "Guide: Help Setting up Amazon Athena"],
|
USAGE_DATA_SHARING: ["/open-source/admin-guide/usage-data", "Help: Anonymous Usage Data Sharing"],
|
||||||
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
|
DS_ATHENA: ["/data-sources/amazon-athena-setup", "Guide: Help Setting up Amazon Athena"],
|
||||||
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
|
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
|
||||||
DS_MONGODB: ["/data-sources/mongodb-setup", "Guide: Help Setting up MongoDB"],
|
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
|
||||||
DS_GOOGLE_SPREADSHEETS: ["/data-sources/querying-a-google-spreadsheet", "Guide: Help Setting up Google Spreadsheets"],
|
DS_MONGODB: ["/data-sources/mongodb-setup", "Guide: Help Setting up MongoDB"],
|
||||||
DS_GOOGLE_ANALYTICS: ["/data-sources/google-analytics-setup", "Guide: Help Setting up Google Analytics"],
|
DS_GOOGLE_SPREADSHEETS: [
|
||||||
DS_AXIBASETSD: ["/data-sources/axibase-time-series-database", "Guide: Help Setting up Axibase Time Series"],
|
"/data-sources/querying-a-google-spreadsheet",
|
||||||
DS_RESULTS: ["/user-guide/querying/query-results-data-source", "Guide: Help Setting up Query Results"],
|
"Guide: Help Setting up Google Spreadsheets",
|
||||||
ALERT_SETUP: ["/user-guide/alerts/setting-up-an-alert", "Guide: Setting Up a New Alert"],
|
],
|
||||||
MAIL_CONFIG: ["/open-source/setup/#Mail-Configuration", "Guide: Mail Configuration"],
|
DS_GOOGLE_ANALYTICS: ["/data-sources/google-analytics-setup", "Guide: Help Setting up Google Analytics"],
|
||||||
ALERT_NOTIF_TEMPLATE_GUIDE: ["/user-guide/alerts/custom-alert-notifications", "Guide: Custom Alerts Notifications"],
|
DS_AXIBASETSD: ["/data-sources/axibase-time-series-database", "Guide: Help Setting up Axibase Time Series"],
|
||||||
FAVORITES: ["/user-guide/querying/favorites-tagging/#Favorites", "Guide: Favorites"],
|
DS_RESULTS: ["/user-guide/querying/query-results-data-source", "Guide: Help Setting up Query Results"],
|
||||||
MANAGE_PERMISSIONS: [
|
ALERT_SETUP: ["/user-guide/alerts/setting-up-an-alert", "Guide: Setting Up a New Alert"],
|
||||||
"/user-guide/querying/writing-queries#Managing-Query-Permissions",
|
MAIL_CONFIG: ["/open-source/setup/#Mail-Configuration", "Guide: Mail Configuration"],
|
||||||
"Guide: Managing Query Permissions",
|
ALERT_NOTIF_TEMPLATE_GUIDE: ["/user-guide/alerts/custom-alert-notifications", "Guide: Custom Alerts Notifications"],
|
||||||
],
|
FAVORITES: ["/user-guide/querying/favorites-tagging/#Favorites", "Guide: Favorites"],
|
||||||
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
|
MANAGE_PERMISSIONS: [
|
||||||
|
"/user-guide/querying/writing-queries#Managing-Query-Permissions",
|
||||||
|
"Guide: Managing Query Permissions",
|
||||||
|
],
|
||||||
|
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
|
||||||
|
GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"],
|
||||||
|
DASHBOARDS: ["/user-guide/dashboards", "Guide: Dashboards"],
|
||||||
|
QUERIES: ["/help/user-guide/querying", "Guide: Queries"],
|
||||||
|
ALERTS: ["/user-guide/alerts", "Guide: Alerts"],
|
||||||
|
},
|
||||||
|
([url, title]) => [DOMAIN + HELP_PATH + url, title]
|
||||||
|
);
|
||||||
|
|
||||||
|
const HelpTriggerPropTypes = {
|
||||||
|
type: PropTypes.string,
|
||||||
|
href: PropTypes.string,
|
||||||
|
title: PropTypes.node,
|
||||||
|
className: PropTypes.string,
|
||||||
|
showTooltip: PropTypes.bool,
|
||||||
|
renderAsLink: PropTypes.bool,
|
||||||
|
children: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class HelpTrigger extends React.Component {
|
const HelpTriggerDefaultProps = {
|
||||||
static propTypes = {
|
type: null,
|
||||||
type: PropTypes.oneOf(Object.keys(TYPES)),
|
href: null,
|
||||||
href: PropTypes.string,
|
title: null,
|
||||||
title: PropTypes.node,
|
className: null,
|
||||||
className: PropTypes.string,
|
showTooltip: true,
|
||||||
showTooltip: PropTypes.bool,
|
renderAsLink: false,
|
||||||
children: PropTypes.node,
|
children: <i className="fa fa-question-circle" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName = null) {
|
||||||
type: null,
|
return class HelpTrigger extends React.Component {
|
||||||
href: null,
|
static propTypes = {
|
||||||
title: null,
|
...HelpTriggerPropTypes,
|
||||||
className: null,
|
type: PropTypes.oneOf(Object.keys(types)),
|
||||||
showTooltip: true,
|
};
|
||||||
children: <i className="fa fa-question-circle" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
iframeRef = React.createRef();
|
static defaultProps = HelpTriggerDefaultProps;
|
||||||
|
|
||||||
iframeLoadingTimeout = null;
|
iframeRef = React.createRef();
|
||||||
|
|
||||||
state = {
|
iframeLoadingTimeout = null;
|
||||||
visible: false,
|
|
||||||
loading: false,
|
|
||||||
error: false,
|
|
||||||
currentUrl: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
state = {
|
||||||
window.addEventListener("message", this.onPostMessageReceived, false);
|
visible: false,
|
||||||
}
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
currentUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentDidMount() {
|
||||||
window.removeEventListener("message", this.onPostMessageReceived);
|
window.addEventListener("message", this.onPostMessageReceived, false);
|
||||||
clearTimeout(this.iframeLoadingTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadIframe = url => {
|
|
||||||
clearTimeout(this.iframeLoadingTimeout);
|
|
||||||
this.setState({ loading: true, error: false });
|
|
||||||
|
|
||||||
this.iframeRef.current.src = url;
|
|
||||||
this.iframeLoadingTimeout = setTimeout(() => {
|
|
||||||
this.setState({ error: url, loading: false });
|
|
||||||
}, IFRAME_TIMEOUT); // safety
|
|
||||||
};
|
|
||||||
|
|
||||||
onIframeLoaded = () => {
|
|
||||||
this.setState({ loading: false });
|
|
||||||
clearTimeout(this.iframeLoadingTimeout);
|
|
||||||
};
|
|
||||||
|
|
||||||
onPostMessageReceived = event => {
|
|
||||||
if (!startsWith(event.origin, DOMAIN)) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, message: currentUrl } = event.data || {};
|
componentWillUnmount() {
|
||||||
if (type !== IFRAME_URL_UPDATE_MESSAGE) {
|
window.removeEventListener("message", this.onPostMessageReceived);
|
||||||
return;
|
clearTimeout(this.iframeLoadingTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ currentUrl });
|
loadIframe = url => {
|
||||||
};
|
clearTimeout(this.iframeLoadingTimeout);
|
||||||
|
this.setState({ loading: true, error: false });
|
||||||
|
|
||||||
getUrl = () => {
|
this.iframeRef.current.src = url;
|
||||||
const helpTriggerType = get(TYPES, this.props.type);
|
this.iframeLoadingTimeout = setTimeout(() => {
|
||||||
return helpTriggerType ? DOMAIN + HELP_PATH + helpTriggerType[0] : this.props.href;
|
this.setState({ error: url, loading: false });
|
||||||
};
|
}, IFRAME_TIMEOUT); // safety
|
||||||
|
};
|
||||||
|
|
||||||
openDrawer = () => {
|
onIframeLoaded = () => {
|
||||||
this.setState({ visible: true });
|
this.setState({ loading: false });
|
||||||
// wait for drawer animation to complete so there's no animation jank
|
clearTimeout(this.iframeLoadingTimeout);
|
||||||
setTimeout(() => this.loadIframe(this.getUrl()), 300);
|
};
|
||||||
};
|
|
||||||
|
|
||||||
closeDrawer = event => {
|
onPostMessageReceived = event => {
|
||||||
if (event) {
|
if (!some(allowedDomains, domain => startsWith(event.origin, domain))) {
|
||||||
event.preventDefault();
|
return;
|
||||||
}
|
}
|
||||||
this.setState({ visible: false });
|
|
||||||
this.setState({ visible: false, currentUrl: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
const { type, message: currentUrl } = event.data || {};
|
||||||
const tooltip = get(TYPES, `${this.props.type}[1]`, this.props.title);
|
if (type !== IFRAME_URL_UPDATE_MESSAGE) {
|
||||||
const className = cx("help-trigger", this.props.className);
|
return;
|
||||||
const url = this.state.currentUrl;
|
}
|
||||||
|
|
||||||
const isAllowedDomain = startsWith(url || this.getUrl(), DOMAIN);
|
this.setState({ currentUrl });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
getUrl = () => {
|
||||||
<React.Fragment>
|
const helpTriggerType = get(types, this.props.type);
|
||||||
<Tooltip
|
return helpTriggerType ? helpTriggerType[0] : this.props.href;
|
||||||
title={
|
};
|
||||||
this.props.showTooltip ? (
|
|
||||||
<>
|
openDrawer = e => {
|
||||||
{tooltip}
|
// keep "open in new tab" behavior
|
||||||
{!isAllowedDomain && <i className="fa fa-external-link" style={{ marginLeft: 5 }} />}
|
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||||
</>
|
e.preventDefault();
|
||||||
) : null
|
this.setState({ visible: true });
|
||||||
}>
|
// wait for drawer animation to complete so there's no animation jank
|
||||||
{isAllowedDomain ? (
|
setTimeout(() => this.loadIframe(this.getUrl()), 300);
|
||||||
<a onClick={this.openDrawer} className={className}>
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
closeDrawer = event => {
|
||||||
|
if (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
this.setState({ visible: false });
|
||||||
|
this.setState({ visible: false, currentUrl: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const targetUrl = this.getUrl();
|
||||||
|
if (!targetUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltip = get(types, `${this.props.type}[1]`, this.props.title);
|
||||||
|
const className = cx("help-trigger", this.props.className);
|
||||||
|
const url = this.state.currentUrl;
|
||||||
|
const isAllowedDomain = some(allowedDomains, domain => startsWith(url || targetUrl, domain));
|
||||||
|
const shouldRenderAsLink = this.props.renderAsLink || !isAllowedDomain;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
this.props.showTooltip ? (
|
||||||
|
<>
|
||||||
|
{tooltip}
|
||||||
|
{shouldRenderAsLink && <i className="fa fa-external-link" style={{ marginLeft: 5 }} />}
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}>
|
||||||
|
<Link
|
||||||
|
href={url || this.getUrl()}
|
||||||
|
className={className}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}>
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</a>
|
</Link>
|
||||||
) : (
|
</Tooltip>
|
||||||
<a href={url || this.getUrl()} className={className} rel="noopener noreferrer" target="_blank">
|
<Drawer
|
||||||
{this.props.children}
|
placement="right"
|
||||||
</a>
|
closable={false}
|
||||||
)}
|
onClose={this.closeDrawer}
|
||||||
</Tooltip>
|
visible={this.state.visible}
|
||||||
<Drawer
|
className={cx("help-drawer", drawerClassName)}
|
||||||
placement="right"
|
destroyOnClose
|
||||||
closable={false}
|
width={400}>
|
||||||
onClose={this.closeDrawer}
|
<div className="drawer-wrapper">
|
||||||
visible={this.state.visible}
|
<div className="drawer-menu">
|
||||||
className="help-drawer"
|
{url && (
|
||||||
destroyOnClose
|
<Tooltip title="Open page in a new window" placement="left">
|
||||||
width={400}>
|
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||||
<div className="drawer-wrapper">
|
<Link href={url} target="_blank">
|
||||||
<div className="drawer-menu">
|
<i className="fa fa-external-link" />
|
||||||
{url && (
|
</Link>
|
||||||
<Tooltip title="Open page in a new window" placement="left">
|
</Tooltip>
|
||||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
)}
|
||||||
<a href={url} target="_blank">
|
<Tooltip title="Close" placement="bottom">
|
||||||
<i className="fa fa-external-link" />
|
<a onClick={this.closeDrawer}>
|
||||||
|
<CloseOutlinedIcon />
|
||||||
</a>
|
</a>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* iframe */}
|
||||||
|
{!this.state.error && (
|
||||||
|
<iframe
|
||||||
|
ref={this.iframeRef}
|
||||||
|
title="Usage Help"
|
||||||
|
src="about:blank"
|
||||||
|
className={cx({ ready: !this.state.loading })}
|
||||||
|
onLoad={this.onIframeLoaded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* loading indicator */}
|
||||||
|
{this.state.loading && (
|
||||||
|
<BigMessage icon="fa-spinner fa-2x fa-pulse" message="Loading..." className="help-message" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* error message */}
|
||||||
|
{this.state.error && (
|
||||||
|
<BigMessage icon="fa-exclamation-circle" className="help-message">
|
||||||
|
Something went wrong.
|
||||||
|
<br />
|
||||||
|
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||||
|
<Link href={this.state.error} target="_blank" rel="noopener">
|
||||||
|
Click here
|
||||||
|
</Link>{" "}
|
||||||
|
to open the page in a new window.
|
||||||
|
</BigMessage>
|
||||||
)}
|
)}
|
||||||
<Tooltip title="Close" placement="bottom">
|
|
||||||
<a onClick={this.closeDrawer}>
|
|
||||||
<Icon type="close" />
|
|
||||||
</a>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* iframe */}
|
{/* extra content */}
|
||||||
{!this.state.error && (
|
<DynamicComponent name="HelpDrawerExtraContent" onLeave={this.closeDrawer} openPageUrl={this.loadIframe} />
|
||||||
<iframe
|
</Drawer>
|
||||||
ref={this.iframeRef}
|
</React.Fragment>
|
||||||
title="Redash Help"
|
);
|
||||||
src="about:blank"
|
}
|
||||||
className={cx({ ready: !this.state.loading })}
|
};
|
||||||
onLoad={this.onIframeLoaded}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* loading indicator */}
|
|
||||||
{this.state.loading && (
|
|
||||||
<BigMessage icon="fa-spinner fa-2x fa-pulse" message="Loading..." className="help-message" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* error message */}
|
|
||||||
{this.state.error && (
|
|
||||||
<BigMessage icon="fa-exclamation-circle" className="help-message">
|
|
||||||
Something went wrong.
|
|
||||||
<br />
|
|
||||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
|
||||||
<a href={this.state.error} target="_blank" rel="noopener">
|
|
||||||
Click here
|
|
||||||
</a>{" "}
|
|
||||||
to open the page in a new window.
|
|
||||||
</BigMessage>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* extra content */}
|
|
||||||
<DynamicComponent name="HelpDrawerExtraContent" onLeave={this.closeDrawer} openPageUrl={this.loadIframe} />
|
|
||||||
</Drawer>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerComponent("HelpTrigger", helpTriggerWithTypes(TYPES, [DOMAIN]));
|
||||||
|
|
||||||
|
export default function HelpTrigger(props) {
|
||||||
|
return <DynamicComponent {...props} name="HelpTrigger" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
HelpTrigger.propTypes = HelpTriggerPropTypes;
|
||||||
|
HelpTrigger.defaultProps = HelpTriggerDefaultProps;
|
||||||
|
|||||||
57
client/app/components/InputPopover/index.jsx
Normal file
57
client/app/components/InputPopover/index.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import Button from "antd/lib/button";
|
||||||
|
import Popover from "antd/lib/popover";
|
||||||
|
|
||||||
|
import "./index.less";
|
||||||
|
|
||||||
|
export default function InputPopover({
|
||||||
|
header,
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
okButtonProps,
|
||||||
|
cancelButtonProps,
|
||||||
|
onCancel,
|
||||||
|
onOk,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
{...props}
|
||||||
|
content={
|
||||||
|
<div className="input-popover-content" data-test="InputPopoverContent">
|
||||||
|
{header && <header>{header}</header>}
|
||||||
|
{content}
|
||||||
|
<footer>
|
||||||
|
<Button onClick={onCancel} {...cancelButtonProps}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onOk} type="primary" {...okButtonProps}>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{children}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InputPopover.propTypes = {
|
||||||
|
header: PropTypes.node,
|
||||||
|
content: PropTypes.node,
|
||||||
|
children: PropTypes.node,
|
||||||
|
okButtonProps: PropTypes.object,
|
||||||
|
cancelButtonProps: PropTypes.object,
|
||||||
|
onOk: PropTypes.func,
|
||||||
|
onCancel: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
InputPopover.defaultProps = {
|
||||||
|
header: null,
|
||||||
|
children: null,
|
||||||
|
okButtonProps: null,
|
||||||
|
cancelButtonProps: null,
|
||||||
|
onOk: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
};
|
||||||
37
client/app/components/InputPopover/index.less
Normal file
37
client/app/components/InputPopover/index.less
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
@import "~antd/lib/modal/style/index"; // for ant @vars
|
||||||
|
|
||||||
|
.input-popover-content {
|
||||||
|
width: 390px;
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
display: block;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 0 16px 10px;
|
||||||
|
margin: 0 -16px 20px;
|
||||||
|
border-bottom: @border-width-base @border-style-base @border-color-split;
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
font-weight: 500;
|
||||||
|
color: @heading-color;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: @border-width-base @border-style-base @border-color-split;
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
margin: 0 -16px;
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Input from "antd/lib/input";
|
import Input from "antd/lib/input";
|
||||||
import Icon from "antd/lib/icon";
|
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
|
||||||
import Tooltip from "antd/lib/tooltip";
|
import Tooltip from "antd/lib/tooltip";
|
||||||
|
|
||||||
export default class InputWithCopy extends React.Component {
|
export default class InputWithCopy extends React.Component {
|
||||||
@@ -42,7 +42,7 @@ export default class InputWithCopy extends React.Component {
|
|||||||
render() {
|
render() {
|
||||||
const copyButton = (
|
const copyButton = (
|
||||||
<Tooltip title={this.state.copied || "Copy"}>
|
<Tooltip title={this.state.copied || "Copy"}>
|
||||||
<Icon type="copy" style={{ cursor: "pointer" }} onClick={this.copy} />
|
<CopyOutlinedIcon style={{ cursor: "pointer" }} onClick={this.copy} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
26
client/app/components/Link.jsx
Normal file
26
client/app/components/Link.jsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Button from "antd/lib/button";
|
||||||
|
|
||||||
|
function DefaultLinkComponent(props) {
|
||||||
|
return <a {...props} />; // eslint-disable-line jsx-a11y/anchor-has-content
|
||||||
|
}
|
||||||
|
|
||||||
|
function Link(props) {
|
||||||
|
return <Link.Component {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
Link.Component = DefaultLinkComponent;
|
||||||
|
|
||||||
|
function DefaultButtonLinkComponent(props) {
|
||||||
|
return <Button role="button" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonLink(props) {
|
||||||
|
return <ButtonLink.Component {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
ButtonLink.Component = DefaultButtonLinkComponent;
|
||||||
|
|
||||||
|
Link.Button = ButtonLink;
|
||||||
|
|
||||||
|
export default Link;
|
||||||
@@ -7,7 +7,7 @@ export default function NoTaggedObjectsFound({ objectType, tags }) {
|
|||||||
return (
|
return (
|
||||||
<BigMessage icon="fa-tags">
|
<BigMessage icon="fa-tags">
|
||||||
No {objectType} found tagged with
|
No {objectType} found tagged with
|
||||||
<TagsControl className="inline-tags-control" tags={Array.from(tags)} />.
|
<TagsControl className="inline-tags-control" tags={Array.from(tags)} tagSeparator={"+"} />.
|
||||||
</BigMessage>
|
</BigMessage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
|
|
||||||
export default function PageHeader({ title }) {
|
|
||||||
return (
|
|
||||||
<div className="page-header-wrapper row p-l-15 p-r-15 m-b-10 m-l-0 m-r-0">
|
|
||||||
<div className="col-sm-9 p-l-0 p-r-0">
|
|
||||||
<h3>{title}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PageHeader.propTypes = {
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
23
client/app/components/PageHeader/index.jsx
Normal file
23
client/app/components/PageHeader/index.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
import "./index.less";
|
||||||
|
|
||||||
|
export default function PageHeader({ title, actions }) {
|
||||||
|
return (
|
||||||
|
<div className="page-header-wrapper">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{actions && <div className="page-header-actions">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PageHeader.propTypes = {
|
||||||
|
title: PropTypes.string,
|
||||||
|
actions: PropTypes.node,
|
||||||
|
};
|
||||||
|
|
||||||
|
PageHeader.defaultProps = {
|
||||||
|
title: "",
|
||||||
|
actions: null,
|
||||||
|
};
|
||||||
20
client/app/components/PageHeader/index.less
Executable file
20
client/app/components/PageHeader/index.less
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
.page-header-wrapper {
|
||||||
|
margin: 15px 0 10px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 0 0 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,24 +2,38 @@ import React from "react";
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Pagination from "antd/lib/pagination";
|
import Pagination from "antd/lib/pagination";
|
||||||
|
|
||||||
export default function Paginator({ page, itemsPerPage, totalCount, onChange }) {
|
const MIN_ITEMS_PER_PAGE = 5;
|
||||||
if (totalCount <= itemsPerPage) {
|
|
||||||
|
export default function Paginator({ page, showPageSizeSelect, pageSize, onPageSizeChange, totalCount, onChange }) {
|
||||||
|
if (totalCount <= (showPageSizeSelect ? MIN_ITEMS_PER_PAGE : pageSize)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="paginator-container">
|
<div className="paginator-container">
|
||||||
<Pagination defaultCurrent={page} defaultPageSize={itemsPerPage} total={totalCount} onChange={onChange} />
|
<Pagination
|
||||||
|
showSizeChanger={showPageSizeSelect}
|
||||||
|
pageSizeOptions={["5", "10", "20", "50", "100"]}
|
||||||
|
onShowSizeChange={(_, size) => onPageSizeChange(size)}
|
||||||
|
defaultCurrent={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={totalCount}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Paginator.propTypes = {
|
Paginator.propTypes = {
|
||||||
page: PropTypes.number.isRequired,
|
page: PropTypes.number.isRequired,
|
||||||
itemsPerPage: PropTypes.number.isRequired,
|
showPageSizeSelect: PropTypes.bool,
|
||||||
|
pageSize: PropTypes.number.isRequired,
|
||||||
totalCount: PropTypes.number.isRequired,
|
totalCount: PropTypes.number.isRequired,
|
||||||
|
onPageSizeChange: PropTypes.func,
|
||||||
onChange: PropTypes.func,
|
onChange: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
Paginator.defaultProps = {
|
Paginator.defaultProps = {
|
||||||
|
showPageSizeSelect: false,
|
||||||
onChange: () => {},
|
onChange: () => {},
|
||||||
|
onPageSizeChange: () => {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import Select from "antd/lib/select";
|
|||||||
import Table from "antd/lib/table";
|
import Table from "antd/lib/table";
|
||||||
import Popover from "antd/lib/popover";
|
import Popover from "antd/lib/popover";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Icon from "antd/lib/icon";
|
|
||||||
import Tag from "antd/lib/tag";
|
import Tag from "antd/lib/tag";
|
||||||
import Input from "antd/lib/input";
|
import Input from "antd/lib/input";
|
||||||
import Radio from "antd/lib/radio";
|
import Radio from "antd/lib/radio";
|
||||||
@@ -18,11 +17,15 @@ import ParameterValueInput from "@/components/ParameterValueInput";
|
|||||||
import { ParameterMappingType } from "@/services/widget";
|
import { ParameterMappingType } from "@/services/widget";
|
||||||
import { Parameter, cloneParameter } from "@/services/parameters";
|
import { Parameter, cloneParameter } from "@/services/parameters";
|
||||||
import HelpTrigger from "@/components/HelpTrigger";
|
import HelpTrigger from "@/components/HelpTrigger";
|
||||||
|
import InputPopover from "@/components/InputPopover";
|
||||||
|
|
||||||
|
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
||||||
|
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
||||||
|
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
||||||
|
import CheckOutlinedIcon from "@ant-design/icons/CheckOutlined";
|
||||||
|
|
||||||
import "./ParameterMappingInput.less";
|
import "./ParameterMappingInput.less";
|
||||||
|
|
||||||
const { Option } = Select;
|
|
||||||
|
|
||||||
export const MappingType = {
|
export const MappingType = {
|
||||||
DashboardAddNew: "dashboard-add-new",
|
DashboardAddNew: "dashboard-add-new",
|
||||||
DashboardMapToExisting: "dashboard-map-to-existing",
|
DashboardMapToExisting: "dashboard-map-to-existing",
|
||||||
@@ -181,7 +184,7 @@ export class ParameterMappingInput extends React.Component {
|
|||||||
Existing dashboard parameter{" "}
|
Existing dashboard parameter{" "}
|
||||||
{noExisting ? (
|
{noExisting ? (
|
||||||
<Tooltip title="There are no dashboard parameters corresponding to this data type">
|
<Tooltip title="There are no dashboard parameters corresponding to this data type">
|
||||||
<Icon type="question-circle" theme="filled" />
|
<QuestionCircleFilledIcon />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
</Radio>
|
</Radio>
|
||||||
@@ -204,19 +207,9 @@ export class ParameterMappingInput extends React.Component {
|
|||||||
|
|
||||||
renderDashboardMapToExisting() {
|
renderDashboardMapToExisting() {
|
||||||
const { mapping, existingParamNames } = this.props;
|
const { mapping, existingParamNames } = this.props;
|
||||||
|
const options = map(existingParamNames, paramName => ({ label: paramName, value: paramName }));
|
||||||
|
|
||||||
return (
|
return <Select value={mapping.mapTo} onChange={mapTo => this.updateParamMapping({ mapTo })} options={options} />;
|
||||||
<Select
|
|
||||||
value={mapping.mapTo}
|
|
||||||
onChange={mapTo => this.updateParamMapping({ mapTo })}
|
|
||||||
dropdownMatchSelectWidth={false}>
|
|
||||||
{map(existingParamNames, name => (
|
|
||||||
<Option value={name} key={name}>
|
|
||||||
{name}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderStaticValue() {
|
renderStaticValue() {
|
||||||
@@ -321,43 +314,34 @@ class MappingEditor extends React.Component {
|
|||||||
this.setState({ visible: false });
|
this.setState({ visible: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
renderContent() {
|
|
||||||
const { mapping, inputError } = this.state;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
|
|
||||||
<header>
|
|
||||||
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
|
|
||||||
</header>
|
|
||||||
<ParameterMappingInput
|
|
||||||
mapping={mapping}
|
|
||||||
existingParamNames={this.props.existingParamNames}
|
|
||||||
onChange={this.onChange}
|
|
||||||
inputError={inputError}
|
|
||||||
/>
|
|
||||||
<footer>
|
|
||||||
<Button onClick={this.hide}>Cancel</Button>
|
|
||||||
<Button onClick={this.save} disabled={!!inputError} type="primary">
|
|
||||||
OK
|
|
||||||
</Button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { visible, mapping } = this.state;
|
const { visible, mapping, inputError } = this.state;
|
||||||
return (
|
return (
|
||||||
<Popover
|
<InputPopover
|
||||||
placement="left"
|
placement="left"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
content={this.renderContent()}
|
header={
|
||||||
|
<>
|
||||||
|
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
content={
|
||||||
|
<ParameterMappingInput
|
||||||
|
mapping={mapping}
|
||||||
|
existingParamNames={this.props.existingParamNames}
|
||||||
|
onChange={this.onChange}
|
||||||
|
inputError={inputError}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onOk={this.save}
|
||||||
|
onCancel={this.hide}
|
||||||
|
okButtonProps={{ disabled: !!inputError }}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onVisibleChange={this.onVisibleChange}>
|
onVisibleChange={this.onVisibleChange}>
|
||||||
<Button size="small" type="dashed" data-test={`EditParamMappingButon-${mapping.param.name}`}>
|
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
|
||||||
<Icon type="edit" />
|
<EditOutlinedIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</Popover>
|
</InputPopover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,10 +418,10 @@ class TitleEditor extends React.Component {
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button size="small" type="dashed" onClick={this.hide}>
|
<Button size="small" type="dashed" onClick={this.hide}>
|
||||||
<Icon type="close" />
|
<CloseOutlinedIcon />
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" type="dashed" onClick={this.save}>
|
<Button size="small" type="dashed" onClick={this.save}>
|
||||||
<Icon type="check" />
|
<CheckOutlinedIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -460,7 +444,7 @@ class TitleEditor extends React.Component {
|
|||||||
visible={this.state.showPopup}
|
visible={this.state.showPopup}
|
||||||
onVisibleChange={this.onPopupVisibleChange}>
|
onVisibleChange={this.onPopupVisibleChange}>
|
||||||
<Button size="small" type="dashed">
|
<Button size="small" type="dashed">
|
||||||
<Icon type="edit" />
|
<EditOutlinedIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import '~antd/lib/modal/style/index'; // for ant @vars
|
@import "~antd/lib/modal/style/index"; // for ant @vars
|
||||||
|
|
||||||
.parameters-mapping-list {
|
.parameters-mapping-list {
|
||||||
.keyword {
|
.keyword {
|
||||||
@@ -22,48 +22,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-mapping-editor {
|
|
||||||
width: 390px;
|
|
||||||
|
|
||||||
.radio {
|
|
||||||
display: block;
|
|
||||||
height: 30px;
|
|
||||||
line-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-item {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
padding: 0 16px 10px;
|
|
||||||
margin: 0 -16px 20px;
|
|
||||||
border-bottom: @border-width-base @border-style-base @border-color-split;
|
|
||||||
font-size: @font-size-lg;
|
|
||||||
font-weight: 500;
|
|
||||||
color: @heading-color;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
border-top: @border-width-base @border-style-base @border-color-split;
|
|
||||||
padding: 10px 16px 0;
|
|
||||||
margin: 0 -16px;
|
|
||||||
text-align: right;
|
|
||||||
|
|
||||||
button {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.parameter-mapping-title {
|
.parameter-mapping-title {
|
||||||
.text {
|
.text {
|
||||||
margin-right: 3px;
|
margin-right: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled, .fa {
|
&.disabled,
|
||||||
|
.fa {
|
||||||
color: #a4a4a4;
|
color: #a4a4a4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { isEqual, isEmpty } from "lodash";
|
import { isEqual, isEmpty, map } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Select from "antd/lib/select";
|
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
|
||||||
import Input from "antd/lib/input";
|
import Input from "antd/lib/input";
|
||||||
import InputNumber from "antd/lib/input-number";
|
import InputNumber from "antd/lib/input-number";
|
||||||
import DateParameter from "@/components/dynamic-parameters/DateParameter";
|
import DateParameter from "@/components/dynamic-parameters/DateParameter";
|
||||||
@@ -10,8 +10,6 @@ import QueryBasedParameterInput from "./QueryBasedParameterInput";
|
|||||||
|
|
||||||
import "./ParameterValueInput.less";
|
import "./ParameterValueInput.less";
|
||||||
|
|
||||||
const { Option } = Select;
|
|
||||||
|
|
||||||
const multipleValuesProps = {
|
const multipleValuesProps = {
|
||||||
maxTagCount: 3,
|
maxTagCount: 3,
|
||||||
maxTagTextLength: 10,
|
maxTagTextLength: 10,
|
||||||
@@ -98,25 +96,20 @@ class ParameterValueInput extends React.Component {
|
|||||||
const enumOptionsArray = enumOptions.split("\n").filter(v => v !== "");
|
const enumOptionsArray = enumOptions.split("\n").filter(v => v !== "");
|
||||||
// Antd Select doesn't handle null in multiple mode
|
// Antd Select doesn't handle null in multiple mode
|
||||||
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val);
|
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<SelectWithVirtualScroll
|
||||||
className={this.props.className}
|
className={this.props.className}
|
||||||
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
||||||
optionFilterProp="children"
|
optionFilterProp="children"
|
||||||
value={normalize(value)}
|
value={normalize(value)}
|
||||||
onChange={this.onSelect}
|
onChange={this.onSelect}
|
||||||
dropdownMatchSelectWidth={false}
|
options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))}
|
||||||
showSearch
|
showSearch
|
||||||
showArrow
|
showArrow
|
||||||
style={{ minWidth: 60 }}
|
|
||||||
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
|
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
|
||||||
{...multipleValuesProps}>
|
{...multipleValuesProps}
|
||||||
{enumOptionsArray.map(option => (
|
/>
|
||||||
<Option key={option} value={option}>
|
|
||||||
{option}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import '~antd/lib/input-number/style/index'; // for ant @vars
|
@import "~antd/lib/input-number/style/index"; // for ant @vars
|
||||||
|
|
||||||
@input-dirty: #fffce1;
|
@input-dirty: #fffce1;
|
||||||
|
|
||||||
@@ -17,10 +17,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&[data-dirty] {
|
&[data-dirty] {
|
||||||
.@{ant-prefix}-input, // covers also ant date component
|
.@{ant-prefix}-input,
|
||||||
.@{ant-prefix}-input-number,
|
.@{ant-prefix}-input-number,
|
||||||
.@{ant-prefix}-select-selection {
|
.@{ant-prefix}-select-selector,
|
||||||
background-color: @input-dirty;
|
.@{ant-prefix}-picker {
|
||||||
|
background-color: @input-dirty !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Parameter, createParameter } from "@/services/parameters";
|
|||||||
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
||||||
import ParameterValueInput from "@/components/ParameterValueInput";
|
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||||
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
||||||
import { toHuman } from "@/lib/utils";
|
|
||||||
|
|
||||||
import "./Parameters.less";
|
import "./Parameters.less";
|
||||||
|
|
||||||
@@ -121,7 +120,7 @@ export default class Parameters extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
|
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
|
||||||
<div className="parameter-heading">
|
<div className="parameter-heading">
|
||||||
<label>{param.title || toHuman(param.name)}</label>
|
<label>{param.getTitle()}</label>
|
||||||
{editable && (
|
{editable && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-default btn-xs m-l-5"
|
className="btn btn-default btn-xs m-l-5"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import Link from "@/components/Link";
|
||||||
|
|
||||||
// PreviewCard
|
// PreviewCard
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ PreviewCard.defaultProps = {
|
|||||||
// UserPreviewCard
|
// UserPreviewCard
|
||||||
|
|
||||||
export function UserPreviewCard({ user, withLink, children, ...props }) {
|
export function UserPreviewCard({ user, withLink, children, ...props }) {
|
||||||
const title = withLink ? <a href={"users/" + user.id}>{user.name}</a> : user.name;
|
const title = withLink ? <Link href={"users/" + user.id}>{user.name}</Link> : user.name;
|
||||||
return (
|
return (
|
||||||
<PreviewCard {...props} imageUrl={user.profile_image_url} title={title} body={user.email}>
|
<PreviewCard {...props} imageUrl={user.profile_image_url} title={title} body={user.email}>
|
||||||
{children}
|
{children}
|
||||||
@@ -68,8 +69,8 @@ UserPreviewCard.defaultProps = {
|
|||||||
// DataSourcePreviewCard
|
// DataSourcePreviewCard
|
||||||
|
|
||||||
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) {
|
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) {
|
||||||
const imageUrl = `/static/images/db-logos/${dataSource.type}.png`;
|
const imageUrl = `static/images/db-logos/${dataSource.type}.png`;
|
||||||
const title = withLink ? <a href={"data_sources/" + dataSource.id}>{dataSource.name}</a> : dataSource.name;
|
const title = withLink ? <Link href={"data_sources/" + dataSource.id}>{dataSource.name}</Link> : dataSource.name;
|
||||||
return (
|
return (
|
||||||
<PreviewCard {...props} imageUrl={imageUrl} title={title}>
|
<PreviewCard {...props} imageUrl={imageUrl} title={title}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { find, isArray, get, first, map, intersection, isEqual, isEmpty } from "lodash";
|
import { find, isArray, get, first, map, intersection, isEqual, isEmpty, trim, debounce, isNil } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Select from "antd/lib/select";
|
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
|
||||||
|
|
||||||
const { Option } = Select;
|
const SEARCH_DEBOUNCE_TIME = 300;
|
||||||
|
|
||||||
|
function filterValuesThatAreNotInOptions(value, options) {
|
||||||
|
if (isArray(value)) {
|
||||||
|
const optionValues = map(options, option => option.value);
|
||||||
|
return intersection(value, optionValues);
|
||||||
|
}
|
||||||
|
const found = find(options, option => option.value === value) !== undefined;
|
||||||
|
return found ? value : get(first(options), "value");
|
||||||
|
}
|
||||||
|
|
||||||
export default class QueryBasedParameterInput extends React.Component {
|
export default class QueryBasedParameterInput extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
@@ -30,6 +39,7 @@ export default class QueryBasedParameterInput extends React.Component {
|
|||||||
options: [],
|
options: [],
|
||||||
value: null,
|
value: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
currentSearchTerm: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +48,10 @@ export default class QueryBasedParameterInput extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (this.props.queryId !== prevProps.queryId) {
|
if (this.props.queryId !== prevProps.queryId || this.props.parameter !== prevProps.parameter) {
|
||||||
this._loadOptions(this.props.queryId);
|
this._loadOptions(this.props.queryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.props.value !== prevProps.value) {
|
if (this.props.value !== prevProps.value) {
|
||||||
this.setValue(this.props.value);
|
this.setValue(this.props.value);
|
||||||
}
|
}
|
||||||
@@ -48,60 +59,86 @@ export default class QueryBasedParameterInput extends React.Component {
|
|||||||
|
|
||||||
setValue(value) {
|
setValue(value) {
|
||||||
const { options } = this.state;
|
const { options } = this.state;
|
||||||
if (this.props.mode === "multiple") {
|
const { mode, parameter } = this.props;
|
||||||
|
|
||||||
|
if (mode === "multiple") {
|
||||||
|
if (isNil(value)) {
|
||||||
|
value = [];
|
||||||
|
}
|
||||||
|
|
||||||
value = isArray(value) ? value : [value];
|
value = isArray(value) ? value : [value];
|
||||||
const optionValues = map(options, option => option.value);
|
|
||||||
const validValues = intersection(value, optionValues);
|
|
||||||
this.setState({ value: validValues });
|
|
||||||
return validValues;
|
|
||||||
}
|
}
|
||||||
const found = find(options, option => option.value === this.props.value) !== undefined;
|
|
||||||
value = found ? value : get(first(options), "value");
|
// parameters with search don't have options available, so we trust what we get
|
||||||
|
if (!parameter.searchFunction) {
|
||||||
|
value = filterValuesThatAreNotInOptions(value, options);
|
||||||
|
}
|
||||||
|
|
||||||
this.setState({ value });
|
this.setState({ value });
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateOptions(options) {
|
||||||
|
this.setState({ options, loading: false }, () => {
|
||||||
|
const updatedValue = this.setValue(this.props.value);
|
||||||
|
if (!isEqual(updatedValue, this.props.value)) {
|
||||||
|
this.props.onSelect(updatedValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async _loadOptions(queryId) {
|
async _loadOptions(queryId) {
|
||||||
if (queryId && queryId !== this.state.queryId) {
|
if (queryId && queryId !== this.state.queryId) {
|
||||||
this.setState({ loading: true });
|
this.setState({ loading: true });
|
||||||
const options = await this.props.parameter.loadDropdownValues();
|
const options = await this.props.parameter.loadDropdownValues(this.state.currentSearchTerm);
|
||||||
|
|
||||||
// stale queryId check
|
// stale queryId check
|
||||||
if (this.props.queryId === queryId) {
|
if (this.props.queryId === queryId) {
|
||||||
this.setState({ options, loading: false }, () => {
|
this.updateOptions(options);
|
||||||
const updatedValue = this.setValue(this.props.value);
|
|
||||||
if (!isEqual(updatedValue, this.props.value)) {
|
|
||||||
this.props.onSelect(updatedValue);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
searchFunction = debounce(searchTerm => {
|
||||||
|
const { parameter } = this.props;
|
||||||
|
if (parameter.searchFunction && trim(searchTerm)) {
|
||||||
|
this.setState({ loading: true, currentSearchTerm: searchTerm });
|
||||||
|
parameter.searchFunction(searchTerm).then(options => {
|
||||||
|
if (this.state.currentSearchTerm === searchTerm) {
|
||||||
|
this.updateOptions(options);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, SEARCH_DEBOUNCE_TIME);
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { className, value, mode, onSelect, ...otherProps } = this.props;
|
const { parameter, className, mode, onSelect, queryId, value, ...otherProps } = this.props;
|
||||||
const { loading, options } = this.state;
|
const { loading, options } = this.state;
|
||||||
|
const selectProps = { ...otherProps };
|
||||||
|
|
||||||
|
if (parameter.searchColumn) {
|
||||||
|
selectProps.filterOption = false;
|
||||||
|
selectProps.onSearch = this.searchFunction;
|
||||||
|
selectProps.onChange = value => onSelect(parameter.normalizeValue(value));
|
||||||
|
selectProps.notFoundContent = null;
|
||||||
|
selectProps.labelInValue = true;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
<Select
|
<SelectWithVirtualScroll
|
||||||
className={className}
|
className={className}
|
||||||
disabled={loading}
|
disabled={!parameter.searchFunction && loading}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
value={this.state.value}
|
value={this.state.value || undefined}
|
||||||
onChange={onSelect}
|
onChange={onSelect}
|
||||||
dropdownMatchSelectWidth={false}
|
options={options}
|
||||||
optionFilterProp="children"
|
optionFilterProp="children"
|
||||||
showSearch
|
showSearch
|
||||||
showArrow
|
showArrow
|
||||||
notFoundContent={isEmpty(options) ? "No options available" : null}
|
notFoundContent={isEmpty(options) ? "No options available" : null}
|
||||||
{...otherProps}>
|
{...selectProps}
|
||||||
{options.map(option => (
|
/>
|
||||||
<Option value={option.value} key={option.value}>
|
|
||||||
{option.name}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { VisualizationType } from "@redash/viz/lib";
|
import { VisualizationType } from "@redash/viz/lib";
|
||||||
|
import Link from "@/components/Link";
|
||||||
import VisualizationName from "@/components/visualizations/VisualizationName";
|
import VisualizationName from "@/components/visualizations/VisualizationName";
|
||||||
|
|
||||||
import "./QueryLink.less";
|
import "./QueryLink.less";
|
||||||
@@ -21,9 +22,9 @@ function QueryLink({ query, visualization, readOnly }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href={readOnly ? null : getUrl()} className="query-link">
|
<Link href={readOnly ? null : getUrl()} className="query-link">
|
||||||
<VisualizationName visualization={visualization} /> <span>{query.name}</span>
|
<VisualizationName visualization={visualization} /> <span>{query.name}</span>
|
||||||
</a>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
client/app/components/SelectWithVirtualScroll.tsx
Normal file
38
client/app/components/SelectWithVirtualScroll.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { maxBy } from "lodash";
|
||||||
|
import AntdSelect, { SelectProps, LabeledValue } from "antd/lib/select";
|
||||||
|
import { calculateTextWidth } from "@/lib/calculateTextWidth";
|
||||||
|
|
||||||
|
const MIN_LEN_FOR_VIRTUAL_SCROLL = 400;
|
||||||
|
|
||||||
|
interface VirtualScrollLabeledValue extends LabeledValue {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtualScrollSelectProps extends SelectProps<string> {
|
||||||
|
options: Array<VirtualScrollLabeledValue>;
|
||||||
|
}
|
||||||
|
function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps): JSX.Element {
|
||||||
|
const dropdownMatchSelectWidth = useMemo<number | boolean>(() => {
|
||||||
|
if (options && options.length > MIN_LEN_FOR_VIRTUAL_SCROLL) {
|
||||||
|
const largestOpt = maxBy(options, "label.length");
|
||||||
|
|
||||||
|
if (largestOpt) {
|
||||||
|
const offset = 40;
|
||||||
|
const optionText = largestOpt.label;
|
||||||
|
const width = calculateTextWidth(optionText);
|
||||||
|
if (width) {
|
||||||
|
return width + offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
return <AntdSelect<string> dropdownMatchSelectWidth={dropdownMatchSelectWidth} options={options} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectWithVirtualScroll;
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Menu from "antd/lib/menu";
|
import Menu from "antd/lib/menu";
|
||||||
import PageHeader from "@/components/PageHeader";
|
import PageHeader from "@/components/PageHeader";
|
||||||
|
import Link from "@/components/Link";
|
||||||
import location from "@/services/location";
|
import location from "@/services/location";
|
||||||
import settingsMenu from "@/services/settingsMenu";
|
import settingsMenu from "@/services/settingsMenu";
|
||||||
|
|
||||||
function wrapSettingsTab(options, WrappedComponent) {
|
function wrapSettingsTab(id, options, WrappedComponent) {
|
||||||
if (options) {
|
settingsMenu.add(id, options);
|
||||||
settingsMenu.add(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
return function SettingsTab(props) {
|
return function SettingsTab(props) {
|
||||||
const activeItem = settingsMenu.getActiveItem(location.path);
|
const activeItem = settingsMenu.getActiveItem(location.path);
|
||||||
@@ -17,15 +16,13 @@ function wrapSettingsTab(options, WrappedComponent) {
|
|||||||
<PageHeader title="Settings" />
|
<PageHeader title="Settings" />
|
||||||
<div className="bg-white tiled">
|
<div className="bg-white tiled">
|
||||||
<Menu selectedKeys={[activeItem && activeItem.title]} selectable={false} mode="horizontal">
|
<Menu selectedKeys={[activeItem && activeItem.title]} selectable={false} mode="horizontal">
|
||||||
{settingsMenu.items
|
{settingsMenu.getAvailableItems().map(item => (
|
||||||
.filter(item => item.isAvailable())
|
<Menu.Item key={item.title}>
|
||||||
.map(item => (
|
<Link href={item.path} data-test="SettingsScreenItem">
|
||||||
<Menu.Item key={item.title}>
|
{item.title}
|
||||||
<a href={item.path} data-test="SettingsScreenItem">
|
</Link>
|
||||||
{item.title}
|
</Menu.Item>
|
||||||
</a>
|
))}
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</Menu>
|
</Menu>
|
||||||
<div className="p-15">
|
<div className="p-15">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { map } from "lodash";
|
|
||||||
import React from "react";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import Badge from "antd/lib/badge";
|
|
||||||
import Menu from "antd/lib/menu";
|
|
||||||
import getTags from "@/services/getTags";
|
|
||||||
|
|
||||||
import "./TagsList.less";
|
|
||||||
|
|
||||||
export default class TagsList extends React.Component {
|
|
||||||
static propTypes = {
|
|
||||||
tagsUrl: PropTypes.string.isRequired,
|
|
||||||
onUpdate: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
onUpdate: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
// An array of objects that with the name and count of the tagged items
|
|
||||||
allTags: [],
|
|
||||||
// A set of tag names
|
|
||||||
selectedTags: new Set(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
getTags(this.props.tagsUrl).then(allTags => {
|
|
||||||
this.setState({ allTags });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleTag(event, tag) {
|
|
||||||
const { selectedTags } = this.state;
|
|
||||||
if (event.shiftKey) {
|
|
||||||
// toggle tag
|
|
||||||
if (selectedTags.has(tag)) {
|
|
||||||
selectedTags.delete(tag);
|
|
||||||
} else {
|
|
||||||
selectedTags.add(tag);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// if the tag is the only selected, deselect it, otherwise select only it
|
|
||||||
if (selectedTags.has(tag) && selectedTags.size === 1) {
|
|
||||||
selectedTags.clear();
|
|
||||||
} else {
|
|
||||||
selectedTags.clear();
|
|
||||||
selectedTags.add(tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.forceUpdate();
|
|
||||||
|
|
||||||
this.props.onUpdate([...this.state.selectedTags]);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { allTags, selectedTags } = this.state;
|
|
||||||
if (allTags.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className="m-t-10 tags-list tiled">
|
|
||||||
<Menu className="invert-stripe-position" mode="inline" selectedKeys={[...selectedTags]}>
|
|
||||||
{map(allTags, tag => (
|
|
||||||
<Menu.Item key={tag.name} className="m-0">
|
|
||||||
<a
|
|
||||||
className="d-flex align-items-center justify-content-between"
|
|
||||||
onClick={event => this.toggleTag(event, tag.name)}>
|
|
||||||
<span className="max-character col-xs-11">{tag.name}</span>
|
|
||||||
<Badge count={tag.count} />
|
|
||||||
</a>
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,47 @@
|
|||||||
@import '~@/assets/less/ant';
|
@import "~@/assets/less/ant";
|
||||||
|
|
||||||
.tags-list {
|
.tags-list {
|
||||||
|
.tags-list-title {
|
||||||
|
margin: 15px 5px 5px 5px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 75%;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ant-badge-count {
|
.ant-badge-count {
|
||||||
background-color: fade(@redash-gray, 10%);
|
background-color: fade(@redash-gray, 10%);
|
||||||
color: fade(@redash-gray, 75%);
|
color: fade(@redash-gray, 75%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-menu-item-selected {
|
.ant-menu.ant-menu-inline {
|
||||||
.ant-badge-count {
|
border: none;
|
||||||
background-color: @primary-color;
|
|
||||||
color: white;
|
.ant-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-menu-item-selected {
|
||||||
|
.ant-badge-count {
|
||||||
|
background-color: @primary-color;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
client/app/components/TagsList.tsx
Normal file
107
client/app/components/TagsList.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { map, includes, difference } from "lodash";
|
||||||
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
|
import Badge from "antd/lib/badge";
|
||||||
|
import Menu from "antd/lib/menu";
|
||||||
|
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
||||||
|
import getTags from "@/services/getTags";
|
||||||
|
|
||||||
|
import "./TagsList.less";
|
||||||
|
|
||||||
|
type Tag = {
|
||||||
|
name: string;
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TagsListProps = {
|
||||||
|
tagsUrl: string;
|
||||||
|
showUnselectAll: boolean;
|
||||||
|
onUpdate?: (selectedTags: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TagsList({ tagsUrl, showUnselectAll = false, onUpdate }: TagsListProps): JSX.Element | null {
|
||||||
|
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
getTags(tagsUrl).then(tags => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setAllTags(tags);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [tagsUrl]);
|
||||||
|
|
||||||
|
const toggleTag = useCallback(
|
||||||
|
(event, tag) => {
|
||||||
|
let newSelectedTags;
|
||||||
|
if (event.shiftKey) {
|
||||||
|
// toggle tag
|
||||||
|
if (includes(selectedTags, tag)) {
|
||||||
|
newSelectedTags = difference(selectedTags, [tag]);
|
||||||
|
} else {
|
||||||
|
newSelectedTags = [...selectedTags, tag];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if the tag is the only selected, deselect it, otherwise select only it
|
||||||
|
if (includes(selectedTags, tag) && selectedTags.length === 1) {
|
||||||
|
newSelectedTags = [];
|
||||||
|
} else {
|
||||||
|
newSelectedTags = [tag];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedTags(newSelectedTags);
|
||||||
|
if (onUpdate) {
|
||||||
|
onUpdate([...newSelectedTags]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedTags, onUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const unselectAll = useCallback(() => {
|
||||||
|
setSelectedTags([]);
|
||||||
|
if (onUpdate) {
|
||||||
|
onUpdate([]);
|
||||||
|
}
|
||||||
|
}, [onUpdate]);
|
||||||
|
|
||||||
|
if (allTags.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tags-list">
|
||||||
|
<div className="tags-list-title">
|
||||||
|
<label>Tags</label>
|
||||||
|
{showUnselectAll && selectedTags.length > 0 && (
|
||||||
|
<a onClick={unselectAll}>
|
||||||
|
<CloseOutlinedIcon />
|
||||||
|
clear selection
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tiled">
|
||||||
|
<Menu className="invert-stripe-position" mode="inline" selectedKeys={selectedTags}>
|
||||||
|
{map(allTags, tag => (
|
||||||
|
<Menu.Item key={tag.name} className="m-0">
|
||||||
|
<a
|
||||||
|
className="d-flex align-items-center justify-content-between"
|
||||||
|
onClick={event => toggleTag(event, tag.name)}>
|
||||||
|
<span className="max-character col-xs-11">{tag.name}</span>
|
||||||
|
<Badge count={tag.count} />
|
||||||
|
</a>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TagsList;
|
||||||
@@ -11,7 +11,7 @@ function toMoment(value) {
|
|||||||
return value && value.isValid() ? value : null;
|
return value && value.isValid() ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TimeAgo({ date, placeholder, autoUpdate }) {
|
export default function TimeAgo({ date, placeholder, autoUpdate, variation }) {
|
||||||
const startDate = toMoment(date);
|
const startDate = toMoment(date);
|
||||||
const [value, setValue] = useState(null);
|
const [value, setValue] = useState(null);
|
||||||
const title = useMemo(() => (startDate ? startDate.format(clientConfig.dateTimeFormat) : null), [startDate]);
|
const title = useMemo(() => (startDate ? startDate.format(clientConfig.dateTimeFormat) : null), [startDate]);
|
||||||
@@ -28,6 +28,13 @@ export default function TimeAgo({ date, placeholder, autoUpdate }) {
|
|||||||
}
|
}
|
||||||
}, [autoUpdate, startDate, placeholder]);
|
}, [autoUpdate, startDate, placeholder]);
|
||||||
|
|
||||||
|
if (variation === "timeAgoInTooltip") {
|
||||||
|
return (
|
||||||
|
<Tooltip title={value}>
|
||||||
|
<span data-test="TimeAgo">{title}</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Tooltip title={title}>
|
<Tooltip title={title}>
|
||||||
<span data-test="TimeAgo">{value}</span>
|
<span data-test="TimeAgo">{value}</span>
|
||||||
@@ -39,6 +46,7 @@ TimeAgo.propTypes = {
|
|||||||
date: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]),
|
date: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]),
|
||||||
placeholder: PropTypes.string,
|
placeholder: PropTypes.string,
|
||||||
autoUpdate: PropTypes.bool,
|
autoUpdate: PropTypes.bool,
|
||||||
|
variation: PropTypes.oneOf(["timeAgoInTooltip"]),
|
||||||
};
|
};
|
||||||
|
|
||||||
TimeAgo.defaultProps = {
|
TimeAgo.defaultProps = {
|
||||||
|
|||||||
32
client/app/components/UserGroups.jsx
Normal file
32
client/app/components/UserGroups.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { map } from "lodash";
|
||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import Tag from "antd/lib/tag";
|
||||||
|
import Link from "@/components/Link";
|
||||||
|
|
||||||
|
import "./UserGroups.less";
|
||||||
|
|
||||||
|
export default function UserGroups({ groups, linkGroups, ...props }) {
|
||||||
|
return (
|
||||||
|
<div className="user-groups" {...props}>
|
||||||
|
{map(groups, group => (
|
||||||
|
<Tag key={group.id}>{linkGroups ? <Link href={`groups/${group.id}`}>{group.name}</Link> : group.name}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
UserGroups.propTypes = {
|
||||||
|
groups: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||||
|
name: PropTypes.string,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
linkGroups: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
UserGroups.defaultProps = {
|
||||||
|
groups: [],
|
||||||
|
linkGroups: true,
|
||||||
|
};
|
||||||
7
client/app/components/UserGroups.less
Normal file
7
client/app/components/UserGroups.less
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.user-groups {
|
||||||
|
margin: -5px 0 0 -5px;
|
||||||
|
|
||||||
|
.ant-tag {
|
||||||
|
margin: 5px 0 0 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,30 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Tabs from "antd/lib/tabs";
|
import Menu from "antd/lib/menu";
|
||||||
import PageHeader from "@/components/PageHeader";
|
import PageHeader from "@/components/PageHeader";
|
||||||
|
import Link from "@/components/Link";
|
||||||
|
|
||||||
import "./layout.less";
|
import "./layout.less";
|
||||||
|
|
||||||
export default function Layout({ activeTab, children }) {
|
export default function Layout({ activeTab, children }) {
|
||||||
return (
|
return (
|
||||||
<div className="container admin-page-layout">
|
<div className="admin-page-layout">
|
||||||
<PageHeader title="Admin" />
|
<div className="container">
|
||||||
|
<PageHeader title="Admin" />
|
||||||
<div className="bg-white tiled">
|
<div className="bg-white tiled">
|
||||||
<Tabs className="admin-page-layout-tabs" defaultActiveKey={activeTab} animated={false} tabBarGutter={0}>
|
<Menu selectedKeys={[activeTab]} selectable={false} mode="horizontal">
|
||||||
<Tabs.TabPane key="system_status" tab={<a href="admin/status">System Status</a>}>
|
<Menu.Item key="system_status">
|
||||||
{activeTab === "system_status" ? children : null}
|
<Link href="admin/status">System Status</Link>
|
||||||
</Tabs.TabPane>
|
</Menu.Item>
|
||||||
<Tabs.TabPane key="jobs" tab={<a href="admin/queries/jobs">RQ Status</a>}>
|
<Menu.Item key="jobs">
|
||||||
{activeTab === "jobs" ? children : null}
|
<Link href="admin/queries/jobs">RQ Status</Link>
|
||||||
</Tabs.TabPane>
|
</Menu.Item>
|
||||||
<Tabs.TabPane key="outdated_queries" tab={<a href="admin/queries/outdated">Outdated Queries</a>}>
|
<Menu.Item key="outdated_queries">
|
||||||
{activeTab === "outdated_queries" ? children : null}
|
<Link href="admin/queries/outdated">Outdated Queries</Link>
|
||||||
</Tabs.TabPane>
|
</Menu.Item>
|
||||||
</Tabs>
|
</Menu>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,5 @@
|
|||||||
.admin-page-layout {
|
.admin-page-layout {
|
||||||
max-width: 100%;
|
.ant-table {
|
||||||
|
overflow-x: auto;
|
||||||
&-tabs.ant-tabs {
|
|
||||||
> .ant-tabs-bar {
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
.ant-tabs-tab {
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 12px 16px;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import Input from "antd/lib/input";
|
|
||||||
import { includes, isEmpty } from "lodash";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import React from "react";
|
|
||||||
import EmptyState from "@/components/items-list/components/EmptyState";
|
|
||||||
|
|
||||||
import "./CardsList.less";
|
|
||||||
|
|
||||||
const { Search } = Input;
|
|
||||||
|
|
||||||
export default class CardsList extends React.Component {
|
|
||||||
static propTypes = {
|
|
||||||
items: PropTypes.arrayOf(
|
|
||||||
PropTypes.shape({
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
imgSrc: PropTypes.string.isRequired,
|
|
||||||
onClick: PropTypes.func,
|
|
||||||
href: PropTypes.string,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
showSearch: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
items: [],
|
|
||||||
showSearch: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
searchText: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.items = [];
|
|
||||||
|
|
||||||
let itemId = 1;
|
|
||||||
props.items.forEach(item => {
|
|
||||||
this.items.push({ id: itemId, ...item });
|
|
||||||
itemId += 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line class-methods-use-this
|
|
||||||
renderListItem(item) {
|
|
||||||
return (
|
|
||||||
<a key={`card${item.id}`} className="visual-card" onClick={item.onClick} href={item.href}>
|
|
||||||
<img alt={item.title} src={item.imgSrc} />
|
|
||||||
<h3>{item.title}</h3>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { showSearch } = this.props;
|
|
||||||
const { searchText } = this.state;
|
|
||||||
|
|
||||||
const filteredItems = this.items.filter(
|
|
||||||
item => isEmpty(searchText) || includes(item.title.toLowerCase(), searchText.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-test="CardsList">
|
|
||||||
{showSearch && (
|
|
||||||
<div className="row p-10">
|
|
||||||
<div className="col-md-4 col-md-offset-4">
|
|
||||||
<Search placeholder="Search..." onChange={e => this.setState({ searchText: e.target.value })} autoFocus />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isEmpty(filteredItems) ? (
|
|
||||||
<EmptyState className="" />
|
|
||||||
) : (
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-lg-12 d-inline-flex flex-wrap visual-card-list">
|
|
||||||
{filteredItems.map(item => this.renderListItem(item))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
80
client/app/components/cards-list/CardsList.tsx
Normal file
80
client/app/components/cards-list/CardsList.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { includes, isEmpty } from "lodash";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import Input from "antd/lib/input";
|
||||||
|
import Link from "@/components/Link";
|
||||||
|
import EmptyState from "@/components/items-list/components/EmptyState";
|
||||||
|
|
||||||
|
import "./CardsList.less";
|
||||||
|
|
||||||
|
export interface CardsListItem {
|
||||||
|
title: string;
|
||||||
|
imgSrc: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardsListProps {
|
||||||
|
items?: CardsListItem[];
|
||||||
|
showSearch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListItemProps {
|
||||||
|
item: CardsListItem;
|
||||||
|
keySuffix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListItem({ item, keySuffix }: ListItemProps) {
|
||||||
|
return (
|
||||||
|
<Link key={`card${keySuffix}`} className="visual-card" onClick={item.onClick} href={item.href}>
|
||||||
|
<img alt={item.title} src={item.imgSrc} />
|
||||||
|
<h3>{item.title}</h3>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CardsList({ items = [], showSearch = false }: CardsListProps) {
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const filteredItems = items.filter(
|
||||||
|
item => isEmpty(searchText) || includes(item.title.toLowerCase(), searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-test="CardsList">
|
||||||
|
{showSearch && (
|
||||||
|
<div className="row p-10">
|
||||||
|
<div className="col-md-4 col-md-offset-4">
|
||||||
|
<Input.Search
|
||||||
|
placeholder="Search..."
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchText(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isEmpty(filteredItems) ? (
|
||||||
|
<EmptyState className="" />
|
||||||
|
) : (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-12 d-inline-flex flex-wrap visual-card-list">
|
||||||
|
{filteredItems.map((item: CardsListItem, index: number) => (
|
||||||
|
<ListItem key={index} item={item} keySuffix={index.toString()} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CardsList.propTypes = {
|
||||||
|
items: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
imgSrc: PropTypes.string.isRequired,
|
||||||
|
onClick: PropTypes.func,
|
||||||
|
href: PropTypes.string,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
showSearch: PropTypes.bool,
|
||||||
|
};
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { trim } from "lodash";
|
import { trim } from "lodash";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { axios } from "@/services/axios";
|
|
||||||
import Modal from "antd/lib/modal";
|
import Modal from "antd/lib/modal";
|
||||||
import Input from "antd/lib/input";
|
import Input from "antd/lib/input";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
@@ -8,6 +7,7 @@ import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
|||||||
import navigateTo from "@/components/ApplicationArea/navigateTo";
|
import navigateTo from "@/components/ApplicationArea/navigateTo";
|
||||||
import recordEvent from "@/services/recordEvent";
|
import recordEvent from "@/services/recordEvent";
|
||||||
import { policy } from "@/services/policy";
|
import { policy } from "@/services/policy";
|
||||||
|
import { Dashboard } from "@/services/dashboard";
|
||||||
|
|
||||||
function CreateDashboardDialog({ dialog }) {
|
function CreateDashboardDialog({ dialog }) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -25,9 +25,9 @@ function CreateDashboardDialog({ dialog }) {
|
|||||||
if (name !== "") {
|
if (name !== "") {
|
||||||
setSaveInProgress(true);
|
setSaveInProgress(true);
|
||||||
|
|
||||||
axios.post("api/dashboards", { name }).then(data => {
|
Dashboard.save({ name }).then(data => {
|
||||||
dialog.close();
|
dialog.close();
|
||||||
navigateTo(`dashboard/${data.slug}?edit`);
|
navigateTo(`${data.url}?edit`);
|
||||||
});
|
});
|
||||||
recordEvent("create", "dashboard");
|
recordEvent("create", "dashboard");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ class DashboardGrid extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<ResponsiveGridLayout
|
<ResponsiveGridLayout
|
||||||
|
draggableCancel="input"
|
||||||
className={cx("layout", { "disable-animations": this.state.disableAnimations })}
|
className={cx("layout", { "disable-animations": this.state.disableAnimations })}
|
||||||
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
|
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
|
||||||
rowHeight={cfg.rowHeight - cfg.margins}
|
rowHeight={cfg.rowHeight - cfg.margins}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import PropTypes from "prop-types";
|
|||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Modal from "antd/lib/modal";
|
import Modal from "antd/lib/modal";
|
||||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||||
|
import { FiltersType } from "@/components/Filters";
|
||||||
import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer";
|
import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer";
|
||||||
import VisualizationName from "@/components/visualizations/VisualizationName";
|
import VisualizationName from "@/components/visualizations/VisualizationName";
|
||||||
|
|
||||||
function ExpandedWidgetDialog({ dialog, widget }) {
|
function ExpandedWidgetDialog({ dialog, widget, filters }) {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
{...dialog.props}
|
{...dialog.props}
|
||||||
@@ -20,6 +21,7 @@ function ExpandedWidgetDialog({ dialog, widget }) {
|
|||||||
<VisualizationRenderer
|
<VisualizationRenderer
|
||||||
visualization={widget.visualization}
|
visualization={widget.visualization}
|
||||||
queryResult={widget.getQueryResult()}
|
queryResult={widget.getQueryResult()}
|
||||||
|
filters={filters}
|
||||||
context="widget"
|
context="widget"
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -29,6 +31,11 @@ function ExpandedWidgetDialog({ dialog, widget }) {
|
|||||||
ExpandedWidgetDialog.propTypes = {
|
ExpandedWidgetDialog.propTypes = {
|
||||||
dialog: DialogPropType.isRequired,
|
dialog: DialogPropType.isRequired,
|
||||||
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
|
filters: FiltersType,
|
||||||
|
};
|
||||||
|
|
||||||
|
ExpandedWidgetDialog.defaultProps = {
|
||||||
|
filters: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default wrapDialog(ExpandedWidgetDialog);
|
export default wrapDialog(ExpandedWidgetDialog);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Modal from "antd/lib/modal";
|
|||||||
import Input from "antd/lib/input";
|
import Input from "antd/lib/input";
|
||||||
import Tooltip from "antd/lib/tooltip";
|
import Tooltip from "antd/lib/tooltip";
|
||||||
import Divider from "antd/lib/divider";
|
import Divider from "antd/lib/divider";
|
||||||
|
import Link from "@/components/Link";
|
||||||
import HtmlContent from "@redash/viz/lib/components/HtmlContent";
|
import HtmlContent from "@redash/viz/lib/components/HtmlContent";
|
||||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||||
import notification from "@/services/notification";
|
import notification from "@/services/notification";
|
||||||
@@ -40,11 +41,30 @@ function TextboxDialog({ dialog, isNew, ...props }) {
|
|||||||
});
|
});
|
||||||
}, [dialog, isNew, text]);
|
}, [dialog, isNew, text]);
|
||||||
|
|
||||||
|
const confirmDialogDismiss = useCallback(() => {
|
||||||
|
const originalText = props.text;
|
||||||
|
if (text !== originalText) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: "Quit editing?",
|
||||||
|
content: "Changes you made so far will not be saved. Are you sure?",
|
||||||
|
okText: "Yes, quit",
|
||||||
|
okType: "danger",
|
||||||
|
onOk: () => dialog.dismiss(),
|
||||||
|
maskClosable: true,
|
||||||
|
autoFocusButton: null,
|
||||||
|
style: { top: 170 },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dialog.dismiss();
|
||||||
|
}
|
||||||
|
}, [dialog, text, props.text]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
{...dialog.props}
|
{...dialog.props}
|
||||||
title={isNew ? "Add Textbox" : "Edit Textbox"}
|
title={isNew ? "Add Textbox" : "Edit Textbox"}
|
||||||
onOk={saveWidget}
|
onOk={saveWidget}
|
||||||
|
onCancel={confirmDialogDismiss}
|
||||||
okText={isNew ? "Add to Dashboard" : "Save"}
|
okText={isNew ? "Add to Dashboard" : "Save"}
|
||||||
width={500}
|
width={500}
|
||||||
wrapProps={{ "data-test": "TextboxDialog" }}>
|
wrapProps={{ "data-test": "TextboxDialog" }}>
|
||||||
@@ -59,9 +79,12 @@ function TextboxDialog({ dialog, isNew, ...props }) {
|
|||||||
/>
|
/>
|
||||||
<small>
|
<small>
|
||||||
Supports basic{" "}
|
Supports basic{" "}
|
||||||
<a target="_blank" rel="noopener noreferrer" href="https://www.markdownguide.org/cheat-sheet/#basic-syntax">
|
<Link
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
href="https://www.markdownguide.org/cheat-sheet/#basic-syntax">
|
||||||
<Tooltip title="Markdown guide opens in new window">Markdown</Tooltip>
|
<Tooltip title="Markdown guide opens in new window">Markdown</Tooltip>
|
||||||
</a>
|
</Link>
|
||||||
.
|
.
|
||||||
</small>
|
</small>
|
||||||
{text && (
|
{text && (
|
||||||
|
|||||||
@@ -48,10 +48,10 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 85px;
|
bottom: 85px;
|
||||||
right: 15px;
|
right: 0;
|
||||||
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
|
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
|
||||||
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
|
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
|
||||||
background-size: calc((100vw - 15px) / 6) 5px;
|
background-size: calc((100% + 15px) / 6) 5px;
|
||||||
background-position: -7px 1px;
|
background-position: -7px 1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,7 @@
|
|||||||
|
|
||||||
> .filters-wrapper {
|
> .filters-wrapper {
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,15 +113,36 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.counter-visualization-content {
|
.counter-visualization-container {
|
||||||
position: absolute;
|
height: 100%;
|
||||||
left: 10px;
|
|
||||||
top: 15px;
|
.counter-visualization-content {
|
||||||
right: 10px;
|
position: absolute;
|
||||||
bottom: 15px;
|
left: 10px;
|
||||||
height: auto;
|
top: 15px;
|
||||||
overflow: hidden;
|
right: 10px;
|
||||||
padding: 0;
|
bottom: 15px;
|
||||||
|
height: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-fixed-layout {
|
||||||
|
.visualization-renderer > .visualization-renderer-wrapper {
|
||||||
|
.counter-visualization-container {
|
||||||
|
// counter is too large on Query pages, so let's add some constraints
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 400px;
|
||||||
|
// center it
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import HtmlContent from "@redash/viz/lib/components/HtmlContent";
|
|||||||
import { currentUser } from "@/services/auth";
|
import { currentUser } from "@/services/auth";
|
||||||
import recordEvent from "@/services/recordEvent";
|
import recordEvent from "@/services/recordEvent";
|
||||||
import { formatDateTime } from "@/lib/utils";
|
import { formatDateTime } from "@/lib/utils";
|
||||||
|
import Link from "@/components/Link";
|
||||||
import Parameters from "@/components/Parameters";
|
import Parameters from "@/components/Parameters";
|
||||||
import TimeAgo from "@/components/TimeAgo";
|
import TimeAgo from "@/components/TimeAgo";
|
||||||
import Timer from "@/components/Timer";
|
import Timer from "@/components/Timer";
|
||||||
@@ -30,27 +31,27 @@ function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParameters
|
|||||||
return compact([
|
return compact([
|
||||||
<Menu.Item key="download_csv" disabled={isQueryResultEmpty}>
|
<Menu.Item key="download_csv" disabled={isQueryResultEmpty}>
|
||||||
{!isQueryResultEmpty ? (
|
{!isQueryResultEmpty ? (
|
||||||
<a href={downloadLink("csv")} download={downloadName("csv")} target="_self">
|
<Link href={downloadLink("csv")} download={downloadName("csv")} target="_self">
|
||||||
Download as CSV File
|
Download as CSV File
|
||||||
</a>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
"Download as CSV File"
|
"Download as CSV File"
|
||||||
)}
|
)}
|
||||||
</Menu.Item>,
|
</Menu.Item>,
|
||||||
<Menu.Item key="download_tsv" disabled={isQueryResultEmpty}>
|
<Menu.Item key="download_tsv" disabled={isQueryResultEmpty}>
|
||||||
{!isQueryResultEmpty ? (
|
{!isQueryResultEmpty ? (
|
||||||
<a href={downloadLink("tsv")} download={downloadName("tsv")} target="_self">
|
<Link href={downloadLink("tsv")} download={downloadName("tsv")} target="_self">
|
||||||
Download as TSV File
|
Download as TSV File
|
||||||
</a>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
"Download as TSV File"
|
"Download as TSV File"
|
||||||
)}
|
)}
|
||||||
</Menu.Item>,
|
</Menu.Item>,
|
||||||
<Menu.Item key="download_excel" disabled={isQueryResultEmpty}>
|
<Menu.Item key="download_excel" disabled={isQueryResultEmpty}>
|
||||||
{!isQueryResultEmpty ? (
|
{!isQueryResultEmpty ? (
|
||||||
<a href={downloadLink("xlsx")} download={downloadName("xlsx")} target="_self">
|
<Link href={downloadLink("xlsx")} download={downloadName("xlsx")} target="_self">
|
||||||
Download as Excel File
|
Download as Excel File
|
||||||
</a>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
"Download as Excel File"
|
"Download as Excel File"
|
||||||
)}
|
)}
|
||||||
@@ -58,7 +59,7 @@ function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParameters
|
|||||||
(canViewQuery || canEditParameters) && <Menu.Divider key="divider" />,
|
(canViewQuery || canEditParameters) && <Menu.Divider key="divider" />,
|
||||||
canViewQuery && (
|
canViewQuery && (
|
||||||
<Menu.Item key="view_query">
|
<Menu.Item key="view_query">
|
||||||
<a href={widget.getQuery().getUrl(true, widget.visualization.id)}>View Query</a>
|
<Link href={widget.getQuery().getUrl(true, widget.visualization.id)}>View Query</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
),
|
),
|
||||||
canEditParameters && (
|
canEditParameters && (
|
||||||
@@ -208,7 +209,10 @@ class VisualizationWidget extends React.Component {
|
|||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { localParameters: props.widget.getLocalParameters() };
|
this.state = {
|
||||||
|
localParameters: props.widget.getLocalParameters(),
|
||||||
|
localFilters: props.filters,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -218,8 +222,12 @@ class VisualizationWidget extends React.Component {
|
|||||||
onLoad();
|
onLoad();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onLocalFiltersChange = localFilters => {
|
||||||
|
this.setState({ localFilters });
|
||||||
|
};
|
||||||
|
|
||||||
expandWidget = () => {
|
expandWidget = () => {
|
||||||
ExpandedWidgetDialog.showModal({ widget: this.props.widget });
|
ExpandedWidgetDialog.showModal({ widget: this.props.widget, filters: this.state.localFilters });
|
||||||
};
|
};
|
||||||
|
|
||||||
editParameterMappings = () => {
|
editParameterMappings = () => {
|
||||||
@@ -259,6 +267,7 @@ class VisualizationWidget extends React.Component {
|
|||||||
visualization={widget.visualization}
|
visualization={widget.visualization}
|
||||||
queryResult={widgetQueryResult}
|
queryResult={widgetQueryResult}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
onFiltersChange={this.onLocalFiltersChange}
|
||||||
context="widget"
|
context="widget"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
import React from "react";
|
import React, { useState, useReducer, useCallback } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
import Input from "antd/lib/input";
|
|
||||||
import InputNumber from "antd/lib/input-number";
|
|
||||||
import Checkbox from "antd/lib/checkbox";
|
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Upload from "antd/lib/upload";
|
import { includes, isFunction, filter, find, difference, isEmpty, mapValues } from "lodash";
|
||||||
import Icon from "antd/lib/icon";
|
|
||||||
import { includes, isFunction, filter, difference, isEmpty, some, isNumber, isBoolean } from "lodash";
|
|
||||||
import Select from "antd/lib/select";
|
|
||||||
import notification from "@/services/notification";
|
import notification from "@/services/notification";
|
||||||
import Collapse from "@/components/Collapse";
|
import Collapse from "@/components/Collapse";
|
||||||
import AceEditorInput from "@/components/AceEditorInput";
|
import DynamicFormField, { FieldType } from "./DynamicFormField";
|
||||||
import { toHuman } from "@/lib/utils";
|
import getFieldLabel from "./getFieldLabel";
|
||||||
import { Field, Action, AntdForm } from "../proptypes";
|
|
||||||
import helper from "./dynamicFormHelper";
|
import helper from "./dynamicFormHelper";
|
||||||
|
|
||||||
import "./DynamicForm.less";
|
import "./DynamicForm.less";
|
||||||
|
|
||||||
|
const ActionType = PropTypes.shape({
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
callback: PropTypes.func.isRequired,
|
||||||
|
type: PropTypes.string,
|
||||||
|
pullRight: PropTypes.bool,
|
||||||
|
disabledWhenDirty: PropTypes.bool,
|
||||||
|
});
|
||||||
|
|
||||||
|
const AntdFormType = PropTypes.shape({
|
||||||
|
validateFieldsAndScroll: PropTypes.func,
|
||||||
|
});
|
||||||
|
|
||||||
const fieldRules = ({ type, required, minLength }) => {
|
const fieldRules = ({ type, required, minLength }) => {
|
||||||
const requiredRule = required;
|
const requiredRule = required;
|
||||||
const minLengthRule = minLength && includes(["text", "email", "password"], type);
|
const minLengthRule = minLength && includes(["text", "email", "password"], type);
|
||||||
@@ -31,290 +36,206 @@ const fieldRules = ({ type, required, minLength }) => {
|
|||||||
].filter(rule => rule);
|
].filter(rule => rule);
|
||||||
};
|
};
|
||||||
|
|
||||||
class DynamicForm extends React.Component {
|
function normalizeEmptyValuesToNull(fields, values) {
|
||||||
static propTypes = {
|
return mapValues(values, (value, key) => {
|
||||||
id: PropTypes.string,
|
const { initialValue } = find(fields, { name: key }) || {};
|
||||||
fields: PropTypes.arrayOf(Field),
|
if ((initialValue === null || initialValue === undefined || initialValue === "") && value === "") {
|
||||||
actions: PropTypes.arrayOf(Action),
|
return null;
|
||||||
feedbackIcons: PropTypes.bool,
|
|
||||||
hideSubmitButton: PropTypes.bool,
|
|
||||||
saveText: PropTypes.string,
|
|
||||||
onSubmit: PropTypes.func,
|
|
||||||
form: AntdForm.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
id: null,
|
|
||||||
fields: [],
|
|
||||||
actions: [],
|
|
||||||
feedbackIcons: false,
|
|
||||||
hideSubmitButton: false,
|
|
||||||
saveText: "Save",
|
|
||||||
onSubmit: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
const hasFilledExtraField = some(props.fields, field => {
|
|
||||||
const { extra, initialValue, placeholder } = field;
|
|
||||||
return (
|
|
||||||
extra &&
|
|
||||||
(!isEmpty(initialValue) ||
|
|
||||||
isNumber(initialValue) ||
|
|
||||||
(isBoolean(initialValue) && initialValue.toString() !== placeholder))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const inProgressActions = {};
|
|
||||||
props.actions.forEach(action => (inProgressActions[action.name] = false));
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isSubmitting: false,
|
|
||||||
showExtraFields: hasFilledExtraField,
|
|
||||||
inProgressActions,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.actionCallbacks = this.props.actions.reduce(
|
|
||||||
(acc, cur) => ({
|
|
||||||
...acc,
|
|
||||||
[cur.name]: cur.callback,
|
|
||||||
}),
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setActionInProgress = (actionName, inProgress) => {
|
|
||||||
this.setState(prevState => ({
|
|
||||||
inProgressActions: {
|
|
||||||
...prevState.inProgressActions,
|
|
||||||
[actionName]: inProgress,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleSubmit = e => {
|
|
||||||
this.setState({ isSubmitting: true });
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
this.props.form.validateFieldsAndScroll((err, values) => {
|
|
||||||
Object.entries(values).forEach(([key, value]) => {
|
|
||||||
const initialValue = this.props.fields.find(f => f.name === key).initialValue;
|
|
||||||
if ((initialValue === null || initialValue === undefined || initialValue === "") && value === "") {
|
|
||||||
values[key] = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!err) {
|
|
||||||
this.props.onSubmit(
|
|
||||||
values,
|
|
||||||
msg => {
|
|
||||||
const { setFieldsValue, getFieldsValue } = this.props.form;
|
|
||||||
this.setState({ isSubmitting: false });
|
|
||||||
setFieldsValue(getFieldsValue()); // reset form touched state
|
|
||||||
notification.success(msg);
|
|
||||||
},
|
|
||||||
msg => {
|
|
||||||
this.setState({ isSubmitting: false });
|
|
||||||
notification.error(msg);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else this.setState({ isSubmitting: false });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleAction = e => {
|
|
||||||
const actionName = e.target.dataset.action;
|
|
||||||
|
|
||||||
this.setActionInProgress(actionName, true);
|
|
||||||
this.actionCallbacks[actionName](() => {
|
|
||||||
this.setActionInProgress(actionName, false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
base64File = (fieldName, e) => {
|
|
||||||
if (e && e.fileList[0]) {
|
|
||||||
helper.getBase64(e.file).then(value => {
|
|
||||||
this.props.form.setFieldsValue({ [fieldName]: value });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
renderUpload(field, props) {
|
function DynamicFormFields({ fields, feedbackIcons, form }) {
|
||||||
const { getFieldDecorator, getFieldValue } = this.props.form;
|
return fields.map(field => {
|
||||||
const { name, initialValue } = field;
|
const { name, type, initialValue, contentAfter } = field;
|
||||||
|
const fieldLabel = getFieldLabel(field);
|
||||||
|
|
||||||
const fileOptions = {
|
const formItemProps = {
|
||||||
rules: fieldRules(field),
|
|
||||||
initialValue,
|
|
||||||
getValueFromEvent: this.base64File.bind(this, name),
|
|
||||||
};
|
|
||||||
|
|
||||||
const disabled = getFieldValue(name) !== undefined && getFieldValue(name) !== initialValue;
|
|
||||||
|
|
||||||
const upload = (
|
|
||||||
<Upload {...props} beforeUpload={() => false}>
|
|
||||||
<Button disabled={disabled}>
|
|
||||||
<Icon type="upload" /> Click to upload
|
|
||||||
</Button>
|
|
||||||
</Upload>
|
|
||||||
);
|
|
||||||
|
|
||||||
return getFieldDecorator(name, fileOptions)(upload);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSelect(field, props) {
|
|
||||||
const { getFieldDecorator } = this.props.form;
|
|
||||||
const { name, options, mode, initialValue, readOnly, loading } = field;
|
|
||||||
const { Option } = Select;
|
|
||||||
|
|
||||||
const decoratorOptions = {
|
|
||||||
rules: fieldRules(field),
|
|
||||||
initialValue,
|
|
||||||
};
|
|
||||||
|
|
||||||
return getFieldDecorator(
|
|
||||||
name,
|
name,
|
||||||
decoratorOptions
|
className: "m-b-10",
|
||||||
)(
|
hasFeedback: type !== "checkbox" && type !== "file" && feedbackIcons,
|
||||||
<Select
|
label: type === "checkbox" ? "" : fieldLabel,
|
||||||
{...props}
|
|
||||||
optionFilterProp="children"
|
|
||||||
loading={loading || false}
|
|
||||||
mode={mode}
|
|
||||||
getPopupContainer={trigger => trigger.parentNode}>
|
|
||||||
{options &&
|
|
||||||
options.map(option => (
|
|
||||||
<Option key={`${option.value}`} value={option.value} disabled={readOnly}>
|
|
||||||
{option.name || option.value}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderField(field, props) {
|
|
||||||
const { getFieldDecorator } = this.props.form;
|
|
||||||
const { name, type, initialValue } = field;
|
|
||||||
const fieldLabel = field.title || toHuman(name);
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
rules: fieldRules(field),
|
rules: fieldRules(field),
|
||||||
valuePropName: type === "checkbox" ? "checked" : "value",
|
valuePropName: type === "checkbox" ? "checked" : "value",
|
||||||
initialValue,
|
initialValue,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === "checkbox") {
|
if (type === "file") {
|
||||||
return getFieldDecorator(name, options)(<Checkbox {...props}>{fieldLabel}</Checkbox>);
|
formItemProps.valuePropName = "data-value";
|
||||||
} else if (type === "file") {
|
formItemProps.getValueFromEvent = e => {
|
||||||
return this.renderUpload(field, props);
|
if (e && e.fileList[0]) {
|
||||||
} else if (type === "select") {
|
helper.getBase64(e.file).then(value => {
|
||||||
return this.renderSelect(field, props);
|
form.setFieldsValue({ [name]: value });
|
||||||
} else if (type === "content") {
|
});
|
||||||
return field.content;
|
}
|
||||||
} else if (type === "number") {
|
return undefined;
|
||||||
return getFieldDecorator(name, options)(<InputNumber {...props} />);
|
};
|
||||||
} else if (type === "textarea") {
|
|
||||||
return getFieldDecorator(name, options)(<Input.TextArea {...props} />);
|
|
||||||
} else if (type === "ace") {
|
|
||||||
return getFieldDecorator(name, options)(<AceEditorInput {...props} />);
|
|
||||||
}
|
}
|
||||||
return getFieldDecorator(name, options)(<Input {...props} />);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderFields(fields) {
|
|
||||||
return fields.map(field => {
|
|
||||||
const FormItem = Form.Item;
|
|
||||||
const { name, title, type, readOnly, autoFocus, contentAfter } = field;
|
|
||||||
const fieldLabel = title || toHuman(name);
|
|
||||||
const { feedbackIcons, form } = this.props;
|
|
||||||
|
|
||||||
const formItemProps = {
|
|
||||||
className: "m-b-10",
|
|
||||||
hasFeedback: type !== "checkbox" && type !== "file" && feedbackIcons,
|
|
||||||
label: type === "checkbox" ? "" : fieldLabel,
|
|
||||||
};
|
|
||||||
|
|
||||||
const fieldProps = {
|
|
||||||
...field.props,
|
|
||||||
className: "w-100",
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
readOnly,
|
|
||||||
autoFocus,
|
|
||||||
placeholder: field.placeholder,
|
|
||||||
"data-test": fieldLabel,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={name}>
|
|
||||||
<FormItem {...formItemProps}>{this.renderField(field, fieldProps)}</FormItem>
|
|
||||||
{isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renderActions() {
|
|
||||||
return this.props.actions.map(action => {
|
|
||||||
const inProgress = this.state.inProgressActions[action.name];
|
|
||||||
const { isFieldsTouched } = this.props.form;
|
|
||||||
|
|
||||||
const actionProps = {
|
|
||||||
key: action.name,
|
|
||||||
htmlType: "button",
|
|
||||||
className: action.pullRight ? "pull-right m-t-10" : "m-t-10",
|
|
||||||
type: action.type,
|
|
||||||
disabled: isFieldsTouched() && action.disableWhenDirty,
|
|
||||||
loading: inProgress,
|
|
||||||
onClick: this.handleAction,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button {...actionProps} data-action={action.name}>
|
|
||||||
{action.name}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const submitProps = {
|
|
||||||
type: "primary",
|
|
||||||
htmlType: "submit",
|
|
||||||
className: "w-100 m-t-20",
|
|
||||||
disabled: this.state.isSubmitting,
|
|
||||||
loading: this.state.isSubmitting,
|
|
||||||
};
|
|
||||||
const { id, hideSubmitButton, saveText, fields } = this.props;
|
|
||||||
const { showExtraFields } = this.state;
|
|
||||||
const saveButton = !hideSubmitButton;
|
|
||||||
const extraFields = filter(fields, { extra: true });
|
|
||||||
const regularFields = difference(fields, extraFields);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form id={id} className="dynamic-form" layout="vertical" onSubmit={this.handleSubmit}>
|
<React.Fragment key={name}>
|
||||||
{this.renderFields(regularFields)}
|
<Form.Item {...formItemProps}>
|
||||||
{!isEmpty(extraFields) && (
|
<DynamicFormField field={field} form={form} />
|
||||||
<div className="extra-options">
|
</Form.Item>
|
||||||
<Button
|
{isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter}
|
||||||
type="dashed"
|
</React.Fragment>
|
||||||
block
|
|
||||||
className="extra-options-button"
|
|
||||||
onClick={() => this.setState({ showExtraFields: !showExtraFields })}>
|
|
||||||
Additional Settings
|
|
||||||
<i className={cx("fa m-l-5", { "fa-caret-up": showExtraFields, "fa-caret-down": !showExtraFields })} />
|
|
||||||
</Button>
|
|
||||||
<Collapse collapsed={!showExtraFields} className="extra-options-content">
|
|
||||||
{this.renderFields(extraFields)}
|
|
||||||
</Collapse>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{saveButton && <Button {...submitProps}>{saveText}</Button>}
|
|
||||||
{this.renderActions()}
|
|
||||||
</Form>
|
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Form.create()(DynamicForm);
|
DynamicFormFields.propTypes = {
|
||||||
|
fields: PropTypes.arrayOf(FieldType),
|
||||||
|
feedbackIcons: PropTypes.bool,
|
||||||
|
form: AntdFormType.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
DynamicFormFields.defaultProps = {
|
||||||
|
fields: [],
|
||||||
|
feedbackIcons: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reducerForActionSet = (state, action) => {
|
||||||
|
if (action.inProgress) {
|
||||||
|
state.add(action.actionName);
|
||||||
|
} else {
|
||||||
|
state.delete(action.actionName);
|
||||||
|
}
|
||||||
|
return new Set(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
function DynamicFormActions({ actions, isFormDirty }) {
|
||||||
|
const [inProgressActions, setActionInProgress] = useReducer(reducerForActionSet, new Set());
|
||||||
|
|
||||||
|
const handleAction = useCallback(action => {
|
||||||
|
const actionName = action.name;
|
||||||
|
if (isFunction(action.callback)) {
|
||||||
|
setActionInProgress({ actionName, inProgress: true });
|
||||||
|
action.callback(() => {
|
||||||
|
setActionInProgress({ actionName, inProgress: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return actions.map(action => (
|
||||||
|
<Button
|
||||||
|
key={action.name}
|
||||||
|
htmlType="button"
|
||||||
|
className={cx("m-t-10", { "pull-right": action.pullRight })}
|
||||||
|
type={action.type}
|
||||||
|
disabled={isFormDirty && action.disableWhenDirty}
|
||||||
|
loading={inProgressActions.has(action.name)}
|
||||||
|
onClick={() => handleAction(action)}>
|
||||||
|
{action.name}
|
||||||
|
</Button>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
DynamicFormActions.propTypes = {
|
||||||
|
actions: PropTypes.arrayOf(ActionType),
|
||||||
|
isFormDirty: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
DynamicFormActions.defaultProps = {
|
||||||
|
actions: [],
|
||||||
|
isFormDirty: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DynamicForm({
|
||||||
|
id,
|
||||||
|
fields,
|
||||||
|
actions,
|
||||||
|
feedbackIcons,
|
||||||
|
hideSubmitButton,
|
||||||
|
defaultShowExtraFields,
|
||||||
|
saveText,
|
||||||
|
onSubmit,
|
||||||
|
}) {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [showExtraFields, setShowExtraFields] = useState(defaultShowExtraFields);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const extraFields = filter(fields, { extra: true });
|
||||||
|
const regularFields = difference(fields, extraFields);
|
||||||
|
|
||||||
|
const handleFinish = useCallback(
|
||||||
|
values => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
values = normalizeEmptyValuesToNull(fields, values);
|
||||||
|
onSubmit(
|
||||||
|
values,
|
||||||
|
msg => {
|
||||||
|
const { setFieldsValue, getFieldsValue } = form;
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setFieldsValue(getFieldsValue()); // reset form touched state
|
||||||
|
notification.success(msg);
|
||||||
|
},
|
||||||
|
msg => {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
notification.error(msg);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[form, fields, onSubmit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFinishFailed = useCallback(
|
||||||
|
({ errorFields }) => {
|
||||||
|
form.scrollToField(errorFields[0].name);
|
||||||
|
},
|
||||||
|
[form]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
id={id}
|
||||||
|
className="dynamic-form"
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleFinish}
|
||||||
|
onFinishFailed={handleFinishFailed}>
|
||||||
|
<DynamicFormFields fields={regularFields} feedbackIcons={feedbackIcons} form={form} />
|
||||||
|
{!isEmpty(extraFields) && (
|
||||||
|
<div className="extra-options">
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
block
|
||||||
|
className="extra-options-button"
|
||||||
|
onClick={() => setShowExtraFields(currentShowExtraFields => !currentShowExtraFields)}>
|
||||||
|
Additional Settings
|
||||||
|
<i className={cx("fa m-l-5", { "fa-caret-up": showExtraFields, "fa-caret-down": !showExtraFields })} />
|
||||||
|
</Button>
|
||||||
|
<Collapse collapsed={!showExtraFields} className="extra-options-content">
|
||||||
|
<DynamicFormFields fields={extraFields} feedbackIcons={feedbackIcons} form={form} />
|
||||||
|
</Collapse>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!hideSubmitButton && (
|
||||||
|
<Button className="w-100 m-t-20" type="primary" htmlType="submit" disabled={isSubmitting}>
|
||||||
|
{saveText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<DynamicFormActions actions={actions} isFormDirty={form.isFieldsTouched()} />
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DynamicForm.propTypes = {
|
||||||
|
id: PropTypes.string,
|
||||||
|
fields: PropTypes.arrayOf(FieldType),
|
||||||
|
actions: PropTypes.arrayOf(ActionType),
|
||||||
|
feedbackIcons: PropTypes.bool,
|
||||||
|
hideSubmitButton: PropTypes.bool,
|
||||||
|
defaultShowExtraFields: PropTypes.bool,
|
||||||
|
saveText: PropTypes.string,
|
||||||
|
onSubmit: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
DynamicForm.defaultProps = {
|
||||||
|
id: null,
|
||||||
|
fields: [],
|
||||||
|
actions: [],
|
||||||
|
feedbackIcons: false,
|
||||||
|
hideSubmitButton: false,
|
||||||
|
defaultShowExtraFields: false,
|
||||||
|
saveText: "Save",
|
||||||
|
onSubmit: () => {},
|
||||||
|
};
|
||||||
|
|||||||
82
client/app/components/dynamic-form/DynamicFormField.jsx
Normal file
82
client/app/components/dynamic-form/DynamicFormField.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { get } from "lodash";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import getFieldLabel from "./getFieldLabel";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AceEditorField,
|
||||||
|
CheckboxField,
|
||||||
|
ContentField,
|
||||||
|
FileField,
|
||||||
|
InputField,
|
||||||
|
NumberField,
|
||||||
|
SelectField,
|
||||||
|
TextAreaField,
|
||||||
|
} from "./fields";
|
||||||
|
|
||||||
|
export const FieldType = PropTypes.shape({
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
title: PropTypes.string,
|
||||||
|
type: PropTypes.oneOf([
|
||||||
|
"ace",
|
||||||
|
"text",
|
||||||
|
"textarea",
|
||||||
|
"email",
|
||||||
|
"password",
|
||||||
|
"number",
|
||||||
|
"checkbox",
|
||||||
|
"file",
|
||||||
|
"select",
|
||||||
|
"content",
|
||||||
|
]).isRequired,
|
||||||
|
initialValue: PropTypes.oneOfType([
|
||||||
|
PropTypes.string,
|
||||||
|
PropTypes.number,
|
||||||
|
PropTypes.bool,
|
||||||
|
PropTypes.arrayOf(PropTypes.string),
|
||||||
|
PropTypes.arrayOf(PropTypes.number),
|
||||||
|
]),
|
||||||
|
content: PropTypes.node,
|
||||||
|
mode: PropTypes.string,
|
||||||
|
required: PropTypes.bool,
|
||||||
|
extra: PropTypes.bool,
|
||||||
|
readOnly: PropTypes.bool,
|
||||||
|
autoFocus: PropTypes.bool,
|
||||||
|
minLength: PropTypes.number,
|
||||||
|
placeholder: PropTypes.string,
|
||||||
|
contentAfter: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||||
|
loading: PropTypes.bool,
|
||||||
|
props: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||||
|
});
|
||||||
|
|
||||||
|
const FieldTypeComponent = {
|
||||||
|
checkbox: CheckboxField,
|
||||||
|
file: FileField,
|
||||||
|
select: SelectField,
|
||||||
|
number: NumberField,
|
||||||
|
textarea: TextAreaField,
|
||||||
|
ace: AceEditorField,
|
||||||
|
content: ContentField,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DynamicFormField({ form, field, ...otherProps }) {
|
||||||
|
const { name, type, readOnly, autoFocus } = field;
|
||||||
|
const fieldLabel = getFieldLabel(field);
|
||||||
|
|
||||||
|
const fieldProps = {
|
||||||
|
...field.props,
|
||||||
|
className: "w-100",
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
readOnly,
|
||||||
|
autoFocus,
|
||||||
|
placeholder: field.placeholder,
|
||||||
|
"data-test": fieldLabel,
|
||||||
|
...otherProps,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FieldComponent = get(FieldTypeComponent, type, InputField);
|
||||||
|
return <FieldComponent {...fieldProps} form={form} field={field} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
DynamicFormField.propTypes = { field: FieldType.isRequired };
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { each, includes, isUndefined, isEmpty, isNil, map } from "lodash";
|
import { each, includes, isUndefined, isEmpty, isNil, map, get, some } from "lodash";
|
||||||
|
|
||||||
function orderedInputs(properties, order, targetOptions) {
|
function orderedInputs(properties, order, targetOptions) {
|
||||||
const inputs = new Array(order.length);
|
const inputs = new Array(order.length);
|
||||||
@@ -124,8 +124,18 @@ function getBase64(file) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasFilledExtraField(type, target) {
|
||||||
|
const extraOptions = get(type, "configuration_schema.extra_options", []);
|
||||||
|
return some(extraOptions, optionName => {
|
||||||
|
const defaultOptionValue = get(type, ["configuration_schema", "properties", optionName, "default"]);
|
||||||
|
const targetOptionValue = get(target, ["options", optionName]);
|
||||||
|
return !isNil(targetOptionValue) && targetOptionValue !== defaultOptionValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getFields,
|
getFields,
|
||||||
updateTargetWithValues,
|
updateTargetWithValues,
|
||||||
getBase64,
|
getBase64,
|
||||||
|
hasFilledExtraField,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import React from "react";
|
||||||
|
import AceEditorInput from "@/components/AceEditorInput";
|
||||||
|
|
||||||
|
export default function AceEditorField({ form, field, ...otherProps }) {
|
||||||
|
return <AceEditorInput {...otherProps} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Checkbox from "antd/lib/checkbox";
|
||||||
|
import getFieldLabel from "../getFieldLabel";
|
||||||
|
|
||||||
|
export default function CheckboxField({ form, field, ...otherProps }) {
|
||||||
|
const fieldLabel = getFieldLabel(field);
|
||||||
|
return <Checkbox {...otherProps}>{fieldLabel}</Checkbox>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function ContentField({ field }) {
|
||||||
|
return field.content;
|
||||||
|
}
|
||||||
18
client/app/components/dynamic-form/fields/FileField.jsx
Normal file
18
client/app/components/dynamic-form/fields/FileField.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Button from "antd/lib/button";
|
||||||
|
import Upload from "antd/lib/upload";
|
||||||
|
import UploadOutlinedIcon from "@ant-design/icons/UploadOutlined";
|
||||||
|
|
||||||
|
export default function FileField({ form, field, ...otherProps }) {
|
||||||
|
const { name, initialValue } = field;
|
||||||
|
const { getFieldValue } = form;
|
||||||
|
const disabled = getFieldValue(name) !== undefined && getFieldValue(name) !== initialValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Upload {...otherProps} beforeUpload={() => false}>
|
||||||
|
<Button disabled={disabled}>
|
||||||
|
<UploadOutlinedIcon /> Click to upload
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
client/app/components/dynamic-form/fields/InputField.jsx
Normal file
6
client/app/components/dynamic-form/fields/InputField.jsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Input from "antd/lib/input";
|
||||||
|
|
||||||
|
export default function InputField({ form, field, ...otherProps }) {
|
||||||
|
return <Input {...otherProps} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import React from "react";
|
||||||
|
import InputNumber from "antd/lib/input-number";
|
||||||
|
|
||||||
|
export default function NumberField({ form, field, ...otherProps }) {
|
||||||
|
return <InputNumber {...otherProps} />;
|
||||||
|
}
|
||||||
21
client/app/components/dynamic-form/fields/SelectField.jsx
Normal file
21
client/app/components/dynamic-form/fields/SelectField.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Select from "antd/lib/select";
|
||||||
|
|
||||||
|
export default function SelectField({ form, field, ...otherProps }) {
|
||||||
|
const { readOnly } = field;
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
{...otherProps}
|
||||||
|
optionFilterProp="children"
|
||||||
|
loading={field.loading || false}
|
||||||
|
mode={field.mode}
|
||||||
|
getPopupContainer={trigger => trigger.parentNode}>
|
||||||
|
{field.options &&
|
||||||
|
field.options.map(option => (
|
||||||
|
<Select.Option key={`${option.value}`} value={option.value} disabled={readOnly}>
|
||||||
|
{option.name || option.value}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Input from "antd/lib/input";
|
||||||
|
|
||||||
|
export default function TextAreaField({ form, field, ...otherProps }) {
|
||||||
|
return <Input.TextArea {...otherProps} />;
|
||||||
|
}
|
||||||
8
client/app/components/dynamic-form/fields/index.js
Normal file
8
client/app/components/dynamic-form/fields/index.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { default as AceEditorField } from "./AceEditorField";
|
||||||
|
export { default as CheckboxField } from "./CheckboxField";
|
||||||
|
export { default as ContentField } from "./ContentField";
|
||||||
|
export { default as FileField } from "./FileField";
|
||||||
|
export { default as InputField } from "./InputField";
|
||||||
|
export { default as NumberField } from "./NumberField";
|
||||||
|
export { default as SelectField } from "./SelectField";
|
||||||
|
export { default as TextAreaField } from "./TextAreaField";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user