diff --git a/README.md b/README.md index 98437a7abbf4f..b3c55e775da48 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@ Metabase is the easy, open source way for everyone in your company to ask questi # Features - 5 minute [setup](http://www.metabase.com/docs/latest/setting-up-metabase) (We're not kidding) -- Let anyone on your team [ask questions](http://www.metabase.com/docs/latest/users-guide/03-asking-questions) without knowing SQL -- Rich beautiful [dashboards](http://www.metabase.com/docs/latest/users-guide/05-sharing-answers) with auto refresh and fullscreen +- Let anyone on your team [ask questions](http://www.metabase.com/docs/latest/users-guide/04-asking-questions) without knowing SQL +- Rich beautiful [dashboards](http://www.metabase.com/docs/latest/users-guide/06-sharing-answers) with auto refresh and fullscreen - SQL Mode for analysts and data pros - Create canonical [segments and metrics](http://www.metabase.com/docs/latest/administration-guide/07-segments-and-metrics) for your team to use -- Send data to Slack or email on a schedule with [Pulses](http://www.metabase.com/docs/latest/users-guide/09-pulses) -- View data in Slack anytime with [MetaBot](http://www.metabase.com/docs/latest/users-guide/10-metabot) +- Send data to Slack or email on a schedule with [Pulses](http://www.metabase.com/docs/latest/users-guide/10-pulses) +- View data in Slack anytime with [MetaBot](http://www.metabase.com/docs/latest/users-guide/11-metabot) - [Humanize data](http://www.metabase.com/docs/latest/administration-guide/03-metadata-editing) for your team by renaming, annotating and hiding fields For more information check out [metabase.com](http://www.metabase.com) diff --git a/bin/ci b/bin/ci index 915b3afebe15c..71f0748400d49 100755 --- a/bin/ci +++ b/bin/ci @@ -49,7 +49,6 @@ node-5() { run_step lein eastwood run_step yarn run lint run_step yarn run test - run_step yarn run test-jest run_step yarn run flow } node-6() { diff --git a/bin/osx-release b/bin/osx-release index 868747e5ccc48..0165e4b915613 100755 --- a/bin/osx-release +++ b/bin/osx-release @@ -187,8 +187,10 @@ sub create_dmg_from_source_dir { '-fs', 'HFS+', '-fsargs', '-c c=64,a=16,e=16', '-format', 'UDRW', - '-size', '256MB', # it looks like this can be whatever size we want; compression slims it down + '-size', '512MB', # has to be big enough to hold everything uncompressed, but doesn't matter if there's extra space -- compression slims it down $dmg_filename) == 0 or die $!; + + announce "$dmg_filename created."; } # Mount the disk image, return the device name diff --git a/bin/version b/bin/version index fa20000a446b8..8f8564b7acdd9 100755 --- a/bin/version +++ b/bin/version @@ -1,6 +1,6 @@ #!/usr/bin/env bash -VERSION="v0.24.0-snapshot" +VERSION="v0.25.0-snapshot" # dynamically pull more interesting stuff from latest git commit HASH=$(git show-ref --head --hash=7 head) # first 7 letters of hash should be enough; that's what GitHub uses diff --git a/docs/administration-guide/13-embedding.md b/docs/administration-guide/13-embedding.md index 8c853bd4128b1..950d545bfe4fe 100644 --- a/docs/administration-guide/13-embedding.md +++ b/docs/administration-guide/13-embedding.md @@ -4,9 +4,9 @@ Metabase includes a powerful application embedding feature that allows you to em ### Key Concepts #### Applications -An important distinction to keep in mind is the difference between Metabase and the embedding application. The charts and dashboards you will be embedding live in the Metabase application, and will be embedded in your application (i.e. the embedding application). +An important distinction to keep in mind is the difference between Metabase and the embedding application. The charts and dashboards you will be embedding live in the Metabase application, and will be embedded in your application (i.e. the embedding application). -#### Parameters +#### Parameters Some dashboards and questions have the ability to accept parameters. In dashboards, these are synonymous with dashboard filters. For example, if you have a dashboard with a filter on Publisher ID, this can be specified as a parameter when embedding, so that you could insert the dashboard filtered down to a specific Publisher ID. SQL based questions with template variables can also accept parameters for each variable. So for a query like @@ -18,21 +18,21 @@ WHERE product_id = {{productID}} you could specify a specific productID when embedding the question. #### Signed parameters -In general, when embedding a chart or dashboard, the server of your embedding application will need to sign a request for that resource. +In general, when embedding a chart or dashboard, the server of your embedding application will need to sign a request for that resource. -If you choose to sign a specific parameter value, that means the user can't modify that, nor is a filter widget displayed for that parameter. For example, if the "Publisher ID" is assigned a value and the request signed, that means the front-end client that renders that dashboard on behalf of a given logged-in user can only see information for that publisher ID. +If you choose to sign a specific parameter value, that means the user can't modify that, nor is a filter widget displayed for that parameter. For example, if the "Publisher ID" is assigned a value and the request signed, that means the front-end client that renders that dashboard on behalf of a given logged-in user can only see information for that publisher ID. ### Enabling embedding -To enable embedding, go to the Admin Panel and under Settings, go to the "Embedding in other applications" tab. From there, click "Enable." Here you will see a secret signing key you can use later to sign requests. If you ever need to invalidate that key and generate a new one, just click on "Regenerate Key". +To enable embedding, go to the Admin Panel and under Settings, go to the "Embedding in other applications" tab. From there, click "Enable." Here you will see a secret signing key you can use later to sign requests. If you ever need to invalidate that key and generate a new one, just click on "Regenerate Key". ![Enabling Embedding](images/embedding/01-enabling.png) -You can also see all questions and dashboards that have been marked as "Embeddable" here, as well as revoke any questions or dashboards that should no longer be embeddable in other applications. +You can also see all questions and dashboards that have been marked as "Embeddable" here, as well as revoke any questions or dashboards that should no longer be embeddable in other applications. Once you've enabled the embedding feature on your Metabase instance, you should then go to the individual questions and dashboards you wish to embed to set them up for embedding. ### Embedding Charts and Dashboards -To mark a given question or dashboard, click on the sharing icon +To mark a given question or dashboard, click on the sharing icon ![Share icon](images/embedding/02-share-icon.png) @@ -40,11 +40,11 @@ Then select "Embed this question in an application" ![Enable sharing for a question](images/embedding/03-enable-question.png) -Here you will see a preview of the question or dashboard as it will appear in your application, as well as a panel that shows you the code you will need to insert in your application. +Here you will see a preview of the question or dashboard as it will appear in your application, as well as a panel that shows you the code you will need to insert in your application. ![Preview](images/embedding/04-preview.png) -Importantly, you will need to hit "Publish" when you first set up a chart or dashboard for embedding and each time you change your embedding settings. Also, any changes you make to the resource might require you to update the code in your own application to the latest code sample in the "Code Pane". +Importantly, you will need to hit "Publish" when you first set up a chart or dashboard for embedding and each time you change your embedding settings. Also, any changes you make to the resource might require you to update the code in your own application to the latest code sample in the "Code Pane". ![Code samples for embedding](images/embedding/05-code.png) @@ -52,7 +52,7 @@ We provide code samples for common front end template languages as well as some ### Embedding Charts and Dashboards with locked parameters -If you wish to have a parameter locked down to prevent your embedding application's end users from seeing other users' data, you can mark parameters as "Locked."Once a parameter is marked as Locked, it is not displayed as a filter widget, and must be set by the embedding application's server code. +If you wish to have a parameter locked down to prevent your embedding application's end users from seeing other users' data, you can mark parameters as "Locked."Once a parameter is marked as Locked, it is not displayed as a filter widget, and must be set by the embedding application's server code. ![Locked parameters](images/embedding/06-locked.png) @@ -65,6 +65,3 @@ Dashboards are a fixed aspect ratio, so if you'd like to ensure they're automati ### Reference applications To see concrete examples of how to embed Metabase in applications under a number of common frameworks, check out our [reference implementations](https://github.com/metabase/embedding-reference-apps) on Github. - -## That’s it! -If you still have questions, or want to share Metabase tips and tricks, head over to our [discussion board](http://discourse.metabase.com/). See you there! diff --git a/docs/administration-guide/14-caching.md b/docs/administration-guide/14-caching.md new file mode 100644 index 0000000000000..963a0bd28bd03 --- /dev/null +++ b/docs/administration-guide/14-caching.md @@ -0,0 +1,21 @@ +## Caching query results in Metabase +Metabase now gives you the ability to automatically cache the results of queries that take a long time to run. + +### Enabling caching +To start caching your queries, head to the Settings section of the Admin Panel, and click on the `Caching` tab at the bottom of the side navigation. Then turn the caching toggle to `Enabled`. + +![Caching](images/caching.png) + +End-users will see a timestamp on cached questions in the top right of the question detail page showing the time when that question was last updated (i.e., the time when the current result was cached). Clicking on the `Refresh` button on a question page will manually rerun the query and override the cached result with the new result. + +### Caching settings +In Metabase, rather than setting cache settings manually on a per-query basis, we give you two parameters to set to automatically cache the results of long queries: the minimum average query duration, and the cache TTL multiplier. + +#### Minimum query duration +Your Metabase instance keeps track of the average query execution times of your queries, and it will cache the results of all saved questions with an average query execution time longer than the number you put in this box (in seconds). + +#### Cache Time-to-live (TTL) +Instead of setting an absolute number of minutes or seconds for a cached result to persist, Metabase lets you put in a multiplier to determine the cache's TTL. Each query's cache TTL is computed by multiplying its average execution time by the number you put in this box. So if you put in `10`, a query that takes 5 seconds on average to execute will have its cache last for 50 seconds; and a query that takes 10 minutes will have a cached result lasting 100 minutes. This way, each query's cache is proportional to its execution time. + +#### Max cache entry size +Lastly, you can set the maximum size of each question's cache in kilobytes, to prevent them from taking up too much space on your server. diff --git a/docs/administration-guide/images/caching.png b/docs/administration-guide/images/caching.png new file mode 100644 index 0000000000000..aa37220315bed Binary files /dev/null and b/docs/administration-guide/images/caching.png differ diff --git a/docs/administration-guide/start.md b/docs/administration-guide/start.md index 9466cc055dd76..9c8e21e123193 100644 --- a/docs/administration-guide/start.md +++ b/docs/administration-guide/start.md @@ -17,6 +17,7 @@ Are you in charge of managing Metabase for your organization? Then you're in the * [Creating a Getting Started Guide for your team](11-getting-started-guide.md) * [Sharing dashboards and questions with public links](12-public-links.md) * [Embedding Metabase in other Applications](13-embedding.md) +* [Caching query results](14-caching.md) First things first, you'll need to install Metabase. If you haven’t done that yet, our [Installation Guide](../operations-guide/start.md#installing-and-running-metabase) will help you through the process. diff --git a/docs/api-documentation.md b/docs/api-documentation.md index 72bf4b3df8400..65de1da123cf0 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -153,24 +153,15 @@ Run the query associated with a Card. * **`ignore_cache`** value may be nil, or if non-nil, value must be a boolean. -## `POST /api/card/:card-id/query/csv` +## `POST /api/card/:card-id/query/:export-format` -Run the query associated with a Card, and return its results as CSV. Note that this expects the parameters as serialized JSON in the 'parameters' parameter +Run the query associated with a Card, and return its results as a file in the specified format. Note that this expects the parameters as serialized JSON in the 'parameters' parameter ##### PARAMS: * **`card-id`** -* **`parameters`** value may be nil, or if non-nil, value must be a valid JSON string. - - -## `POST /api/card/:card-id/query/json` - -Run the query associated with a Card, and return its results as JSON. Note that this expects the parameters as serialized JSON in the 'parameters' parameter - -##### PARAMS: - -* **`card-id`** +* **`export-format`** value must be one of: `csv`, `json`, `xlsx`. * **`parameters`** value may be nil, or if non-nil, value must be a valid JSON string. @@ -224,7 +215,7 @@ Fetch a list of all Collections that the current user has read permissions for. ##### PARAMS: -* **`archived`** value may be nil, or if non-nil, value must be a valid boolean (true or false). +* **`archived`** value may be nil, or if non-nil, value must be a valid boolean string ('true' or 'false'). ## `GET /api/collection/:id` @@ -240,11 +231,15 @@ Fetch a specific (non-archived) Collection, including cards that belong to it. Fetch a graph of all Collection Permissions. +You must be a superuser to do this. + ## `POST /api/collection/` Create a new Collection. +You must be a superuser to do this. + ##### PARAMS: * **`name`** value must be a non-blank string. @@ -258,6 +253,8 @@ Create a new Collection. Modify an existing Collection, including archiving or unarchiving it. +You must be a superuser to do this. + ##### PARAMS: * **`id`** @@ -275,6 +272,8 @@ Modify an existing Collection, including archiving or unarchiving it. Do a batch update of Collections Permissions by passing in a modified graph. +You must be a superuser to do this. + ##### PARAMS: * **`body`** value must be a map. @@ -311,16 +310,26 @@ Remove a `DashboardCard` from a `Dashboard`. * **`dashcardId`** value must be a valid integer greater than zero. +## `DELETE /api/dashboard/:id/favorite` + +Unfavorite a Dashboard. + +##### PARAMS: + +* **`id`** + + ## `GET /api/dashboard/` Get `Dashboards`. With filter option `f` (default `all`), restrict results as follows: - * `all` - Return all `Dashboards`. - * `mine` - Return `Dashboards` created by the current user. + * `all` - Return all Dashboards. + * `mine` - Return Dashboards created by the current user. + * `archived` - Return Dashboards that have been archived. (By default, these are *excluded*.) ##### PARAMS: -* **`f`** value may be nil, or if non-nil, value must be one of: `all`, `mine`. +* **`f`** value may be nil, or if non-nil, value must be one of: `all`, `archived`, `mine`. ## `GET /api/dashboard/:id` @@ -398,6 +407,15 @@ Add a `Card` to a `Dashboard`. * **`dashboard-card`** +## `POST /api/dashboard/:id/favorite` + +Favorite a Dashboard. + +##### PARAMS: + +* **`id`** + + ## `POST /api/dashboard/:id/revert` Revert a `Dashboard` to a prior `Revision`. @@ -420,17 +438,19 @@ Update a `Dashboard`. * **`parameters`** value may be nil, or if non-nil, value must be an array. Each value must be a map. -* **`points_of_interest`** value may be nil, or if non-nil, value must be a non-blank string. +* **`points_of_interest`** value may be nil, or if non-nil, value must be a string. * **`description`** value may be nil, or if non-nil, value must be a string. -* **`show_in_getting_started`** value may be nil, or if non-nil, value must be a non-blank string. +* **`archived`** value may be nil, or if non-nil, value must be a boolean. + +* **`show_in_getting_started`** value may be nil, or if non-nil, value must be a boolean. * **`enable_embedding`** value may be nil, or if non-nil, value must be a boolean. * **`name`** value may be nil, or if non-nil, value must be a non-blank string. -* **`caveats`** value may be nil, or if non-nil, value must be a non-blank string. +* **`caveats`** value may be nil, or if non-nil, value must be a string. * **`dashboard`** @@ -438,16 +458,18 @@ Update a `Dashboard`. * **`id`** +* **`position`** value may be nil, or if non-nil, value must be an integer greater than zero. + ## `PUT /api/dashboard/:id/cards` Update `Cards` on a `Dashboard`. Request body should have the form: - {:cards [{:id ... - :sizeX ... - :sizeY ... - :row ... - :col ... + {:cards [{:id ... + :sizeX ... + :sizeY ... + :row ... + :col ... :series [{:id 123 ...}]} ...]} @@ -596,12 +618,14 @@ Execute a query and retrieve the results in the usual format. * **`database`** -## `POST /api/dataset/csv` +## `POST /api/dataset/:export-format` -Execute a query and download the result data as a CSV file. +Execute a query and download the result data as a file in the specified format. ##### PARAMS: +* **`export-format`** value must be one of: `csv`, `json`, `xlsx`. + * **`query`** value must be a valid JSON string. @@ -616,15 +640,6 @@ Get historical query execution duration. * **`query`** -## `POST /api/dataset/json` - -Execute a query and download the result data as a JSON file. - -##### PARAMS: - -* **`query`** value must be a valid JSON string. - - ## `POST /api/email/test` Send a test email. You must be a superuser to do this. @@ -674,26 +689,15 @@ Fetch the results of running a Card using a JSON Web Token signed with the `embe * **`query-params`** -## `GET /api/embed/card/:token/query/csv` +## `GET /api/embed/card/:token/query/:export-format` -Like `GET /api/embed/card/query`, but returns the results as CSV. +Like `GET /api/embed/card/query`, but returns the results as a file in the specified format. ##### PARAMS: * **`token`** -* **`&`** - -* **`query-params`** - - -## `GET /api/embed/card/:token/query/json` - -Like `GET /api/embed/card/query`, but returns the results as JSOn. - -##### PARAMS: - -* **`token`** +* **`export-format`** value must be one of: `csv`, `json`, `xlsx`. * **`&`** @@ -934,23 +938,13 @@ You must be a superuser to do this. ##### PARAMS: -* **`points_of_interest`** - -* **`description`** +* **`id`** * **`definition`** value must be a map. -* **`revision_message`** value must be a non-blank string. - -* **`show_in_getting_started`** - * **`name`** value must be a non-blank string. -* **`caveats`** - -* **`id`** - -* **`how_is_this_calculated`** +* **`revision_message`** value must be a non-blank string. ## `PUT /api/metric/:id/important_fields` @@ -1162,24 +1156,15 @@ Fetch a publically-accessible Card an return query results as well as `:card` in * **`parameters`** value may be nil, or if non-nil, value must be a valid JSON string. -## `GET /api/public/card/:uuid/query/csv` +## `GET /api/public/card/:uuid/query/:export-format` -Fetch a publically-accessible Card and return query results as CSV. Does not require auth credentials. Public sharing must be enabled. +Fetch a publically-accessible Card and return query results in the specified format. Does not require auth credentials. Public sharing must be enabled. ##### PARAMS: * **`uuid`** -* **`parameters`** value may be nil, or if non-nil, value must be a valid JSON string. - - -## `GET /api/public/card/:uuid/query/json` - -Fetch a publically-accessible Card and return query results as JSON. Does not require auth credentials. Public sharing must be enabled. - -##### PARAMS: - -* **`uuid`** +* **`export-format`** value must be one of: `csv`, `json`, `xlsx`. * **`parameters`** value may be nil, or if non-nil, value must be a valid JSON string. @@ -1429,14 +1414,6 @@ You must be a superuser to do this. * **`name`** value must be a non-blank string. -* **`description`** - -* **`caveats`** - -* **`points_of_interest`** - -* **`show_in_getting_started`** - * **`definition`** value must be a map. * **`revision_message`** value must be a non-blank string. @@ -1561,7 +1538,7 @@ Special endpoint for creating the first user during setup. * **`engine`** -* **`allow_tracking`** +* **`allow_tracking`** value may be nil, or if non-nil, value must satisfy one of the following requirements: 1) value must be a boolean. 2) value must be a valid boolean string ('true' or 'false'). * **`email`** value must be a valid email address. @@ -1649,7 +1626,7 @@ Get metadata about a `Table` useful for running queries. * **`id`** -* **`include_sensitive_fields`** value may be nil, or if non-nil, value must be a valid boolean (true or false). +* **`include_sensitive_fields`** value may be nil, or if non-nil, value must be a valid boolean string ('true' or 'false'). ## `PUT /api/table/:id` diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 5bc17af05e03b..603155e76a951 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -116,7 +116,11 @@ Run the linters and type checker with #### End-to-end tests -End-to-end tests are written with [webschauffeur](https://github.com/metabase/webchauffeur) which is a wrapper around [`selenium-webdriver`](https://www.npmjs.com/package/selenium-webdriver). +End-to-end tests are written with [webschauffeur](https://github.com/metabase/webchauffeur) which is a wrapper around [`selenium-webdriver`](https://www.npmjs.com/package/selenium-webdriver). + +Generate the Metabase jar file which is used in E2E tests: + + ./bin/build Run E2E tests once with @@ -178,7 +182,6 @@ Start up an instant cheatsheet for the project + dependencies by running lein instant-cheatsheet - ## License Copyright © 2016 Metabase, Inc diff --git a/docs/operations-guide/running-the-metabase-jar-file.md b/docs/operations-guide/running-the-metabase-jar-file.md index 2c08ebbea287d..005d7b9d0c07e 100644 --- a/docs/operations-guide/running-the-metabase-jar-file.md +++ b/docs/operations-guide/running-the-metabase-jar-file.md @@ -1,6 +1,6 @@ # Running the Metabase Jar File -To run the Metabase jar file you need to have Java installed on your system. Currently Metabase requires Java 6 or higher and will work on either the OpenJDK or Oracle JDK. Note that the Metabase team prefers to stick with open source solutions where possible, so we use the OpenJDK for our Metabase instances. +To run the Metabase jar file you need to have Java installed on your system. Currently Metabase requires Java 7 or higher and will work on either the OpenJDK or Oracle JDK. Note that the Metabase team prefers to stick with open source solutions where possible, so we use the OpenJDK for our Metabase instances. ### Download Metabase @@ -15,11 +15,11 @@ Before you can launch the application you must verify that you have Java install You should see output such as: - java version "1.60_65" - Java (TM) SE Runtime Environment (build 1.6.0_65-b14-466.1-11M4716) - Java HotSpot (TM) 64-Bit Server VM (build 20.65-b04-466.1, mixed mode) + java version "1.8.0_31" + Java(TM) SE Runtime Environment (build 1.8.0_31-b13) + Java HotSpot(TM) 64-Bit Server VM (build 25.31-b07, mixed mode) -If you did not see the output above and instead saw either an error or your Java version is less than 1.6, then you need to install the Java Runtime. +If you did not see the output above and instead saw either an error or your Java version is less than 1.7, then you need to install the Java Runtime. [OpenJDK Downloads](http://openjdk.java.net/install/) [Oracle's Java Downloads](http://www.oracle.com/technetwork/java/javase/downloads/index.html) diff --git a/docs/users-guide/02-database-basics.md b/docs/users-guide/02-database-basics.md index 015087a6c33c2..958cbc152bea9 100644 --- a/docs/users-guide/02-database-basics.md +++ b/docs/users-guide/02-database-basics.md @@ -74,4 +74,4 @@ To do this, we’d open up the Reservation table, add a filter to only look at r --- ## Next: Asking questions -Now that we have a shared vocabulary and a basic understanding of databases, let's learn more about [asking questions](03-asking-questions.md) +Now that we have a shared vocabulary and a basic understanding of databases, let's learn more about [exploring in Metabase](03-basic-exploration.md) diff --git a/docs/users-guide/03-basic-exploration.md b/docs/users-guide/03-basic-exploration.md new file mode 100644 index 0000000000000..98dbe34532d33 --- /dev/null +++ b/docs/users-guide/03-basic-exploration.md @@ -0,0 +1,47 @@ +### Exploring in Metabase +As long as you're not the very first user in your team's Metabase, the easiest way to get started is by exploring charts and dashboards that your teammates have already created. + +#### Exploring dashboards +Click on the `Dashboards` nav item to see all the dashboards your teammates have created. Dashboards are simply collections of charts and numbers that you want to be able to refer back to regularly. (You can learn more about dashboards [here](07-dashboards.md)) + +If you click on a part of a chart, such as a bar in a bar chart, or a dot on a line chart, you'll see a menu with actions you can take to dive deeper into that result, or to branch off from it in a different direction. + +![Drill through](images/drill-through/drill-through.png) + +In this example of pie orders by type over time, clicking on a dot on this line chart gives us the ability to: +- Zoom in — i.e., see just the banana cream pie orders in June 2017 over time +- View these Orders — which lets us see a list of banana cream pie orders in June 2017 +- Break out by a category — this lets us do things like see the banana cream pie orders in June 2017 broken out by the status of the customer (e.g., `new` or `VIP`, etc.) or other different aspects of the order. Different charts will have different break out options, such as Location and Time. + +Other charts as well as table cells will often also allow you to go to a filtered view of that chart or table. You can click on one of the inequality symbols to see that chart where, for example, the value of the Subtotal column is less than $100, or where the Purchased-at timestamp is greater than (i.e., after) April 1, 2017. + +![Inequality filters](images/drill-through/inequality-filters.png) + +Lastly, clicking on the ID of an item in table gives you the option to go to a detail view for that single record. (E.g., you can click on a customer's ID to see the profile view for that one customer.) + +**Note that charts created with SQL don't currently have these action options.** + +#### Exploring saved questions +In Metabase parlance, every chart on number on a dashboard is called a "question." Clicking on the title of a question on a dashboard will take you to a detail view of that question. You'll also end up at this detail view if you use one of the actions mentioned above. You can also browse all the questions your teammates have saved by clicking the `Questions` link in the main navigation. + +When you're viewing the detail view of a question, you can use all the same actions mentioned above. You can also click on the headings of tables to see more options, like viewing the sum of the values in a column, or finding the minimum or maximum value in it. + +![Heading actions](images/drill-through/heading-actions.png) + +Additionally, the question detail page has an Explore button in the bottom-right of the screen with options that change depending on the kind of question you're looking at. (Note that the Explore button disappears if your cursor stops moving.) + +![Action menu](images/drill-through/actions.png) + +Here's a list of all the actions: +* Table actions + - `Count of rows by time` lets you see how many rows there were in this table over time. + - `Summarize this segment` gives you options of various summarization functions (sum, average, maximum, etc.) you can use on this table to arrive at a number. +* Chart and pivot table actions + - `Break outs` will be listed depending on the question, and include the option to break out by a category, location, or time. For example, if you're looking at the count of total orders over time, you might be able to further break that out by customer gender, if that information is present. + - `View this as a table` does what it says. Every chart has a table behind it that is providing the data for the chart, and this action lets you see that table. + - `View the underlying records` shows you the un-summarized list of records underlying the chart or number you're currently viewing. + +--- + +## Next: Asking new questions +So what do you do if you can't find an existing dashboard or question that's exactly what you're looking for? Let's learn about [asking our own new questions](04-asking-questions.md) diff --git a/docs/users-guide/03-asking-questions.md b/docs/users-guide/04-asking-questions.md similarity index 99% rename from docs/users-guide/03-asking-questions.md rename to docs/users-guide/04-asking-questions.md index 26e61993e8268..767576f96741d 100644 --- a/docs/users-guide/03-asking-questions.md +++ b/docs/users-guide/04-asking-questions.md @@ -159,4 +159,4 @@ Questions asked using SQL can be saved, downloaded, or added to a dashboard just --- ## Next: Creating charts -Once you have an answer to your question, you can now learn more about [visualizing answers](04-visualizing-results.md). +Once you have an answer to your question, you can now learn more about [visualizing answers](05-visualizing-results.md). diff --git a/docs/users-guide/04-visualizing-results.md b/docs/users-guide/05-visualizing-results.md similarity index 99% rename from docs/users-guide/04-visualizing-results.md rename to docs/users-guide/05-visualizing-results.md index 39722528142ef..6b60d2b120a74 100644 --- a/docs/users-guide/04-visualizing-results.md +++ b/docs/users-guide/05-visualizing-results.md @@ -100,4 +100,4 @@ Metabase now also allows administrators to add custom region maps via GeoJSON fi --- ## Next: Sharing and organizing questions -Now let's learn about [sharing and organizing your saved questions](05-sharing-answers.md). +Now let's learn about [sharing and organizing your saved questions](06-sharing-answers.md). diff --git a/docs/users-guide/05-sharing-answers.md b/docs/users-guide/06-sharing-answers.md similarity index 99% rename from docs/users-guide/05-sharing-answers.md rename to docs/users-guide/06-sharing-answers.md index 21586407df429..ddb895afd2226 100644 --- a/docs/users-guide/05-sharing-answers.md +++ b/docs/users-guide/06-sharing-answers.md @@ -51,4 +51,4 @@ Clicking on the icon to the left of questions let's you select several at once s --- ## Next: creating dashboards -Next, we'll learn about [creating dashboards and adding questions to them](06-dashboards.md). +Next, we'll learn about [creating dashboards and adding questions to them](07-dashboards.md). diff --git a/docs/users-guide/06-dashboards.md b/docs/users-guide/07-dashboards.md similarity index 82% rename from docs/users-guide/06-dashboards.md rename to docs/users-guide/07-dashboards.md index 970bb1f4ba538..8a2b19e5cbaec 100644 --- a/docs/users-guide/06-dashboards.md +++ b/docs/users-guide/07-dashboards.md @@ -10,7 +10,7 @@ Have a few key performance indicators that you want to be able to easily check? You can make as many dashboards as you want. Go nuts. ### How to create a dashboard -Once you have a question saved, you can create a dashboard. Click the **Dashboards** dropdown at the top of the screen, then **Create a new dashboard**. Give your new dashboard a name and a description, then click **Create**, and you’ll be taken to your shiny new dashboard. You can always get to your dashboards from the dropdown at the very top of the screen. +Once you have a question saved, you can create a dashboard. Click the **Dashboards** link at the top of the screen, then click the plus icon in the top-right to create a new dashboard. Give your new dashboard a name and a description, then click **Create**, and you’ll be taken to your shiny new dashboard. ![Create Dashboard](images/dashboards/DashboardCreate.png) @@ -34,11 +34,17 @@ Once you're in edit mode you'll see a grid appear. You can move and resize the c Questions in your dashboard will automatically update their display based on the size you choose to make sure your data looks great at any size. +### Archiving a dashboard +Archiving a dashboard does not archive the individual saved questions on it — it just archives the dashboard. To archive a dashboard while viewing it, click the pencil icon to enter edit mode, then click the Archive button. -### Deleting a dashboard -Deleting a dashboard does not delete the individual saved questions on it — it just deletes the dashboard. Remember — dashboards are shared by everyone on your team, so think twice before you delete something that someone else might be using! +You can view all of your archived dashboards by clicking the box icon in the top-right of the Dashboards page. Archived dashboards in this list can be unarchived by clicking the icon of the box with the upward arrow next to that dashboard. -To delete a dashboard, click the pencil-looking **Edit** icon in the top right of the dashboard, then click **Delete**. +(Note: as of Metabase v0.24, dashboards can no longer be permanently deleted; only archived.) + +### Finding dashboards +After a while, your team might have a lot of dashboards. To make it a little easier to find dashboards that you look at often, you can mark a dashboard as a favorite by clicking the star icon on it from the dashboards list. You can use the filter dropdown in the top of the list to view only your favorite dashboards, or only the ones that you created yourself. + +![Filter list](images/dashboards/FilterDashboards.png) ### Fullscreen dashboards @@ -90,4 +96,4 @@ Some tips: --- ## Next: Adding dashboard filters -Make your dashboards more flexible and powerful by [adding dashboard filters](07-dashboard-filters.md). +Make your dashboards more flexible and powerful by [adding dashboard filters](08-dashboard-filters.md). diff --git a/docs/users-guide/07-dashboard-filters.md b/docs/users-guide/08-dashboard-filters.md similarity index 99% rename from docs/users-guide/07-dashboard-filters.md rename to docs/users-guide/08-dashboard-filters.md index fde813da6856b..9b4d234e590ee 100644 --- a/docs/users-guide/07-dashboard-filters.md +++ b/docs/users-guide/08-dashboard-filters.md @@ -74,4 +74,4 @@ Here are a few tips to get the most out of dashboard filters: --- ## Next: Charts with multiple series -We'll learn how to [create charts with multiple lines, bars, and more](08-multi-series-charting.md) next. +We'll learn how to [create charts with multiple lines, bars, and more](09-multi-series-charting.md) next. diff --git a/docs/users-guide/08-multi-series-charting.md b/docs/users-guide/09-multi-series-charting.md similarity index 98% rename from docs/users-guide/08-multi-series-charting.md rename to docs/users-guide/09-multi-series-charting.md index 2ca5cbee01924..bec40797ed172 100644 --- a/docs/users-guide/08-multi-series-charting.md +++ b/docs/users-guide/09-multi-series-charting.md @@ -78,4 +78,4 @@ Go forth and start letting your data get to know each other. ## Next: Getting reports with Pulses -Pulses let you send out a group of saved questions on a schedule via email or Slack. [Get started with Pulses](09-pulses.md). +Pulses let you send out a group of saved questions on a schedule via email or Slack. [Get started with Pulses](10-pulses.md). diff --git a/docs/users-guide/09-pulses.md b/docs/users-guide/10-pulses.md similarity index 98% rename from docs/users-guide/09-pulses.md rename to docs/users-guide/10-pulses.md index 96e68e3051af9..6786ee21b28e3 100644 --- a/docs/users-guide/09-pulses.md +++ b/docs/users-guide/10-pulses.md @@ -50,4 +50,4 @@ If you want to delete a pulse, you can find that option at the bottom of the edi ## Next: Connecting Metabase to Slack with Metabot 🤖 -If your team uses Slack to communicate, you can [use Metabot](10-metabot.md) to display your saved questions directly within Slack whenever you want. +If your team uses Slack to communicate, you can [use Metabot](11-metabot.md) to display your saved questions directly within Slack whenever you want. diff --git a/docs/users-guide/10-metabot.md b/docs/users-guide/11-metabot.md similarity index 97% rename from docs/users-guide/10-metabot.md rename to docs/users-guide/11-metabot.md index bf1b871eef447..c7fd2b2608d42 100644 --- a/docs/users-guide/10-metabot.md +++ b/docs/users-guide/11-metabot.md @@ -48,4 +48,4 @@ If you don’t have a sense of which questions you want to view in Slack, you c ## Next: -Sometimes you’ll need help understanding what data is available to you and what it means. Metabase provides a way for your administrators and data experts to build a [data model reference](11-data-model-reference.md) to help you make sense of your data. +Sometimes you’ll need help understanding what data is available to you and what it means. Metabase provides a way for your administrators and data experts to build a [data model reference](12-data-model-reference.md) to help you make sense of your data. diff --git a/docs/users-guide/11-data-model-reference.md b/docs/users-guide/12-data-model-reference.md similarity index 100% rename from docs/users-guide/11-data-model-reference.md rename to docs/users-guide/12-data-model-reference.md diff --git a/docs/users-guide/12-sql-parameters.md b/docs/users-guide/13-sql-parameters.md similarity index 100% rename from docs/users-guide/12-sql-parameters.md rename to docs/users-guide/13-sql-parameters.md diff --git a/docs/users-guide/images/dashboards/DashboardEdit.png b/docs/users-guide/images/dashboards/DashboardEdit.png index 827a25c6775a3..1c1601cac0c38 100644 Binary files a/docs/users-guide/images/dashboards/DashboardEdit.png and b/docs/users-guide/images/dashboards/DashboardEdit.png differ diff --git a/docs/users-guide/images/dashboards/FilterDashboards.png b/docs/users-guide/images/dashboards/FilterDashboards.png new file mode 100644 index 0000000000000..256a90cbd5b80 Binary files /dev/null and b/docs/users-guide/images/dashboards/FilterDashboards.png differ diff --git a/docs/users-guide/images/drill-through/actions.png b/docs/users-guide/images/drill-through/actions.png new file mode 100644 index 0000000000000..feb04e38be96d Binary files /dev/null and b/docs/users-guide/images/drill-through/actions.png differ diff --git a/docs/users-guide/images/drill-through/drill-through.png b/docs/users-guide/images/drill-through/drill-through.png new file mode 100644 index 0000000000000..4df6668bcbea1 Binary files /dev/null and b/docs/users-guide/images/drill-through/drill-through.png differ diff --git a/docs/users-guide/images/drill-through/heading-actions.png b/docs/users-guide/images/drill-through/heading-actions.png new file mode 100644 index 0000000000000..f0817602da13e Binary files /dev/null and b/docs/users-guide/images/drill-through/heading-actions.png differ diff --git a/docs/users-guide/images/drill-through/inequality-filters.png b/docs/users-guide/images/drill-through/inequality-filters.png new file mode 100644 index 0000000000000..6fffc3db838c8 Binary files /dev/null and b/docs/users-guide/images/drill-through/inequality-filters.png differ diff --git a/docs/users-guide/start.md b/docs/users-guide/start.md index 164823359033c..402aa340b35e2 100644 --- a/docs/users-guide/start.md +++ b/docs/users-guide/start.md @@ -4,15 +4,16 @@ * [What Metabase does](01-what-is-metabase.md) * [The basics of database terminology](02-database-basics.md) -* [Asking questions in Metabase](03-asking-questions.md) -* [How to visualize the answers to questions](04-visualizing-results.md) -* [Sharing and organizing your saved questions](05-sharing-answers.md) -* [Creating dashboards](06-dashboards.md) -* [Adding filters to dashboards](07-dashboard-filters.md) -* [Creating charts with multiple series](08-multi-series-charting.md) -* [Using Pulses for daily emails](09-pulses.md) -* [Get answers in Slack with Metabot](10-metabot.md) -* [Some helpful tips on building your data model](11-data-model-reference.md) -* [Creating SQL Templates](12-sql-parameters.md) +* [Basic exploration in Metabase](03-basic-exploration.md) +* [Asking questions in Metabase](04-asking-questions.md) +* [How to visualize the answers to questions](05-visualizing-results.md) +* [Sharing and organizing your saved questions](06-sharing-answers.md) +* [Creating dashboards](07-dashboards.md) +* [Adding filters to dashboards](08-dashboard-filters.md) +* [Creating charts with multiple series](09-multi-series-charting.md) +* [Using Pulses for daily emails](10-pulses.md) +* [Get answers in Slack with Metabot](11-metabot.md) +* [Some helpful tips on building your data model](12-data-model-reference.md) +* [Creating SQL Templates](13-sql-parameters.md) Let's get started with an overview of [What Metabase does](01-what-is-metabase.md). diff --git a/frontend/interfaces/underscore.js b/frontend/interfaces/underscore.js index 00fc5ce0ede50..3590308a6a62e 100644 --- a/frontend/interfaces/underscore.js +++ b/frontend/interfaces/underscore.js @@ -60,4 +60,9 @@ declare module "underscore" { // TODO: improve this declare function chain(obj: S): any; + + declare function constant(obj: S): () => S; + + declare function isMatch(object: Object, properties: Object): boolean; + declare function identity(o: T): T; } diff --git a/frontend/src/metabase/admin/datamodel/datamodel.js b/frontend/src/metabase/admin/datamodel/datamodel.js index 0dafaacccd0e8..43f3576c3e8e2 100644 --- a/frontend/src/metabase/admin/datamodel/datamodel.js +++ b/frontend/src/metabase/admin/datamodel/datamodel.js @@ -124,7 +124,7 @@ export const updateField = createThunkAction(UPDATE_FIELD, function(field) { try { // make sure we don't send all the computed metadata let slimField = { ...field }; - slimField = _.omit(slimField, "operators_lookup", "valid_operators", "values"); + slimField = _.omit(slimField, "operators_lookup", "operators", "values"); // update the field let updatedField = await MetabaseApi.field_update(slimField); diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx index a35dcf3310070..04de3ec712f7b 100644 --- a/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx +++ b/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx @@ -20,6 +20,7 @@ const PermissionsEditor = ({ title = "Permissions", modal, admin, grid, onUpdate action={onSave} content={} triggerClasses={cx({ disabled: !isDirty })} + key="save" > ; @@ -29,11 +30,12 @@ const PermissionsEditor = ({ title = "Permissions", modal, admin, grid, onUpdate title="Discard changes?" action={onCancel} content="No changes to permissions will be made." + key="discard" > : - ; + ; return ( diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx index 5e9dfe6bf15f3..a2e242a2db27a 100644 --- a/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx +++ b/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx @@ -219,7 +219,7 @@ const AccessOptionList = ({ value, options, onChange }) => )} -const EntityRowHeader = ({ entity, type }) => +const EntityRowHeader = ({ entity, icon }) =>
}} >
- +

{entity.name}

{ entity.subtitle && @@ -292,7 +292,7 @@ const PermissionsGrid = ({ className, grid, onUpdatePermission, entityId, groupI } renderRowHeader={({ rowIndex }) => state.admin.permissions.permissions; @@ -33,7 +34,7 @@ const getOriginalPermissions = (state) => state.admin.permissions.originalPermis const getDatabaseId = (state, props) => props.params.databaseId ? parseInt(props.params.databaseId) : null const getSchemaName = (state, props) => props.params.schemaName -const getMetadata = createSelector( +const getMeta = createSelector( [(state) => state.admin.permissions.databases], (databases) => databases && new Metadata(databases) ); @@ -135,6 +136,36 @@ function getRawQueryWarningModal(permissions, groupId, entityId, value) { } } +// If the user is revoking an access to every single table of a database for a specific user group, +// warn the user that the access to raw queries will be revoked as well. +// This warning will only be shown if the user is editing the permissions of individual tables. +function getRevokingAccessToAllTablesWarningModal(database, permissions, groupId, entityId, value) { + if (value === "none" && + getSchemasPermission(permissions, groupId, entityId) === "controlled" && + getNativePermission(permissions, groupId, entityId) !== "none" + ) { + // allTableEntityIds contains tables from all schemas + const allTableEntityIds = database.tables().map((table) => ({ + databaseId: table.db_id, + schemaName: table.schema, + tableId: table.id + })); + + // Show the warning only if user tries to revoke access to the very last table of all schemas + const afterChangesNoAccessToAnyTable = _.every(allTableEntityIds, (id) => + getFieldsPermission(permissions, groupId, id) === "none" || _.isEqual(id, entityId) + ); + if (afterChangesNoAccessToAnyTable) { + return { + title: "Revoke access to all tables?", + message: "This will also revoke this group's access to raw queries for this database.", + confirmButtonText: "Revoke access", + cancelButtonText: "Cancel" + }; + } + } +} + const OPTION_GREEN = { icon: "check", iconColor: "#9CC177", @@ -204,7 +235,7 @@ const OPTION_COLLECTION_READ = { }; export const getTablesPermissionsGrid = createSelector( - getMetadata, getGroups, getPermissions, getDatabaseId, getSchemaName, + getMeta, getGroups, getPermissions, getDatabaseId, getSchemaName, (metadata: Metadata, groups: Array, permissions: GroupsPermissions, databaseId: DatabaseId, schemaName: SchemaName) => { const database = metadata && metadata.database(databaseId); @@ -217,6 +248,7 @@ export const getTablesPermissionsGrid = createSelector( return { type: "table", + icon: "table", crumbs: database.schemaNames().length > 1 ? [ ["Databases", "/admin/permissions/databases"], [database.name, "/admin/permissions/databases/"+database.id+"/schemas"], @@ -237,12 +269,14 @@ export const getTablesPermissionsGrid = createSelector( }, updater(groupId, entityId, value) { MetabaseAnalytics.trackEvent("Permissions", "fields", value); - return updateFieldsPermission(permissions, groupId, entityId, value, metadata); + let updatedPermissions = updateFieldsPermission(permissions, groupId, entityId, value, metadata); + return inferAndUpdateEntityPermissions(updatedPermissions, groupId, entityId, metadata); }, confirm(groupId, entityId, value) { return [ getPermissionWarningModal(getFieldsPermission, "fields", defaultGroup, permissions, groupId, entityId, value), - getControlledDatabaseWarningModal(permissions, groupId, entityId) + getControlledDatabaseWarningModal(permissions, groupId, entityId), + getRevokingAccessToAllTablesWarningModal(database, permissions, groupId, entityId, value) ]; }, warning(groupId, entityId) { @@ -264,7 +298,7 @@ export const getTablesPermissionsGrid = createSelector( ); export const getSchemasPermissionsGrid = createSelector( - getMetadata, getGroups, getPermissions, getDatabaseId, + getMeta, getGroups, getPermissions, getDatabaseId, (metadata: Metadata, groups: Array, permissions: GroupsPermissions, databaseId: DatabaseId) => { const database = metadata && metadata.database(databaseId); @@ -277,14 +311,15 @@ export const getSchemasPermissionsGrid = createSelector( return { type: "schema", + icon: "folder", crumbs: [ ["Databases", "/admin/permissions/databases"], [database.name], ], groups, permissions: { - header: "Data Access", "tables": { + header: "Data Access", options(groupId, entityId) { return [OPTION_ALL, OPTION_CONTROLLED, OPTION_NONE] }, @@ -293,7 +328,8 @@ export const getSchemasPermissionsGrid = createSelector( }, updater(groupId, entityId, value) { MetabaseAnalytics.trackEvent("Permissions", "tables", value); - return updateTablesPermission(permissions, groupId, entityId, value, metadata); + let updatedPermissions = updateTablesPermission(permissions, groupId, entityId, value, metadata); + return inferAndUpdateEntityPermissions(updatedPermissions, groupId, entityId, metadata); }, postAction(groupId, { databaseId, schemaName }, value) { if (value === "controlled") { @@ -324,7 +360,7 @@ export const getSchemasPermissionsGrid = createSelector( ); export const getDatabasesPermissionsGrid = createSelector( - getMetadata, getGroups, getPermissions, + getMeta, getGroups, getPermissions, (metadata: Metadata, groups: Array, permissions: GroupsPermissions) => { if (!groups || !permissions || !metadata) { return null; @@ -335,6 +371,7 @@ export const getDatabasesPermissionsGrid = createSelector( return { type: "database", + icon: "database", groups, permissions: { "schemas": { @@ -364,7 +401,7 @@ export const getDatabasesPermissionsGrid = createSelector( }, confirm(groupId, entityId, value) { return [ - getPermissionWarningModal(getSchemasPermission, "schemas", defaultGroup, permissions, groupId, entityId, value) + getPermissionWarningModal(getSchemasPermission, "schemas", defaultGroup, permissions, groupId, entityId, value), ]; }, warning(groupId, entityId) { @@ -433,6 +470,7 @@ export const getCollectionsPermissionsGrid = createSelector( return { type: "collection", + icon: "collection", groups, permissions: { "access": { @@ -469,7 +507,7 @@ export const getCollectionsPermissionsGrid = createSelector( export const getDiff = createSelector( - getMetadata, getGroups, getPermissions, getOriginalPermissions, + getMeta, getGroups, getPermissions, getOriginalPermissions, (metadata: Metadata, groups: Array, permissions: GroupsPermissions, originalPermissions: GroupsPermissions) => diffPermissions(permissions, originalPermissions, groups, metadata) ); diff --git a/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx b/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx index 83e121977c463..e00c797bb5f12 100644 --- a/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx +++ b/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx @@ -15,10 +15,11 @@ import SettingsSetupList from "../components/SettingsSetupList.jsx"; import SettingsUpdatesForm from "../components/SettingsUpdatesForm.jsx"; import SettingsSingleSignOnForm from "../components/SettingsSingleSignOnForm.jsx"; +import { prepareAnalyticsValue } from 'metabase/admin/settings/utils' + import _ from "underscore"; import cx from 'classnames'; - import { getSettings, getSettingValues, @@ -82,8 +83,16 @@ export default class SettingsEditorApp extends Component { } this.refs.layout.setSaved(); - let val = (setting.key === "report-timezone" || setting.type === "boolean") ? setting.value : "success"; - MetabaseAnalytics.trackEvent("General Settings", setting.display_name || setting.key, val); + + const value = prepareAnalyticsValue(setting); + + MetabaseAnalytics.trackEvent( + "General Settings", + setting.display_name || setting.key, + value, + // pass the actual value if it's a number + typeof(value) === 'number' && value + ); } catch (error) { let message = error && (error.message || (error.data && error.data.message)); this.refs.layout.setSaveError(message); diff --git a/frontend/src/metabase/admin/settings/selectors.js b/frontend/src/metabase/admin/settings/selectors.js index 1303d13df831c..0434679965a13 100644 --- a/frontend/src/metabase/admin/settings/selectors.js +++ b/frontend/src/metabase/admin/settings/selectors.js @@ -47,7 +47,8 @@ const SECTIONS = [ ...MetabaseSettings.get('timezones') ], placeholder: "Select a timezone", - note: "Not all databases support timezones, in which case this setting won't take effect." + note: "Not all databases support timezones, in which case this setting won't take effect.", + allowValueCollection: true }, { key: "anon-tracking-enabled", @@ -249,19 +250,22 @@ const SECTIONS = [ key: "query-caching-min-ttl", display_name: "Minimum Query Duration", type: "number", - getHidden: (settings) => !settings["enable-query-caching"] + getHidden: (settings) => !settings["enable-query-caching"], + allowValueCollection: true }, { key: "query-caching-ttl-ratio", - display_name: "Cache Time-To-Live (TTL)", + display_name: "Cache Time-To-Live (TTL) multiplier", type: "number", - getHidden: (settings) => !settings["enable-query-caching"] + getHidden: (settings) => !settings["enable-query-caching"], + allowValueCollection: true }, { key: "query-caching-max-kb", display_name: "Max Cache Entry Size", type: "number", - getHidden: (settings) => !settings["enable-query-caching"] + getHidden: (settings) => !settings["enable-query-caching"], + allowValueCollection: true } ] } diff --git a/frontend/src/metabase/admin/settings/utils.js b/frontend/src/metabase/admin/settings/utils.js new file mode 100644 index 0000000000000..7d1979e37c258 --- /dev/null +++ b/frontend/src/metabase/admin/settings/utils.js @@ -0,0 +1,6 @@ +// in order to prevent collection of identifying information only fields +// that are explicitly marked as collectable or booleans should show the true value +export const prepareAnalyticsValue = (setting) => + (setting.allowValueCollection || setting.type === "boolean") + ? setting.value + : "success" diff --git a/frontend/src/metabase/admin/settings/utils.spec.js b/frontend/src/metabase/admin/settings/utils.spec.js new file mode 100644 index 0000000000000..a58c32653c884 --- /dev/null +++ b/frontend/src/metabase/admin/settings/utils.spec.js @@ -0,0 +1,20 @@ +import { prepareAnalyticsValue } from './utils' + +describe('prepareAnalyticsValue', () => { + const defaultSetting = { value: 120, type: 'number' } + + const checkResult = (setting = defaultSetting, expected = "success") => + expect(prepareAnalyticsValue(setting)).toEqual(expected) + + it('should return a non identifying value by default ', () => { + checkResult() + }) + + it('should return the value of a setting marked collectable', () => { + checkResult({ ...defaultSetting, allowValueCollection: true }, defaultSetting.value) + }) + + it('should return the value of a setting with a type of "boolean" collectable', () => { + checkResult({ ...defaultSetting, type: 'boolean'}, defaultSetting.value) + }) +}) diff --git a/frontend/src/metabase/questions/components/ArchivedItem.jsx b/frontend/src/metabase/components/ArchivedItem.jsx similarity index 100% rename from frontend/src/metabase/questions/components/ArchivedItem.jsx rename to frontend/src/metabase/components/ArchivedItem.jsx diff --git a/frontend/src/metabase/components/DatabaseDetailsForm.jsx b/frontend/src/metabase/components/DatabaseDetailsForm.jsx index 38e331696a3da..3adc7254bd39e 100644 --- a/frontend/src/metabase/components/DatabaseDetailsForm.jsx +++ b/frontend/src/metabase/components/DatabaseDetailsForm.jsx @@ -15,6 +15,7 @@ function isEmpty(str) { const AUTH_URL_PREFIXES = { bigquery: 'https://accounts.google.com/o/oauth2/auth?redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=https://www.googleapis.com/auth/bigquery&client_id=', + bigquery_with_drive: 'https://accounts.google.com/o/oauth2/auth?redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=https://www.googleapis.com/auth/bigquery%20https://www.googleapis.com/auth/drive&client_id=', googleanalytics: 'https://accounts.google.com/o/oauth2/auth?access_type=offline&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=https://www.googleapis.com/auth/analytics.readonly&client_id=', }; @@ -164,7 +165,7 @@ export default class DatabaseDetailsForm extends Component {
Some database installations can only be accessed by connecting through an SSH bastion host. This option also provides an extra layer of security when a VPN is not available. - Enabling this is usually slower than a dirrect connection. + Enabling this is usually slower than a direct connection.
@@ -222,7 +223,10 @@ export default class DatabaseDetailsForm extends Component { authURLLink = (
- Click here to get an auth code 😋 + Click here to get an auth code + { engine === "bigquery" && + (or with Google Drive permissions) + }
); } diff --git a/frontend/src/metabase/components/EmptyState.jsx b/frontend/src/metabase/components/EmptyState.jsx index 2aed7096b7a40..c55d38c4ea108 100644 --- a/frontend/src/metabase/components/EmptyState.jsx +++ b/frontend/src/metabase/components/EmptyState.jsx @@ -15,6 +15,7 @@ type EmptyStateProps = { title?: string, icon?: string, image?: string, + imageHeight?: string, // for reducing ui flickering when the image is loading imageClassName?: string, action?: string, link?: string, @@ -22,7 +23,7 @@ type EmptyStateProps = { smallDescription?: boolean } -const EmptyState = ({title, message, icon, image, imageClassName, action, link, onActionClick, smallDescription = false}: EmptyStateProps) => +const EmptyState = ({title, message, icon, image, imageHeight, imageClassName, action, link, onActionClick, smallDescription = false}: EmptyStateProps) =>
{ title &&

{title}

@@ -31,7 +32,7 @@ const EmptyState = ({title, message, icon, image, imageClassName, action, link, } { image && - {message} }
diff --git a/frontend/src/metabase/components/Header.jsx b/frontend/src/metabase/components/Header.jsx index f8948586f987b..626a995e9ffed 100644 --- a/frontend/src/metabase/components/Header.jsx +++ b/frontend/src/metabase/components/Header.jsx @@ -25,16 +25,24 @@ export default class Header extends Component { }; } - componentDidMount() { - this.componentDidUpdate(); + componentDidMount() { + this.updateHeaderHeight(); + } + + componentWillUpdate() { + const modalIsOpen = !!this.props.headerModalMessage; + if (modalIsOpen) { + this.updateHeaderHeight() + } } - componentDidUpdate() { - if (this.refs.header) { - const rect = ReactDOM.findDOMNode(this.refs.header).getBoundingClientRect(); - const headerHeight = rect.top + getScrollY(); - if (this.state.headerHeight !== headerHeight) { - this.setState({ headerHeight }); - } + + updateHeaderHeight() { + if (!this.refs.header) return; + + const rect = ReactDOM.findDOMNode(this.refs.header).getBoundingClientRect(); + const headerHeight = rect.top + getScrollY(); + if (this.state.headerHeight !== headerHeight) { + this.setState({ headerHeight }); } } diff --git a/frontend/src/metabase/components/Icon.jsx b/frontend/src/metabase/components/Icon.jsx index ff6078af9f0ff..ec82633b49a6b 100644 --- a/frontend/src/metabase/components/Icon.jsx +++ b/frontend/src/metabase/components/Icon.jsx @@ -1,8 +1,8 @@ /*eslint-disable react/no-danger */ import React, { Component } from "react"; -import PropTypes from "prop-types"; import RetinaImage from "react-retina-image"; +import cx from "classnames"; import { loadIcon } from 'metabase/icon_paths'; @@ -10,16 +10,14 @@ import Tooltipify from "metabase/hoc/Tooltipify"; @Tooltipify export default class Icon extends Component { - static propTypes = { - name: PropTypes.string.isRequired, - width: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number, - ]), - height: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number, - ]), + static props: { + name: string, + size?: string | number, + width?: string | number, + height?: string | number, + scale?: string | number, + tooltip?: string, // using Tooltipify + className?: string } render() { @@ -27,8 +25,8 @@ export default class Icon extends Component { if (!icon) { return null; } - - const props = { ...icon.attrs, ...this.props }; + const className = cx(icon.attrs && icon.attrs.className, this.props.className) + const props = { ...icon.attrs, ...this.props, className }; for (const prop of ["width", "height", "size", "scale"]) { if (typeof props[prop] === "string") { props[prop] = parseInt(props[prop], 10); diff --git a/frontend/src/metabase/components/FilterWidget.jsx b/frontend/src/metabase/components/ListFilterWidget.jsx similarity index 90% rename from frontend/src/metabase/components/FilterWidget.jsx rename to frontend/src/metabase/components/ListFilterWidget.jsx index e8d58f7dc76ee..83f5f789968f4 100644 --- a/frontend/src/metabase/components/FilterWidget.jsx +++ b/frontend/src/metabase/components/ListFilterWidget.jsx @@ -7,17 +7,17 @@ import React, { Component } from "react"; import Icon from "metabase/components/Icon"; import PopoverWithTrigger from "./PopoverWithTrigger"; -type FilterWidgetItem = { +export type ListFilterWidgetItem = { id: string, name: string, icon: string } -export default class FilterWidget extends Component { +export default class ListFilterWidget extends Component { props: { - items: FilterWidgetItem[], - activeItem: FilterWidgetItem, - onChange: (FilterWidgetItem) => void + items: ListFilterWidgetItem[], + activeItem: ListFilterWidgetItem, + onChange: (ListFilterWidgetItem) => void }; popoverRef: PopoverWithTrigger; diff --git a/frontend/src/metabase/components/Modal.jsx b/frontend/src/metabase/components/Modal.jsx index 69a77183c8770..662fd9fdc3791 100644 --- a/frontend/src/metabase/components/Modal.jsx +++ b/frontend/src/metabase/components/Modal.jsx @@ -13,11 +13,6 @@ import ModalContent from "./ModalContent"; import _ from "underscore"; -export const MODAL_CHILD_CONTEXT_TYPES = { - fullPageModal: PropTypes.bool, - formModal: PropTypes.bool -}; - function getModalContent(props) { if (React.Children.count(props.children) > 1 || props.title != null || props.footer != null @@ -38,15 +33,6 @@ export class WindowModal extends Component { backdropClassName: "Modal-backdrop" }; - static childContextTypes = MODAL_CHILD_CONTEXT_TYPES; - - getChildContext() { - return { - fullPageModal: false, - formModal: !!this.props.form - }; - } - componentWillMount() { this._modalElement = document.createElement('span'); this._modalElement.className = 'ModalContainer'; @@ -79,7 +65,11 @@ export class WindowModal extends Component { return (
- {getModalContent(this.props)} + { getModalContent({ + ...this.props, + fullPageModal: false, + formModal: !!this.props.form + }) }
); @@ -107,15 +97,6 @@ export class WindowModal extends Component { import routeless from "metabase/hoc/Routeless"; export class FullPageModal extends Component { - static childContextTypes = MODAL_CHILD_CONTEXT_TYPES; - - getChildContext() { - return { - fullPageModal: true, - formModal: !!this.props.form - }; - } - componentDidMount() { this._modalElement = document.createElement("div"); this._modalElement.className = "Modal--full"; @@ -165,7 +146,11 @@ export class FullPageModal extends Component { }> { motionStyle =>
- { getModalContent(this.props) } + { getModalContent({ + ...this.props, + fullPageModal: true, + formModal: !!this.props.form + }) }
} diff --git a/frontend/src/metabase/components/ModalContent.jsx b/frontend/src/metabase/components/ModalContent.jsx index 59b4b43f68af5..85e133f8f6e33 100644 --- a/frontend/src/metabase/components/ModalContent.jsx +++ b/frontend/src/metabase/components/ModalContent.jsx @@ -1,27 +1,24 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; +import cx from "classnames"; -import { MODAL_CHILD_CONTEXT_TYPES } from "./Modal"; import Icon from "metabase/components/Icon.jsx"; -import cx from "classnames"; - export default class ModalContent extends Component { static propTypes = { id: PropTypes.string, title: PropTypes.string, - onClose: PropTypes.func.isRequired + onClose: PropTypes.func.isRequired, + fullPageModal: PropTypes.bool, + formModal: PropTypes.bool }; static defaultProps = { }; - static contextTypes = MODAL_CHILD_CONTEXT_TYPES; - render() { - const { title, footer, onClose, children, className } = this.props; + const { title, footer, onClose, children, className, fullPageModal, formModal } = this.props; - const { fullPageModal, formModal } = this.context; return (
} { title && - + {title} } - + {children} { footer && - + {footer} } @@ -55,14 +52,13 @@ export default class ModalContent extends Component { const FORM_WIDTH = 500 + 32 * 2; // includes padding -export const ModalHeader = ({ children }, { fullPageModal, formModal }) => +export const ModalHeader = ({ children, fullPageModal, formModal }) =>

{children}

-ModalHeader.contextTypes = MODAL_CHILD_CONTEXT_TYPES; -export const ModalBody = ({ children }, { fullPageModal, formModal }) => +export const ModalBody = ({ children, fullPageModal, formModal }) =>
@@ -74,9 +70,8 @@ export const ModalBody = ({ children }, { fullPageModal, formModal }) =>
-ModalBody.contextTypes = MODAL_CHILD_CONTEXT_TYPES; -export const ModalFooter = ({ children }, { fullPageModal, formModal }) => +export const ModalFooter = ({ children, fullPageModal, formModal }) =>
-ModalFooter.contextTypes = MODAL_CHILD_CONTEXT_TYPES; diff --git a/frontend/src/metabase/components/Popover.jsx b/frontend/src/metabase/components/Popover.jsx index cd72579ac1558..eb8a8e74ceef6 100644 --- a/frontend/src/metabase/components/Popover.jsx +++ b/frontend/src/metabase/components/Popover.jsx @@ -29,6 +29,7 @@ export default class Popover extends Component { // target: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), tetherOptions: PropTypes.object, sizeToFit: PropTypes.bool, + pinInitialAttachment: PropTypes.bool, }; static defaultProps = { @@ -206,39 +207,43 @@ export default class Popover extends Component { ...this.props.tetherOptions }); } else { - let best = { - attachmentX: "center", - attachmentY: "top", - targetAttachmentX: "center", - targetAttachmentY: "bottom", - offsetX: 0, - offsetY: 0 - }; - - // horizontal - best = this._getBestAttachmentOptions( - tetherOptions, best, this.props.horizontalAttachments, ["left", "right"], - (best, attachmentX) => ({ - ...best, - attachmentX: attachmentX, + if (!this._best || !this.props.pinInitialAttachment) { + let best = { + attachmentX: "center", + attachmentY: "top", targetAttachmentX: "center", - offsetX: ({ "center": 0, "left": -(this.props.targetOffsetX), "right": this.props.targetOffsetX })[attachmentX] - }) - ); - - // vertical - best = this._getBestAttachmentOptions( - tetherOptions, best, this.props.verticalAttachments, ["top", "bottom"], - (best, attachmentY) => ({ - ...best, - attachmentY: attachmentY, - targetAttachmentY: (attachmentY === "top" ? "bottom" : "top"), - offsetY: ({ "top": this.props.targetOffsetY, "bottom": -(this.props.targetOffsetY) })[attachmentY] - }) - ); + targetAttachmentY: "bottom", + offsetX: 0, + offsetY: 0 + }; + + // horizontal + best = this._getBestAttachmentOptions( + tetherOptions, best, this.props.horizontalAttachments, ["left", "right"], + (best, attachmentX) => ({ + ...best, + attachmentX: attachmentX, + targetAttachmentX: "center", + offsetX: ({ "center": 0, "left": -(this.props.targetOffsetX), "right": this.props.targetOffsetX })[attachmentX] + }) + ); + + // vertical + best = this._getBestAttachmentOptions( + tetherOptions, best, this.props.verticalAttachments, ["top", "bottom"], + (best, attachmentY) => ({ + ...best, + attachmentY: attachmentY, + targetAttachmentY: (attachmentY === "top" ? "bottom" : "top"), + offsetY: ({ "top": this.props.targetOffsetY, "bottom": -(this.props.targetOffsetY) })[attachmentY] + }) + ); + + this._best = best; + } // finally set the best options - this._setTetherOptions(tetherOptions, best); + this._setTetherOptions(tetherOptions, this._best); } if (this.props.sizeToFit) { diff --git a/frontend/src/metabase/components/SearchHeader.jsx b/frontend/src/metabase/components/SearchHeader.jsx index 2014b6603b9df..ccd13419418a5 100644 --- a/frontend/src/metabase/components/SearchHeader.jsx +++ b/frontend/src/metabase/components/SearchHeader.jsx @@ -11,7 +11,7 @@ const SearchHeader = ({ searchText, setSearchText }) =>
class extends Component { this.toggle())} + onClick={(event) => { + event.preventDefault() + !this.props.disabled && this.toggle() + }} className={cx(triggerClasses, isOpen && triggerClassesOpen, "no-decoration", { 'cursor-default': this.props.disabled })} diff --git a/frontend/src/metabase/components/Value.jsx b/frontend/src/metabase/components/Value.jsx index eb1990bc45d20..10721fb453df9 100644 --- a/frontend/src/metabase/components/Value.jsx +++ b/frontend/src/metabase/components/Value.jsx @@ -1,8 +1,17 @@ +/* @flow */ + import React from "react"; import { formatValue } from "metabase/lib/formatting"; -const Value = ({ value, ...options }) => { +import type { Value as ValueType } from "metabase/meta/types/Dataset"; +import type { FormattingOptions } from "metabase/lib/formatting" + +type Props = { + value: ValueType +} & FormattingOptions; + +const Value = ({ value, ...options }: Props) => { let formatted = formatValue(value, { ...options, jsx: true }); if (React.isValidElement(formatted)) { return formatted; diff --git a/frontend/src/metabase/components/__snapshots__/Button.spec.js.snap b/frontend/src/metabase/components/__snapshots__/Button.spec.js.snap index f882d09abb2bf..af6dc387ed6c8 100644 --- a/frontend/src/metabase/components/__snapshots__/Button.spec.js.snap +++ b/frontend/src/metabase/components/__snapshots__/Button.spec.js.snap @@ -22,7 +22,7 @@ exports[`Button should render correctly with an icon 1`] = ` className="flex layout-centered" > { // we send the user over to the chosen dashboard in edit mode with the current card added - this.props.onChangeLocation(Urls.dashboard(dashboard.id)+"?add="+this.props.card.id); + this.props.onChangeLocation(Urls.dashboard(dashboard.id, {addCardWithId: this.props.card.id})); } createDashboard = async(newDashboard: Dashboard) => { diff --git a/frontend/src/metabase/css/components/popover.css b/frontend/src/metabase/css/components/popover.css index c41b6c2bc26d0..a9e54efbc6625 100644 --- a/frontend/src/metabase/css/components/popover.css +++ b/frontend/src/metabase/css/components/popover.css @@ -19,6 +19,7 @@ display: flex; flex-direction: column; overflow: hidden; + max-width: 500px; } .PopoverBody.PopoverBody--tooltip { diff --git a/frontend/src/metabase/css/core/colors.css b/frontend/src/metabase/css/core/colors.css index 0242b1d3f1481..eaa0d54edabc9 100644 --- a/frontend/src/metabase/css/core/colors.css +++ b/frontend/src/metabase/css/core/colors.css @@ -33,6 +33,10 @@ color: var(--default-font-color); } +.text-default-hover:hover { + color: var(--default-font-color); +} + .text-danger { color: #EEA5A5; } /* brand */ @@ -191,3 +195,5 @@ color: #CFE4F5 } .text-slate { color: #606E7B; } + +.bg-transparent { background-color: transparent } diff --git a/frontend/src/metabase/dashboard/components/DeleteDashboardModal.jsx b/frontend/src/metabase/dashboard/components/ArchiveDashboardModal.jsx similarity index 85% rename from frontend/src/metabase/dashboard/components/DeleteDashboardModal.jsx rename to frontend/src/metabase/dashboard/components/ArchiveDashboardModal.jsx index f75223b38abe7..78922e6ebbd97 100644 --- a/frontend/src/metabase/dashboard/components/DeleteDashboardModal.jsx +++ b/frontend/src/metabase/dashboard/components/ArchiveDashboardModal.jsx @@ -3,7 +3,7 @@ import PropTypes from "prop-types"; import ModalContent from "metabase/components/ModalContent.jsx"; -export default class DeleteDashboardModal extends Component { +export default class ArchiveDashboardModal extends Component { constructor(props, context) { super(props, context); @@ -16,12 +16,12 @@ export default class DeleteDashboardModal extends Component { dashboard: PropTypes.object.isRequired, onClose: PropTypes.func, - onDelete: PropTypes.func + onArchive: PropTypes.func }; - async deleteDashboard() { + async archiveDashboard() { try { - this.props.onDelete(this.props.dashboard); + this.props.onArchive(this.props.dashboard); } catch (error) { this.setState({ error }); } @@ -46,7 +46,7 @@ export default class DeleteDashboardModal extends Component { return (
@@ -54,7 +54,7 @@ export default class DeleteDashboardModal extends Component {
- + {formError}
diff --git a/frontend/src/metabase/dashboard/components/DashCard.jsx b/frontend/src/metabase/dashboard/components/DashCard.jsx index 8f292bac556e0..b6b0fc202a390 100644 --- a/frontend/src/metabase/dashboard/components/DashCard.jsx +++ b/frontend/src/metabase/dashboard/components/DashCard.jsx @@ -18,6 +18,8 @@ import cx from "classnames"; import _ from "underscore"; import { getIn } from "icepick"; +const DATASET_USUALLY_FAST_THRESHOLD = 15 * 1000; + const HEADER_ICON_SIZE = 16; const HEADER_ACTION_STYLE = { @@ -28,19 +30,15 @@ export default class DashCard extends Component { static propTypes = { dashcard: PropTypes.object.isRequired, dashcardData: PropTypes.object.isRequired, - cardDurations: PropTypes.object.isRequired, + slowCards: PropTypes.object.isRequired, parameterValues: PropTypes.object.isRequired, markNewCardSeen: PropTypes.func.isRequired, fetchCardData: PropTypes.func.isRequired, - linkToCard: PropTypes.bool, }; async componentDidMount() { const { dashcard, markNewCardSeen } = this.props; - this.visibilityTimer = window.setInterval(this.updateVisibility, 2000); - window.addEventListener("scroll", this.updateVisibility, false); - // HACK: way to scroll to a newly added card if (dashcard.justAdded) { ReactDOM.findDOMNode(this).scrollIntoView(); @@ -50,25 +48,21 @@ export default class DashCard extends Component { componentWillUnmount() { window.clearInterval(this.visibilityTimer); - window.removeEventListener("scroll", this.updateVisibility, false); - } - - updateVisibility = () => { - const { isFullscreen } = this.props; - const element = ReactDOM.findDOMNode(this); - if (element) { - const rect = element.getBoundingClientRect(); - const isOffscreen = (rect.bottom < 0 || rect.bottom > window.innerHeight || rect.top < 0); - if (isFullscreen && isOffscreen) { - element.style.opacity = 0.05; - } else { - element.style.opacity = 1.0; - } - } } render() { - const { dashcard, dashcardData, cardDurations, parameterValues, isEditing, isEditingParameter, onAddSeries, onRemove, linkToCard } = this.props; + const { + dashcard, + dashcardData, + slowCards, + parameterValues, + isEditing, + isEditingParameter, + onAddSeries, + onRemove, + navigateToNewCard, + metadata + } = this.props; const mainCard = { ...dashcard.card, @@ -79,13 +73,14 @@ export default class DashCard extends Component { .map(card => ({ ...getIn(dashcardData, [dashcard.id, card.id]), card: card, - duration: cardDurations[card.id] + isSlow: slowCards[card.id], + isUsuallyFast: card.query_average_duration && (card.query_average_duration < DATASET_USUALLY_FAST_THRESHOLD) })); const loading = !(series.length > 0 && _.every(series, (s) => s.data)); - const expectedDuration = Math.max(...series.map((s) => s.duration ? s.duration.average : 0)); - const usuallyFast = _.every(series, (s) => s.duration && s.duration.average < s.duration.fast_threshold); - const isSlow = loading && _.some(series, (s) => s.duration) && (usuallyFast ? "usually-fast" : "usually-slow"); + const expectedDuration = Math.max(...series.map((s) => s.card.query_average_duration || 0)); + const usuallyFast = _.every(series, (s) => s.isUsuallyFast); + const isSlow = loading && _.some(series, (s) => s.isSlow) && (usuallyFast ? "usually-fast" : "usually-slow"); const parameterMap = dashcard && dashcard.parameter_mappings && dashcard.parameter_mappings .reduce((map, mapping) => ({...map, [mapping.parameter_id]: mapping}), {}); @@ -138,7 +133,10 @@ export default class DashCard extends Component { } onUpdateVisualizationSettings={this.props.onUpdateVisualizationSettings} replacementContent={isEditingParameter && } - linkToCard={linkToCard} + metadata={metadata} + onChangeCardAndRun={ navigateToNewCard ? (card: UnsavedCard) => { + navigateToNewCard(card, dashcard) + } : null} />
); diff --git a/frontend/src/metabase/dashboard/components/DashCard.spec.js b/frontend/src/metabase/dashboard/components/DashCard.spec.js index 3debcfc1fdc57..84f3ed2a07749 100644 --- a/frontend/src/metabase/dashboard/components/DashCard.spec.js +++ b/frontend/src/metabase/dashboard/components/DashCard.spec.js @@ -16,7 +16,7 @@ const DEFAULT_PROPS = { dashcardData: { 1: { cols: [], rows: [] } }, - cardDurations: {}, + slowCards: {}, parameterValues: {}, markNewCardSeen: () => {}, fetchCardData: () => {} @@ -36,10 +36,7 @@ describe("DashCard", () => { expect(dashCard.find(".Card--slow")).toHaveLength(0); }); it("should render slow card with Card--slow className", () => { - const props = assocIn(DEFAULT_PROPS, ["cardDurations", 1], { - average: 1, - fast_threshold: 1 - }); + const props = assocIn(DEFAULT_PROPS, ["slowCards", 1], true); const dashCard = render(); expect(dashCard.find(".Card--recent")).toHaveLength(0); expect(dashCard.find(".Card--unmapped")).toHaveLength(0); diff --git a/frontend/src/metabase/dashboard/components/DashCardParameterMapper.jsx b/frontend/src/metabase/dashboard/components/DashCardParameterMapper.jsx index 70981453d08a0..da2ed9a73a588 100644 --- a/frontend/src/metabase/dashboard/components/DashCardParameterMapper.jsx +++ b/frontend/src/metabase/dashboard/components/DashCardParameterMapper.jsx @@ -11,7 +11,7 @@ const DashCardParameterMapper = ({ dashcard }) => }
{[dashcard.card].concat(dashcard.series || []).map(card => - + )}
diff --git a/frontend/src/metabase/dashboard/components/Dashboard.jsx b/frontend/src/metabase/dashboard/components/Dashboard.jsx index 578c71a8f6d97..6a63a2900da1e 100644 --- a/frontend/src/metabase/dashboard/components/Dashboard.jsx +++ b/frontend/src/metabase/dashboard/components/Dashboard.jsx @@ -39,7 +39,7 @@ type Props = { initialize: () => Promise, addCardToDashboard: ({ dashId: DashCardId, cardId: CardId }) => void, - deleteDashboard: (dashboardId: DashboardId) => void, + archiveDashboard: (dashboardId: DashboardId) => void, fetchCards: (filterMode?: string) => void, fetchDashboard: (dashboardId: DashboardId, queryParams: ?QueryParams) => void, fetchRevisions: ({ entity: string, id: number }) => void, @@ -91,7 +91,7 @@ export default class Dashboard extends Component<*, Props, State> { parameters: PropTypes.array, addCardToDashboard: PropTypes.func.isRequired, - deleteDashboard: PropTypes.func.isRequired, + archiveDashboard: PropTypes.func.isRequired, fetchCards: PropTypes.func.isRequired, fetchDashboard: PropTypes.func.isRequired, fetchRevisions: PropTypes.func.isRequired, @@ -188,7 +188,7 @@ export default class Dashboard extends Component<*, Props, State> { } return ( - + {() =>
diff --git a/frontend/src/metabase/dashboard/components/DashboardGrid.jsx b/frontend/src/metabase/dashboard/components/DashboardGrid.jsx index 4441f0249d1d3..b1ab2839a3bd0 100644 --- a/frontend/src/metabase/dashboard/components/DashboardGrid.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardGrid.jsx @@ -64,10 +64,6 @@ export default class DashboardGrid extends Component { isEditingParameter: false }; - shouldComponentUpdate(nextProps, nextState) { - return !(_.isEqual(this.props, nextProps) && _.isEqual(this.state, nextState)); - } - componentWillReceiveProps(nextProps) { this.setState({ dashcards: this.getSortedDashcards(nextProps), @@ -186,7 +182,7 @@ export default class DashboardGrid extends Component { dashcard={dc} dashcardData={this.props.dashcardData} parameterValues={this.props.parameterValues} - cardDurations={this.props.cardDurations} + slowCards={this.props.slowCards} fetchCardData={this.props.fetchCardData} markNewCardSeen={this.props.markNewCardSeen} isEditing={this.props.isEditing} @@ -197,7 +193,8 @@ export default class DashboardGrid extends Component { onAddSeries={this.onDashCardAddSeries.bind(this, dc)} onUpdateVisualizationSettings={this.props.onUpdateDashCardVisualizationSettings.bind(this, dc.id)} onReplaceAllVisualizationSettings={this.props.onReplaceAllDashCardVisualizationSettings.bind(this, dc.id)} - linkToCard={this.props.linkToCard} + navigateToNewCard={this.props.navigateToNewCard} + metadata={this.props.metadata} /> ) } diff --git a/frontend/src/metabase/dashboard/components/DashboardHeader.jsx b/frontend/src/metabase/dashboard/components/DashboardHeader.jsx index 37ca23df18f9f..388589a93abe4 100644 --- a/frontend/src/metabase/dashboard/components/DashboardHeader.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardHeader.jsx @@ -5,7 +5,7 @@ import PropTypes from "prop-types"; import ActionButton from "metabase/components/ActionButton.jsx"; import AddToDashSelectQuestionModal from "./AddToDashSelectQuestionModal.jsx"; -import DeleteDashboardModal from "./DeleteDashboardModal.jsx"; +import ArchiveDashboardModal from "./ArchiveDashboardModal.jsx"; import Header from "metabase/components/Header.jsx"; import HistoryModal from "metabase/components/HistoryModal.jsx"; import Icon from "metabase/components/Icon.jsx"; @@ -47,7 +47,7 @@ type Props = { parameters: React$Element<*>[], addCardToDashboard: ({ dashId: DashCardId, cardId: CardId }) => void, - deleteDashboard: (dashboardId: DashboardId) => void, + archiveDashboard: (dashboardId: DashboardId) => void, fetchCards: (filterMode?: string) => void, fetchDashboard: (dashboardId: DashboardId, queryParams: ?QueryParams) => void, fetchRevisions: ({ entity: string, id: number }) => void, @@ -87,7 +87,7 @@ export default class DashboardHeader extends Component<*, Props, State> { refreshElapsed: PropTypes.number, addCardToDashboard: PropTypes.func.isRequired, - deleteDashboard: PropTypes.func.isRequired, + archiveDashboard: PropTypes.func.isRequired, fetchCards: PropTypes.func.isRequired, fetchDashboard: PropTypes.func.isRequired, fetchRevisions: PropTypes.func.isRequired, @@ -123,9 +123,9 @@ export default class DashboardHeader extends Component<*, Props, State> { this.onDoneEditing(); } - async onDelete() { - await this.props.deleteDashboard(this.props.dashboard.id); - this.props.onChangeLocation("/dashboard"); + async onArchive() { + await this.props.archiveDashboard(this.props.dashboard.id); + this.props.onChangeLocation("/dashboards"); } // 1. fetch revisions @@ -150,15 +150,15 @@ export default class DashboardHeader extends Component<*, Props, State> { Cancel , - this.refs.deleteDashboardModal.toggle()} - onDelete={() => this.onDelete()} + onClose={() => this.refs.archiveDashboardModal.toggle()} + onArchive={() => this.onArchive()} /> , { return { @@ -28,28 +30,42 @@ const mapStateToProps = (state, props) => { cards: getCardList(state, props), revisions: getRevisions(state, props), dashcardData: getCardData(state, props), - cardDurations: getCardDurations(state, props), + slowCards: getSlowCards(state, props), databases: getDatabases(state, props), editingParameter: getEditingParameter(state, props), parameters: getParameters(state, props), parameterValues: getParameterValues(state, props), - - addCardOnLoad: props.location.query.add ? parseInt(props.location.query.add) : null + metadata: getMetadata(state) } } const mapDispatchToProps = { ...dashboardActions, - deleteDashboard, + archiveDashboard, fetchDatabaseMetadata, setErrorPage, onChangeLocation: push } +type DashboardAppState = { + addCardOnLoad: number|null +} + @connect(mapStateToProps, mapDispatchToProps) @title(({ dashboard }) => dashboard && dashboard.name) export default class DashboardApp extends Component { + state: DashboardAppState = { + addCardOnLoad: null + }; + + componentWillMount() { + let options = parseHashOptions(window.location.hash); + if (options.add) { + this.setState({addCardOnLoad: parseInt(options.add)}) + } + } + render() { - return ; + return ; } } diff --git a/frontend/src/metabase/dashboard/dashboard.js b/frontend/src/metabase/dashboard/dashboard.js index 7ceb22a58f0be..2bff4047b2cce 100644 --- a/frontend/src/metabase/dashboard/dashboard.js +++ b/frontend/src/metabase/dashboard/dashboard.js @@ -10,24 +10,23 @@ import { normalize, schema } from "normalizr"; import { saveDashboard } from "metabase/dashboards/dashboards"; import { createParameter, setParameterName as setParamName, setParameterDefaultValue as setParamDefaultValue } from "metabase/meta/Dashboard"; -import { applyParameters } from "metabase/meta/Card"; +import { applyParameters, questionUrlWithParameters } from "metabase/meta/Card"; import { getParametersBySlug } from "metabase/meta/Parameter"; import type { DashboardWithCards, DashCard, DashCardId } from "metabase/meta/types/Dashboard"; -import type { Card, CardId } from "metabase/meta/types/Card"; +import type { UnsavedCard, Card, CardId } from "metabase/meta/types/Card"; import Utils from "metabase/lib/utils"; import { getPositionForNewDashCard } from "metabase/lib/dashboard_grid"; import { addParamValues, fetchDatabaseMetadata } from "metabase/redux/metadata"; +import { push } from "react-router-redux"; - -import { DashboardApi, MetabaseApi, CardApi, RevisionApi, PublicApi, EmbedApi } from "metabase/services"; +import { DashboardApi, CardApi, RevisionApi, PublicApi, EmbedApi } from "metabase/services"; import { getDashboard, getDashboardComplete } from "./selectors"; -const DATASET_SLOW_TIMEOUT = 15 * 1000; -const DATASET_USUALLY_FAST_THRESHOLD = 15 * 1000; +const DATASET_SLOW_TIMEOUT = 15 * 1000; // normalizr schemas const dashcard = new schema.Entity('dashcard'); @@ -56,11 +55,10 @@ export const SET_DASHCARD_ATTRIBUTES = "metabase/dashboard/SET_DASHCARD_ATTRIBUT export const UPDATE_DASHCARD_VISUALIZATION_SETTINGS = "metabase/dashboard/UPDATE_DASHCARD_VISUALIZATION_SETTINGS"; export const REPLACE_ALL_DASHCARD_VISUALIZATION_SETTINGS = "metabase/dashboard/REPLACE_ALL_DASHCARD_VISUALIZATION_SETTINGS"; export const UPDATE_DASHCARD_ID = "metabase/dashboard/UPDATE_DASHCARD_ID" -export const SAVE_DASHCARD = "metabase/dashboard/SAVE_DASHCARD"; export const FETCH_DASHBOARD_CARD_DATA = "metabase/dashboard/FETCH_DASHBOARD_CARD_DATA"; export const FETCH_CARD_DATA = "metabase/dashboard/FETCH_CARD_DATA"; -export const FETCH_CARD_DURATION = "metabase/dashboard/FETCH_CARD_DURATION"; +export const MARK_CARD_AS_SLOW = "metabase/dashboard/MARK_CARD_AS_SLOW"; export const CLEAR_CARD_DATA = "metabase/dashboard/CLEAR_CARD_DATA"; export const FETCH_REVISIONS = "metabase/dashboard/FETCH_REVISIONS"; @@ -68,8 +66,6 @@ export const REVERT_TO_REVISION = "metabase/dashboard/REVERT_TO_REVISION"; export const MARK_NEW_CARD_SEEN = "metabase/dashboard/MARK_NEW_CARD_SEEN"; -export const FETCH_DATABASE_METADATA = "metabase/dashboard/FETCH_DATABASE_METADATA"; - export const SET_EDITING_PARAMETER_ID = "metabase/dashboard/SET_EDITING_PARAMETER_ID"; export const ADD_PARAMETER = "metabase/dashboard/ADD_PARAMETER"; export const REMOVE_PARAMETER = "metabase/dashboard/REMOVE_PARAMETER"; @@ -281,10 +277,10 @@ export const fetchCardData = createThunkAction(FETCH_CARD_DATA, function(card, d let result = null; - // start a timer that will fetch the expected card duration if the query takes too long + // start a timer that will show the expected card duration if the query takes too long let slowCardTimer = setTimeout(() => { if (result === null) { - dispatch(fetchCardDuration(card, datasetQuery)); + dispatch(markCardAsSlow(card, datasetQuery)); } }, DATASET_SLOW_TIMEOUT); @@ -316,18 +312,11 @@ export const fetchCardData = createThunkAction(FETCH_CARD_DATA, function(card, d }; }); -export const fetchCardDuration = createThunkAction(FETCH_CARD_DURATION, function(card, datasetQuery) { - return async function(dispatch, getState) { - let result = await MetabaseApi.dataset_duration(datasetQuery); - return { - id: card.id, - result: { - fast_threshold: DATASET_USUALLY_FAST_THRESHOLD, - ...result - } - }; - }; -}); +export const markCardAsSlow = createAction(MARK_CARD_AS_SLOW, (card) => ({ + id: card.id, + result: true +})); + export const fetchDashboard = createThunkAction(FETCH_DASHBOARD, function(dashId, queryParams, enableDefaultParameters = true) { let result; @@ -502,6 +491,25 @@ export const deletePublicLink = createAction(DELETE_PUBLIC_LINK, async ({ id }) return { id }; }); +/** All navigation actions from dashboards to cards (e.x. clicking a title, drill through) + * should go through this action, which merges any currently applied dashboard filters + * into the new card / URL parameters. + */ + +// TODO Atte Keinänen 5/2/17: This could be combined with `setCardAndRun` of query_builder/actions.js +// Having two separate actions for very similar behavior was a source of initial confusion for me +const NAVIGATE_TO_NEW_CARD = "metabase/dashboard/NAVIGATE_TO_NEW_CARD"; +export const navigateToNewCard = createThunkAction(NAVIGATE_TO_NEW_CARD, (card: UnsavedCard, dashcard: DashCard) => + (dispatch, getState) => { + const { metadata } = getState(); + const { dashboardId, dashboards, parameterValues } = getState().dashboard; + const dashboard = dashboards[dashboardId]; + + // $FlowFixMe + const url = questionUrlWithParameters(card, metadata, dashboard.parameters, parameterValues, dashcard && dashcard.parameter_mappings); + dispatch(push(url)); + }); + // reducers const dashboardId = handleActions({ @@ -611,8 +619,8 @@ const dashcardData = handleActions({ } }, {}); -const cardDurations = handleActions({ - [FETCH_CARD_DURATION]: { next: (state, { payload: { id, result }}) => ({ ...state, [id]: result }) } +const slowCards = handleActions({ + [MARK_CARD_AS_SLOW]: { next: (state, { payload: { id, result }}) => ({ ...state, [id]: result }) } }, {}); const parameterValues = handleActions({ @@ -632,6 +640,6 @@ export default combineReducers({ editingParameterId, revisions, dashcardData, - cardDurations, + slowCards, parameterValues }); diff --git a/frontend/src/metabase/dashboard/hoc/DashboardControls.jsx b/frontend/src/metabase/dashboard/hoc/DashboardControls.jsx index 7d8225ed66901..f291c2a50ad08 100644 --- a/frontend/src/metabase/dashboard/hoc/DashboardControls.jsx +++ b/frontend/src/metabase/dashboard/hoc/DashboardControls.jsx @@ -38,7 +38,7 @@ export default (ComposedComponent: ReactClass) => class extends Component<*, Props, State> { static displayName = "DashboardControls["+(ComposedComponent.displayName || ComposedComponent.name)+"]"; - state = { + state: State = { isFullscreen: false, isNightMode: false, @@ -97,8 +97,14 @@ export default (ComposedComponent: ReactClass) => setValue("refresh", this.state.refreshPeriod); setValue("fullscreen", this.state.isFullscreen); setValue("theme", this.state.isNightMode ? "night" : null); + delete options.night; // DEPRECATED: options.night + // Delete the "add card to dashboard" parameter if it's present because we don't + // want to add the card again on page refresh. The `add` parameter is already handled in + // DashboardApp before this method is called. + delete options.add; + let hash = stringifyHashOptions(options); hash = hash ? "#" + hash : ""; @@ -106,7 +112,7 @@ export default (ComposedComponent: ReactClass) => if (hash !== location.hash) { replace({ pathname: location.pathname, - earch: location.search, + search: location.search, hash }); } @@ -134,10 +140,12 @@ export default (ComposedComponent: ReactClass) => }; setNightMode = isNightMode => { + isNightMode = !!isNightMode; this.setState({ isNightMode }); }; setFullscreen = (isFullscreen, browserFullscreen = true) => { + isFullscreen = !!isFullscreen; if (isFullscreen !== this.state.isFullscreen) { if (screenfull.enabled && browserFullscreen) { if (isFullscreen) { @@ -184,7 +192,7 @@ export default (ComposedComponent: ReactClass) => } _fullScreenChanged = () => { - this.setState({ isFullscreen: screenfull.isFullscreen }); + this.setState({ isFullscreen: !!screenfull.isFullscreen }); }; render() { diff --git a/frontend/src/metabase/dashboard/selectors.js b/frontend/src/metabase/dashboard/selectors.js index 171796da59ad0..6073917ab6bf7 100644 --- a/frontend/src/metabase/dashboard/selectors.js +++ b/frontend/src/metabase/dashboard/selectors.js @@ -1,13 +1,15 @@ /* @flow weak */ import _ from "underscore"; -import { updateIn, setIn } from "icepick"; +import { setIn } from "icepick"; import { createSelector } from 'reselect'; +import { getMeta } from "metabase/selectors/metadata"; + import * as Dashboard from "metabase/meta/Dashboard"; + import { getParameterTargetFieldId } from "metabase/meta/Parameter"; -import Metadata from "metabase/meta/metadata/Metadata"; import type { CardId, Card } from "metabase/meta/types/Card"; import type { DashCardId } from "metabase/meta/types/Dashboard"; @@ -34,18 +36,11 @@ export const getCards = state => state.dashboard.cards; export const getDashboards = state => state.dashboard.dashboards; export const getDashcards = state => state.dashboard.dashcards; export const getCardData = state => state.dashboard.dashcardData; -export const getCardDurations = state => state.dashboard.cardDurations; +export const getSlowCards = state => state.dashboard.slowCards; export const getCardIdList = state => state.dashboard.cardList; export const getRevisions = state => state.dashboard.revisions; export const getParameterValues = state => state.dashboard.parameterValues; -export const getDatabases = state => state.metadata.databases; - -export const getMetadata = createSelector( - [state => state.metadata], - (metadata) => Metadata.fromEntities(metadata) -) - export const getDashboard = createSelector( [getDashboardId, getDashboards], (dashboardId, dashboards) => dashboards[dashboardId] @@ -98,7 +93,7 @@ export const getParameterTarget = createSelector( ); export const getMappingsByParameter = createSelector( - [getMetadata, getDashboardComplete], + [getMeta, getDashboardComplete], (metadata, dashboard) => { if (!dashboard) { return {}; @@ -114,9 +109,13 @@ export const getMappingsByParameter = createSelector( const fieldId = card && getParameterTargetFieldId(mapping.target, card.dataset_query); const field = metadata.field(fieldId); const values = field && field.values() || []; + if (values.length) { + countsByParameter[mapping.parameter_id] = countsByParameter[mapping.parameter_id] || {}; + } for (const value of values) { - countsByParameter = updateIn(countsByParameter, [mapping.parameter_id, value], (count = 0) => count + 1) + countsByParameter[mapping.parameter_id][value] = (countsByParameter[mapping.parameter_id][value] || 0) + 1 } + let augmentedMapping: AugmentedParameterMapping = { ...mapping, parameter_id: mapping.parameter_id, @@ -135,7 +134,7 @@ export const getMappingsByParameter = createSelector( if (mapping.values && mapping.values.length > 0) { let overlapMax = Math.max(...mapping.values.map(value => countsByParameter[mapping.parameter_id][value])) mappingsByParameter = setIn(mappingsByParameter, [mapping.parameter_id, mapping.dashcard_id, mapping.card_id, "overlapMax"], overlapMax); - mappingsWithValuesByParameter = updateIn(mappingsWithValuesByParameter, [mapping.parameter_id], (count = 0) => count + 1); + mappingsWithValuesByParameter[mapping.parameter_id] = (mappingsWithValuesByParameter[mapping.parameter_id] || 0) + 1; } } // update count of mappings with values @@ -158,6 +157,7 @@ export const getParameters = createSelector( .flatten() .map(m => m.field_id) .uniq() + .filter(fieldId => fieldId != null) .value(); return { ...parameter, @@ -168,7 +168,7 @@ export const getParameters = createSelector( export const makeGetParameterMappingOptions = () => { const getParameterMappingOptions = createSelector( - [getMetadata, getEditingParameter, getCard], + [getMeta, getEditingParameter, getCard], (metadata, parameter: Parameter, card: Card): Array => { return Dashboard.getParameterMappingOptions(metadata, parameter, card); } diff --git a/frontend/src/metabase/dashboard/selectors.spec.js b/frontend/src/metabase/dashboard/selectors.spec.js new file mode 100644 index 0000000000000..cac0ee457575f --- /dev/null +++ b/frontend/src/metabase/dashboard/selectors.spec.js @@ -0,0 +1,132 @@ +import { getParameters } from "./selectors"; + +import { chain } from "icepick"; + +const STATE = { + dashboard: { + dashboardId: 0, + dashboards: { + 0: { + ordered_cards: [0, 1], + parameters: [] + } + }, + dashcards: { + 0: { + card: { id: 0 }, + parameter_mappings: [] + }, + 1: { + card: { id: 1 }, + parameter_mappings: [] + } + } + }, + metadata: { + databases: {}, + tables: {}, + fields: {}, + } +} + +describe("dashboard/selectors", () => { + describe("getParameters", () => { + it("should work with no parameters", () => { + expect(getParameters(STATE)).toEqual([]); + }) + it("should not include field id with no mappings", () => { + const state = chain(STATE) + .assocIn(["dashboard", "dashboards", 0, "parameters", 0], { id: 1 }) + .value(); + expect(getParameters(state)).toEqual([{ + id: 1, + field_id: null + }]); + }) + it("should not include field id with one mapping, no field id", () => { + const state = chain(STATE) + .assocIn(["dashboard", "dashboards", 0, "parameters", 0], { id: 1 }) + .assocIn(["dashboard", "dashcards", 0, "parameter_mappings", 0], { + card_id: 0, + parameter_id: 1, + target: ["variable", ["template-tag", "foo"]] + }) + .value(); + expect(getParameters(state)).toEqual([{ + id: 1, + field_id: null + }]); + }) + it("should include field id with one mappings, with field id", () => { + const state = chain(STATE) + .assocIn(["dashboard", "dashboards", 0, "parameters", 0], { id: 1 }) + .assocIn(["dashboard", "dashcards", 0, "parameter_mappings", 0], { + card_id: 0, + parameter_id: 1, + target: ["dimension", ["field-id", 1]] + }) + .value(); + expect(getParameters(state)).toEqual([{ + id: 1, + field_id: 1 + }]); + }) + it("should include field id with two mappings, with same field id", () => { + const state = chain(STATE) + .assocIn(["dashboard", "dashboards", 0, "parameters", 0], { id: 1 }) + .assocIn(["dashboard", "dashcards", 0, "parameter_mappings", 0], { + card_id: 0, + parameter_id: 1, + target: ["dimension", ["field-id", 1]] + }) + .assocIn(["dashboard", "dashcards", 1, "parameter_mappings", 0], { + card_id: 1, + parameter_id: 1, + target: ["dimension", ["field-id", 1]] + }) + .value(); + expect(getParameters(state)).toEqual([{ + id: 1, + field_id: 1 + }]); + }) + it("should include field id with two mappings, one with field id, one without", () => { + const state = chain(STATE) + .assocIn(["dashboard", "dashboards", 0, "parameters", 0], { id: 1 }) + .assocIn(["dashboard", "dashcards", 0, "parameter_mappings", 0], { + card_id: 0, + parameter_id: 1, + target: ["dimension", ["field-id", 1]] + }) + .assocIn(["dashboard", "dashcards", 1, "parameter_mappings", 0], { + card_id: 1, + parameter_id: 1, + target: ["variable", ["template-tag", "foo"]] + }) + .value(); + expect(getParameters(state)).toEqual([{ + id: 1, + field_id: 1 + }]); + }) + it("should not include field id with two mappings, with different field ids", () => { + const state = chain(STATE) + .assocIn(["dashboard", "dashboards", 0, "parameters", 0], { id: 1 }) + .assocIn(["dashboard", "dashcards", 0, "parameter_mappings", 0], { + card_id: 0, + parameter_id: 1, + target: ["dimension", ["field-id", 1]] + }) + .assocIn(["dashboard", "dashcards", 1, "parameter_mappings", 0], { + card_id: 1, + parameter_id: 1, + target: ["dimension", ["field-id", 2]] + }) + .value(); + expect(getParameters(state)).toEqual([{ + id: 1, + field_id: null + }]); + }) + }) +}) diff --git a/frontend/src/metabase/dashboards/components/DashboardList.jsx b/frontend/src/metabase/dashboards/components/DashboardList.jsx index 50ed2de2e2616..2dffd04473072 100644 --- a/frontend/src/metabase/dashboards/components/DashboardList.jsx +++ b/frontend/src/metabase/dashboards/components/DashboardList.jsx @@ -3,7 +3,6 @@ import React, {Component} from "react"; import PropTypes from "prop-types"; import {Link} from "react-router"; -import {withState} from "recompose"; import cx from "classnames"; import moment from "moment"; @@ -12,46 +11,140 @@ import * as Urls from "metabase/lib/urls"; import type {Dashboard} from "metabase/meta/types/Dashboard"; import Icon from "metabase/components/Icon"; import Ellipsified from "metabase/components/Ellipsified.jsx"; +import Tooltip from "metabase/components/Tooltip"; -type DashboardListItemType = { +type DashboardListItemProps = { dashboard: Dashboard, - hover: boolean, - setHover: (boolean) => void + setFavorited: (dashId: number, favorited: boolean) => void, + setArchived: (dashId: number, archived: boolean) => void } -const enhance = withState('hover', 'setHover', false) -const DashboardListItem = enhance(({dashboard, hover, setHover}: DashboardListItemType) => -
  • - setHover(true)} - onMouseLeave={() => setHover(false)}> +class DashboardListItem extends Component { + props: DashboardListItemProps + + state = { + hover: false, + fadingOut: false + } + + render() { + const {dashboard, setFavorited, setArchived} = this.props + const {hover, fadingOut} = this.state + + const {id, name, created_at, archived, favorite} = dashboard + + const archivalButton = + + { + e.preventDefault(); + + // Let the 0.2s transition finish before the archival API call (`setArchived` action) + this.setState({fadingOut: true}) + setTimeout(() => setArchived(id, !archived, true), 300); + } } + /> + + + const favoritingButton = + + { + e.preventDefault(); + setFavorited(id, !favorite) + } } + /> + + + const dashboardIcon = -
    -

    - {dashboard.name} -

    -
    - {/* NOTE: Could these time formats be centrally stored somewhere? */} - {moment(dashboard.created_at).format('MMM D, YYYY')} -
    -
    - -
  • -); + className={"ml2 text-grey-1"} + size={25}/> + + return ( +
  • + this.setState({hover: true})} + onMouseLeave={() => this.setState({hover: false})}> +
    +
    +
    +

    + {name} +

    +
    + {/* NOTE: Could these time formats be centrally stored somewhere? */} + {moment(created_at).format('MMM D, YYYY')} +
    +
    + + {/* Hidden flexbox item which makes sure that long titles are ellipsified correctly */} +
    + { hover && archivalButton } + { (favorite || hover) && favoritingButton } + { !hover && !favorite && dashboardIcon } +
    + + {/* Non-hover dashboard icon, only rendered if the dashboard isn't favorited */} + {!favorite && +
    + { dashboardIcon } +
    + } + + {/* Favorite icon, only rendered if the dashboard is favorited */} + {/* Visible also in the hover state (under other button) because hiding leads to an ugly animation */} + {favorite && +
    + { favoritingButton } +
    + } + + {/* Hover state buttons, both archival and favoriting */} +
    + { archivalButton } + { favoritingButton } +
    + +
    +
    + + +
  • + ) + } + +} export default class DashboardList extends Component { static propTypes = { @@ -59,11 +152,16 @@ export default class DashboardList extends Component { }; render() { - const {dashboards} = this.props; + const {dashboards, isArchivePage, setFavorited, setArchived} = this.props; return (
      - { dashboards.map(dash => )} + { dashboards.map(dash => + + )}
    ); } diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index becdad46d02ad..b989c2685fdbd 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -1,8 +1,10 @@ /* @flow */ -import React, {Component, PropTypes} from 'react'; +import React, {Component} from 'react'; import {connect} from "react-redux"; +import {Link} from "react-router"; import cx from "classnames"; +import _ from "underscore" import type {Dashboard} from "metabase/meta/types/Dashboard"; @@ -15,29 +17,63 @@ import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; import Icon from "metabase/components/Icon.jsx"; import SearchHeader from "metabase/components/SearchHeader"; import EmptyState from "metabase/components/EmptyState"; +import ListFilterWidget from "metabase/components/ListFilterWidget"; +import type {ListFilterWidgetItem} from "metabase/components/ListFilterWidget"; import {caseInsensitiveSearch} from "metabase/lib/string" +import type {SetFavoritedAction, SetArchivedAction} from "../dashboards"; +import type {User} from "metabase/meta/types/User" import * as dashboardsActions from "../dashboards"; import {getDashboardListing} from "../selectors"; - +import {getUser} from "metabase/selectors/user"; const mapStateToProps = (state, props) => ({ - dashboards: getDashboardListing(state) + dashboards: getDashboardListing(state), + user: getUser(state) }); const mapDispatchToProps = dashboardsActions; +const SECTION_ID_ALL = 'all'; +const SECTION_ID_MINE = 'mine'; +const SECTION_ID_FAVORITES = 'fav'; + +const SECTIONS: ListFilterWidgetItem[] = [ + { + id: SECTION_ID_ALL, + name: 'All dashboards', + icon: 'dashboard', + // empty: 'No questions have been saved yet.', + }, + { + id: SECTION_ID_FAVORITES, + name: 'Favorites', + icon: 'star', + // empty: 'You haven\'t favorited any questions yet.', + }, + { + id: SECTION_ID_MINE, + name: 'Saved by me', + icon: 'mine', + // empty: 'You haven\'t saved any questions yet.' + }, +]; + export class Dashboards extends Component { props: { dashboards: Dashboard[], createDashboard: (Dashboard) => any, - fetchDashboards: PropTypes.func.isRequired, + fetchDashboards: () => void, + setFavorited: SetFavoritedAction, + setArchived: SetArchivedAction, + user: User }; state = { modalOpen: false, - searchText: "" + searchText: "", + section: SECTIONS[0] } componentWillMount() { @@ -72,35 +108,70 @@ export class Dashboards extends Component { ); } + searchTextFilter = (searchText: string) => + ({name, description}: Dashboard) => + (caseInsensitiveSearch(name, searchText) || (description && caseInsensitiveSearch(description, searchText))) + + sectionFilter = (section: ListFilterWidgetItem) => + ({creator_id, favorite}: Dashboard) => + (section.id === SECTION_ID_ALL) || + (section.id === SECTION_ID_MINE && creator_id === this.props.user.id) || + (section.id === SECTION_ID_FAVORITES && favorite === true) + getFilteredDashboards = () => { - const {searchText} = this.state; + const {searchText, section} = this.state; const {dashboards} = this.props; + const noOpFilter = _.constant(true) - if (searchText === "") { - return dashboards; - } else { - return dashboards.filter(({name, description}) => - caseInsensitiveSearch(name,searchText) || (description && caseInsensitiveSearch(description, searchText)) - ); - } + return _.chain(dashboards) + .filter(searchText != "" ? this.searchTextFilter(searchText) : noOpFilter) + .filter(this.sectionFilter(section)) + .value() + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) + } + + updateSection = (section: ListFilterWidgetItem) => { + this.setState({section}); } render() { - let {modalOpen, searchText} = this.state; + let {modalOpen, searchText, section} = this.state; const isLoading = this.props.dashboards === null const noDashboardsCreated = this.props.dashboards && this.props.dashboards.length === 0 const filteredDashboards = isLoading ? [] : this.getFilteredDashboards(); - const noSearchResults = searchText !== "" && filteredDashboards.length === 0; + const noResultsFound = filteredDashboards.length === 0; return ( { modalOpen ? this.renderCreateDashboardModal() : null } +
    + + +
    + + + + + {!noDashboardsCreated && + + } +
    +
    { noDashboardsCreated ? -
    +
    Put the charts and graphs you look at
    frequently in a single, handy place.} image="/app/img/dashboard_illustration" @@ -111,21 +182,20 @@ export class Dashboards extends Component { />
    :
    -
    - -
    - -
    -
    -
    +
    this.setState({searchText: text})} /> +
    + item.id !== "archived")} + activeItem={section} + onChange={this.updateSection} + /> +
    - { noSearchResults ? + { noResultsFound ?
    } image="/app/img/empty_dashboard" + imageHeight="210px" action="Create a dashboard" imageClassName="mln2" smallDescription />
    - : + : }
    diff --git a/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx b/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx new file mode 100644 index 0000000000000..3414b1f097206 --- /dev/null +++ b/frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx @@ -0,0 +1,130 @@ +/* @flow */ + +import React, {Component} from 'react'; +import {connect} from "react-redux"; +import cx from "classnames"; +import _ from "underscore" + +import type {Dashboard} from "metabase/meta/types/Dashboard"; + +import HeaderWithBack from "../../components/HeaderWithBack"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; +import SearchHeader from "metabase/components/SearchHeader"; +import EmptyState from "metabase/components/EmptyState"; +import ArchivedItem from "metabase/components/ArchivedItem"; + +import {caseInsensitiveSearch} from "metabase/lib/string" + +import type {SetArchivedAction} from "../dashboards"; +import {fetchArchivedDashboards, setArchived} from "../dashboards"; +import {getArchivedDashboards} from "../selectors"; +import {getUserIsAdmin} from "metabase/selectors/user"; + +const mapStateToProps = (state, props) => ({ + dashboards: getArchivedDashboards(state), + isAdmin: getUserIsAdmin(state, props) +}); + +const mapDispatchToProps = {fetchArchivedDashboards, setArchived}; + +export class Dashboards extends Component { + props: { + dashboards: Dashboard[], + fetchArchivedDashboards: () => void, + setArchived: SetArchivedAction, + isAdmin: boolean + }; + + state = { + searchText: "", + } + + componentWillMount() { + this.props.fetchArchivedDashboards(); + } + + searchTextFilter = (searchText: string) => + ({name, description}: Dashboard) => + (caseInsensitiveSearch(name, searchText) || (description && caseInsensitiveSearch(description, searchText))) + + getFilteredDashboards = () => { + const {searchText} = this.state; + const {dashboards} = this.props; + const noOpFilter = _.constant(true) + + return _.chain(dashboards) + .filter(searchText != "" ? this.searchTextFilter(searchText) : noOpFilter) + .sortBy((dash) => dash.name.toLowerCase()) + .value() + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) + } + + render() { + let {searchText} = this.state; + + const isLoading = this.props.dashboards === null + const noDashboardsArchived = this.props.dashboards && this.props.dashboards.length === 0 + const filteredDashboards = isLoading ? [] : this.getFilteredDashboards(); + const noSearchResults = searchText !== "" && filteredDashboards.length === 0; + + const headerWithBackContainer = +
    + +
    + + return ( + + { noDashboardsArchived ? +
    + {headerWithBackContainer} +
    + No dashboards have been
    archived yet} + icon="viewArchive" + /> +
    +
    + :
    + {headerWithBackContainer} +
    + this.setState({searchText: text})} + /> +
    + { noSearchResults ? +
    + +

    No results found

    +

    Try adjusting your filter to find what you’re + looking for.

    +
    + } + image="/app/img/empty_dashboard" + imageClassName="mln2" + smallDescription + /> +
    + :
    + { filteredDashboards.map((dashboard) => + { + await this.props.setArchived(dashboard.id, false); + }}/> + )} +
    + } +
    + } + + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Dashboards) diff --git a/frontend/src/metabase/dashboards/dashboards.js b/frontend/src/metabase/dashboards/dashboards.js index cd8bf38dc8da1..a37b1b0f1cbda 100644 --- a/frontend/src/metabase/dashboards/dashboards.js +++ b/frontend/src/metabase/dashboards/dashboards.js @@ -1,19 +1,25 @@ /* @flow weak */ -import { handleActions, createAction, combineReducers, createThunkAction } from "metabase/lib/redux"; -import { DashboardApi } from "metabase/services"; +import { handleActions, combineReducers, createThunkAction } from "metabase/lib/redux"; import MetabaseAnalytics from "metabase/lib/analytics"; -import moment from 'moment'; - import * as Urls from "metabase/lib/urls"; +import { DashboardApi } from "metabase/services"; +import { addUndo } from "metabase/redux/undo"; + +import React from "react"; import { push } from "react-router-redux"; +import moment from 'moment'; + import type { Dashboard } from "metabase/meta/types/Dashboard"; export const FETCH_DASHBOARDS = "metabase/dashboards/FETCH_DASHBOARDS"; +export const FETCH_ARCHIVE = "metabase/dashboards/FETCH_ARCHIVE"; export const CREATE_DASHBOARD = "metabase/dashboards/CREATE_DASHBOARD"; export const DELETE_DASHBOARD = "metabase/dashboards/DELETE_DASHBOARD"; -export const SAVE_DASHBOARD = "metabase/dashboards/SAVE_DASHBOARD"; +export const SAVE_DASHBOARD = "metabase/dashboards/SAVE_DASHBOARD"; export const UPDATE_DASHBOARD = "metabase/dashboards/UPDATE_DASHBOARD"; +export const SET_FAVORITED = "metabase/dashboards/SET_FAVORITED"; +export const SET_ARCHIVED = "metabase/dashboards/SET_ARCHIVED"; /** * Actions that retrieve/update the basic information of dashboards @@ -32,6 +38,18 @@ export const fetchDashboards = createThunkAction(FETCH_DASHBOARDS, () => } ); +export const fetchArchivedDashboards = createThunkAction(FETCH_ARCHIVE, () => + async function(dispatch, getState) { + const dashboards = await DashboardApi.list({f: "archived"}) + + for (const dashboard of dashboards) { + dashboard.updated_at = moment(dashboard.updated_at); + } + + return dashboards; + } +); + type CreateDashboardOpts = { redirect?: boolean } @@ -78,12 +96,6 @@ export const updateDashboard = createThunkAction(UPDATE_DASHBOARD, (dashboard: D } ); -export const deleteDashboard = createAction(DELETE_DASHBOARD, async (dashId) => { - MetabaseAnalytics.trackEvent("Dashboard", "Delete"); - await DashboardApi.delete({ dashId }); - return dashId; -}); - export const saveDashboard = createThunkAction(SAVE_DASHBOARD, function(dashboard: Dashboard) { return async function(dispatch, getState): Promise { let { id, name, description, parameters } = dashboard @@ -92,15 +104,73 @@ export const saveDashboard = createThunkAction(SAVE_DASHBOARD, function(dashboar }; }); +export type SetFavoritedAction = (dashId: number, favorited: boolean) => void; +export const setFavorited: SetFavoritedAction = createThunkAction(SET_FAVORITED, (dashId, favorited) => { + return async (dispatch, getState) => { + if (favorited) { + await DashboardApi.favorite({ dashId }); + } else { + await DashboardApi.unfavorite({ dashId }); + } + MetabaseAnalytics.trackEvent("Dashboard", favorited ? "Favorite" : "Unfavorite"); + return { id: dashId, favorite: favorited }; + } +}); + +// A simplified version of a similar method in questions/questions.js +function createUndo(type, action) { + return { + type: type, + count: 1, + message: (undo) => // eslint-disable-line react/display-name +
    { "Dashboard was " + type + "."}
    , + actions: [action] + }; +} + +export type SetArchivedAction = (dashId: number, archived: boolean, undoable?: boolean) => void; +export const setArchived = createThunkAction(SET_ARCHIVED, (dashId, archived, undoable = false) => { + return async (dispatch, getState) => { + const response = await DashboardApi.update({ + id: dashId, + archived: archived + }); + + if (undoable) { + dispatch(addUndo(createUndo( + archived ? "archived" : "unarchived", + setArchived(dashId, !archived) + ))); + } + + MetabaseAnalytics.trackEvent("Dashboard", archived ? "Archive" : "Unarchive"); + return response; + } +}); +// Convenience shorthand +export const archiveDashboard = async (dashId) => await setArchived(dashId, true); + +const archive = handleActions({ + [FETCH_ARCHIVE]: (state, { payload }) => payload, + [SET_ARCHIVED]: (state, {payload}) => payload.archived + ? (state || []).concat(payload) + : (state || []).filter(d => d.id !== payload.id) +}, null); + const dashboardListing = handleActions({ [FETCH_DASHBOARDS]: (state, { payload }) => payload, [CREATE_DASHBOARD]: (state, { payload }) => (state || []).concat(payload), [DELETE_DASHBOARD]: (state, { payload }) => (state || []).filter(d => d.id !== payload), [SAVE_DASHBOARD]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? payload : d), [UPDATE_DASHBOARD]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? payload : d), + [SET_FAVORITED]: (state, { payload }) => (state || []).map(d => d.id === payload.id ? {...d, favorite: payload.favorite} : d), + [SET_ARCHIVED]: (state, {payload}) => payload.archived + ? (state || []).filter(d => d.id !== payload.id) + : (state || []).concat(payload) }, null); export default combineReducers({ - dashboardListing + dashboardListing, + archive }); diff --git a/frontend/src/metabase/dashboards/selectors.js b/frontend/src/metabase/dashboards/selectors.js index 28affff034248..e49de25414e69 100644 --- a/frontend/src/metabase/dashboards/selectors.js +++ b/frontend/src/metabase/dashboards/selectors.js @@ -1 +1,2 @@ export const getDashboardListing = (state) => state.dashboards.dashboardListing; +export const getArchivedDashboards = (state) => state.dashboards.archive; diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js index b96cfc59f07c5..5bfb5e3f3ff3e 100644 --- a/frontend/src/metabase/icon_paths.js +++ b/frontend/src/metabase/icon_paths.js @@ -19,6 +19,7 @@ export var ICON_PATHS = { backArrow: 'M11.7416687,19.0096 L18.8461178,26.4181004 L14.2696969,30.568 L0.38960831,16.093881 L0,15.6875985 L0.49145276,15.241949 L14.6347557,1 L19.136,5.22693467 L11.3214393,13.096 L32,13.096 L32,19.0096 L11.7416687,19.0096 Z', bar: 'M2 23.467h6.4V32H2v-8.533zm10.667-12.8h6.4V32h-6.4V10.667zM23.333 0h6.4v32h-6.4V0z', beaker: 'M4.31736354,31.1631075 C3.93810558,30.6054137 3.89343681,29.6635358 4.20559962,29.0817181 L11.806982,14.9140486 L11.8069821,10.5816524 L10.7015144,10.4653256 C10.0309495,10.394763 9.48734928,9.78799739 9.48734928,9.12166999 L9.48734928,7.34972895 C9.48734928,6.67821106 10.0368737,6.13383825 10.7172248,6.13383825 L21.8462005,6.13383825 C22.525442,6.13383825 23.0760761,6.68340155 23.0760761,7.34972895 L23.0760761,9.12166999 C23.0760761,9.79318788 22.5250158,10.3375607 21.856025,10.3375607 L20.9787023,10.3375607 L20.9787024,14.9281806 L28.77277,29.0827118 C29.0983515,29.6739888 29.0709073,30.6193105 28.7174156,31.1846409 L28.852457,30.9686726 C28.4963041,31.538259 27.6541076,32 26.9865771,32 L6.10749779,32 C5.43315365,32 4.58248747,31.5529687 4.19978245,30.9902061 L4.31736354,31.1631075 Z M15.5771418,17.6040443 C16.5170398,17.6040443 17.2789777,16.8377777 17.2789777,15.89254 C17.2789777,14.9473023 16.5170398,14.1810358 15.5771418,14.1810358 C14.6372438,14.1810358 13.8753059,14.9473023 13.8753059,15.89254 C13.8753059,16.8377777 14.6372438,17.6040443 15.5771418,17.6040443 Z M16.5496195,12.8974079 C17.8587633,12.8974079 18.9200339,11.830108 18.9200339,10.5135268 C18.9200339,9.1969457 17.8587633,8.1296458 16.5496195,8.1296458 C15.2404758,8.1296458 14.1792052,9.1969457 14.1792052,10.5135268 C14.1792052,11.830108 15.2404758,12.8974079 16.5496195,12.8974079 Z M5.71098553,30.2209651 L10.9595331,20.5151267 C10.9595331,20.5151267 12.6834557,21.2672852 14.3734184,21.2672852 C16.0633811,21.2672852 16.8198616,19.2872624 17.588452,18.6901539 C18.3570425,18.0930453 19.9467191,17.1113296 19.9467191,17.1113296 L27.0506095,30.1110325 L5.71098553,30.2209651 Z M13.6608671,4.37817079 C14.4114211,4.37817079 15.0198654,3.78121712 15.0198654,3.04483745 C15.0198654,2.30845779 14.4114211,1.71150412 13.6608671,1.71150412 C12.9103132,1.71150412 12.3018689,2.30845779 12.3018689,3.04483745 C12.3018689,3.78121712 12.9103132,4.37817079 13.6608671,4.37817079 Z M17.9214578,2.45333328 C18.6119674,2.45333328 19.1717361,1.90413592 19.1717361,1.22666664 C19.1717361,0.549197362 18.6119674,0 17.9214578,0 C17.2309481,0 16.6711794,0.549197362 16.6711794,1.22666664 C16.6711794,1.90413592 17.2309481,2.45333328 17.9214578,2.45333328 Z', + breakout: 'M24.47 1H32v7.53h-7.53V1zm0 11.294H32v7.53h-7.53v-7.53zm0 11.294H32v7.53h-7.53v-7.53zM0 1h9.412v30.118H0V1zm11.731 13.714c.166-.183.452-.177.452-.177h6.475s-1.601-2.053-2.07-2.806c-.469-.753-.604-1.368 0-1.905.603-.536 1.226-.281 1.878.497.652.779 2.772 3.485 3.355 4.214.583.73.65 1.965 0 2.835-.65.87-2.65 4.043-3.163 4.65-.514.607-1.123.713-1.732.295-.609-.419-.838-1.187-.338-1.872.5-.684 2.07-3.073 2.07-3.073h-6.475s-.27 0-.46-.312-.151-.612-.151-.612l.007-1.246s-.014-.306.152-.488z', bubble: 'M18.155 20.882c-5.178-.638-9.187-5.051-9.187-10.402C8.968 4.692 13.66 0 19.448 0c5.789 0 10.48 4.692 10.48 10.48 0 3.05-1.302 5.797-3.383 7.712a7.127 7.127 0 1 1-8.39 2.69zm-6.392 10.14a2.795 2.795 0 1 1 0-5.59 2.795 2.795 0 0 1 0 5.59zm-6.079-6.288a4.541 4.541 0 1 1 0-9.083 4.541 4.541 0 0 1 0 9.083z', cards: 'M16.5,11 C16.1340991,11 15.7865579,10.9213927 15.4733425,10.7801443 L7.35245972,21.8211652 C7.7548404,22.264891 8,22.8538155 8,23.5 C8,24.8807119 6.88071187,26 5.5,26 C4.11928813,26 3,24.8807119 3,23.5 C3,22.1192881 4.11928813,21 5.5,21 C5.87370843,21 6.22826528,21.0819977 6.5466604,21.2289829 L14.6623495,10.1950233 C14.2511829,9.74948188 14,9.15407439 14,8.5 C14,7.11928813 15.1192881,6 16.5,6 C17.8807119,6 19,7.11928813 19,8.5 C19,8.96980737 18.8704088,9.4093471 18.6450228,9.78482291 L25.0405495,15.4699905 C25.4512188,15.1742245 25.9552632,15 26.5,15 C27.8807119,15 29,16.1192881 29,17.5 C29,18.8807119 27.8807119,20 26.5,20 C25.1192881,20 24,18.8807119 24,17.5 C24,17.0256697 24.1320984,16.5821926 24.3615134,16.2043506 L17.9697647,10.5225413 C17.5572341,10.8228405 17.0493059,11 16.5,11 Z M5.5,25 C6.32842712,25 7,24.3284271 7,23.5 C7,22.6715729 6.32842712,22 5.5,22 C4.67157288,22 4,22.6715729 4,23.5 C4,24.3284271 4.67157288,25 5.5,25 Z M26.5,19 C27.3284271,19 28,18.3284271 28,17.5 C28,16.6715729 27.3284271,16 26.5,16 C25.6715729,16 25,16.6715729 25,17.5 C25,18.3284271 25.6715729,19 26.5,19 Z M16.5,10 C17.3284271,10 18,9.32842712 18,8.5 C18,7.67157288 17.3284271,7 16.5,7 C15.6715729,7 15,7.67157288 15,8.5 C15,9.32842712 15.6715729,10 16.5,10 Z', calendar: { @@ -55,6 +56,7 @@ export var ICON_PATHS = { database: 'M1.18285296e-08,10.5127919 C-1.47856568e-08,7.95412848 1.18285298e-08,4.57337284 1.18285298e-08,4.57337284 C1.18285298e-08,4.57337284 1.58371041,5.75351864e-10 15.6571342,0 C29.730558,-5.7535027e-10 31.8900148,4.13849684 31.8900148,4.57337284 L31.8900148,10.4843058 C31.8900148,10.4843058 30.4448001,15.1365942 16.4659751,15.1365944 C2.48715012,15.1365947 2.14244494e-08,11.4353349 1.18285296e-08,10.5127919 Z M0.305419478,21.1290071 C0.305419478,21.1290071 0.0405133833,21.2033291 0.0405133833,21.8492606 L0.0405133833,27.3032816 C0.0405133833,27.3032816 1.46515486,31.941655 15.9641228,31.941655 C30.4630908,31.941655 32,27.3446712 32,27.3446712 C32,27.3446712 32,21.7986104 32,21.7986105 C32,21.2073557 31.6620557,21.0987647 31.6620557,21.0987647 C31.6620557,21.0987647 29.7146434,25.22314 16.0318829,25.22314 C2.34912233,25.22314 0.305419478,21.1290071 0.305419478,21.1290071 Z M0.305419478,12.656577 C0.305419478,12.656577 0.0405133833,12.730899 0.0405133833,13.3768305 L0.0405133833,18.8308514 C0.0405133833,18.8308514 1.46515486,23.4692249 15.9641228,23.4692249 C30.4630908,23.4692249 32,18.8722411 32,18.8722411 C32,18.8722411 32,13.3261803 32,13.3261803 C32,12.7349256 31.6620557,12.6263346 31.6620557,12.6263346 C31.6620557,12.6263346 29.7146434,16.7507099 16.0318829,16.7507099 C2.34912233,16.7507099 0.305419478,12.656577 0.305419478,12.656577 Z', dashboard: 'M32,29 L32,4 L32,0 L0,0 L0,8 L28,8 L28,28 L4,28 L4,8 L0,8 L0,29.5 L0,32 L32,32 L32,29 Z M7.27272727,18.9090909 L17.4545455,18.9090909 L17.4545455,23.2727273 L7.27272727,23.2727273 L7.27272727,18.9090909 Z M7.27272727,12.0909091 L24.7272727,12.0909091 L24.7272727,16.4545455 L7.27272727,16.4545455 L7.27272727,12.0909091 Z M20.3636364,18.9090909 L24.7272727,18.9090909 L24.7272727,23.2727273 L20.3636364,23.2727273 L20.3636364,18.9090909 Z', dashboards: 'M17,5.49100518 L17,10.5089948 C17,10.7801695 17.2276528,11 17.5096495,11 L26.4903505,11 C26.7718221,11 27,10.7721195 27,10.5089948 L27,5.49100518 C27,5.21983051 26.7723472,5 26.4903505,5 L17.5096495,5 C17.2281779,5 17,5.22788048 17,5.49100518 Z M18.5017326,14 C18.225722,14 18,13.77328 18,13.4982674 L18,26.5017326 C18,26.225722 18.22672,26 18.5017326,26 L5.49826741,26 C5.77427798,26 6,26.22672 6,26.5017326 L6,13.4982674 C6,13.774278 5.77327997,14 5.49826741,14 L18.5017326,14 Z M14.4903505,6 C14.2278953,6 14,5.78028538 14,5.49100518 L14,10.5089948 C14,10.2167107 14.2224208,10 14.4903505,10 L5.50964952,10 C5.77210473,10 6,10.2197146 6,10.5089948 L6,5.49100518 C6,5.78328929 5.77757924,6 5.50964952,6 L14.4903505,6 Z M26.5089948,22 C26.2251201,22 26,21.7774008 26,21.4910052 L26,26.5089948 C26,26.2251201 26.2225992,26 26.5089948,26 L21.4910052,26 C21.7748799,26 22,26.2225992 22,26.5089948 L22,21.4910052 C22,21.7748799 21.7774008,22 21.4910052,22 L26.5089948,22 Z M26.5089948,14 C26.2251201,14 26,13.7774008 26,13.4910052 L26,18.5089948 C26,18.2251201 26.2225992,18 26.5089948,18 L21.4910052,18 C21.7748799,18 22,18.2225992 22,18.5089948 L22,13.4910052 C22,13.7748799 21.7774008,14 21.4910052,14 L26.5089948,14 Z M26.4903505,6 C26.2278953,6 26,5.78028538 26,5.49100518 L26,10.5089948 C26,10.2167107 26.2224208,10 26.4903505,10 L17.5096495,10 C17.7721047,10 18,10.2197146 18,10.5089948 L18,5.49100518 C18,5.78328929 17.7775792,6 17.5096495,6 L26.4903505,6 Z M5,13.4982674 L5,26.5017326 C5,26.7769181 5.21990657,27 5.49826741,27 L18.5017326,27 C18.7769181,27 19,26.7800934 19,26.5017326 L19,13.4982674 C19,13.2230819 18.7800934,13 18.5017326,13 L5.49826741,13 C5.22308192,13 5,13.2199066 5,13.4982674 Z M5,5.49100518 L5,10.5089948 C5,10.7801695 5.22765279,11 5.50964952,11 L14.4903505,11 C14.7718221,11 15,10.7721195 15,10.5089948 L15,5.49100518 C15,5.21983051 14.7723472,5 14.4903505,5 L5.50964952,5 C5.22817786,5 5,5.22788048 5,5.49100518 Z M21,21.4910052 L21,26.5089948 C21,26.7801695 21.2278805,27 21.4910052,27 L26.5089948,27 C26.7801695,27 27,26.7721195 27,26.5089948 L27,21.4910052 C27,21.2198305 26.7721195,21 26.5089948,21 L21.4910052,21 C21.2198305,21 21,21.2278805 21,21.4910052 Z M21,13.4910052 L21,18.5089948 C21,18.7801695 21.2278805,19 21.4910052,19 L26.5089948,19 C26.7801695,19 27,18.7721195 27,18.5089948 L27,13.4910052 C27,13.2198305 26.7721195,13 26.5089948,13 L21.4910052,13 C21.2198305,13 21,13.2278805 21,13.4910052 Z', + curve: 'M3.033 3.791v22.211H31.09c.403 0 .882.872.882 1.59 0 .717-.48 1.408-.882 1.408H0V3.791c0-.403.875-.914 1.487-.914.612 0 1.546.511 1.546.914zm3.804 17.912C5.714 21.495 5 20.318 5 19.355c0-.963.831-2.296 1.837-2.296 2.093 0 2.965-1.207 4.204-5.242l.148-.482C12.798 6.077 14.18 3 17.968 3c3.792 0 5.17 3.08 6.765 8.343l.145.478c1.227 4.034 2.093 5.238 4.181 5.238 1.006 0 1.875 1.29 1.875 2.296 0 1.007-.898 2.184-1.875 2.348-3.656.612-6.004-2.364-7.665-7.821l-.146-.482c-1.14-3.76-1.8-6.754-3.28-6.754-1.483 0-2.147 2.995-3.297 6.754l-.148.486c-1.675 5.454-3.93 8.514-7.686 7.817z', document: 'M29,10.1052632 L29,28.8325291 C29,30.581875 27.5842615,32 25.8337327,32 L7.16626728,32 C5.41758615,32 4,30.5837102 4,28.8441405 L4,3.15585953 C4,1.41292644 5.42339685,9.39605581e-15 7.15970573,8.42009882e-15 L20.713352,8.01767853e-16 L20.713352,8.42105263 L22.3846872,8.42105263 L22.3846872,0.310375032 L28.7849894,8.42105263 L20.713352,8.42105263 L20.713352,10.1052632 L29,10.1052632 Z M7.3426704,12.8000006 L25.7273576,12.8000006 L25.7273576,14.4842112 L7.3426704,14.4842112 L7.3426704,12.8000006 Z M7.3426704,17.3473687 L25.7273576,17.3473687 L25.7273576,19.0315793 L7.3426704,19.0315793 L7.3426704,17.3473687 Z M7.3426704,21.8947352 L25.7273576,21.8947352 L25.7273576,23.5789458 L7.3426704,23.5789458 L7.3426704,21.8947352 Z M7.43137255,26.2736849 L16.535014,26.2736849 L16.535014,27.9578954 L7.43137255,27.9578954 L7.43137255,26.2736849 Z', downarrow: 'M12.2782161,19.3207547 L12.2782161,0 L19.5564322,0 L19.5564322,19.3207547 L26.8346484,19.3207547 L15.9173242,32 L5,19.3207547 L12.2782161,19.3207547 Z', download: { @@ -92,6 +94,15 @@ export var ICON_PATHS = { }, funnel: 'M3.18586974,3.64621479 C2.93075885,3.28932022 3.08031197,3 3.5066208,3 L28.3780937,3 C28.9190521,3 29.0903676,3.34981042 28.7617813,3.77995708 L18.969764,16.5985181 L18.969764,24.3460671 C18.969764,24.8899179 18.5885804,25.5564176 18.133063,25.8254534 C18.133063,25.8254534 12.5698889,29.1260709 12.5673818,28.9963552 C12.4993555,25.4767507 12.5749031,16.7812673 12.5749031,16.7812673 L3.18586974,3.64621479 Z', funneladd: 'M22.5185184,5.27947653 L17.2510286,5.27947653 L17.2510286,9.50305775 L22.5185184,9.50305775 L22.5185184,14.7825343 L26.7325102,14.7825343 L26.7325102,9.50305775 L32,9.50305775 L32,5.27947653 L26.7325102,5.27947653 L26.7325102,0 L22.5185184,0 L22.5185184,5.27947653 Z M14.9369872,0.791920724 C14.9369872,0.791920724 2.77552871,0.83493892 1.86648164,0.83493892 C0.957434558,0.83493892 0.45215388,1.50534608 0.284450368,1.77831828 C0.116746855,2.05129048 -0.317642562,2.91298361 0.398382661,3.9688628 C1.11440788,5.024742 9.74577378,17.8573356 9.74577378,17.8573356 C9.74577378,17.8573356 9.74577394,28.8183645 9.74577378,29.6867194 C9.74577362,30.5550744 9.83306175,31.1834301 10.7557323,31.6997692 C11.6784029,32.2161084 12.4343349,31.9564284 12.7764933,31.7333621 C13.1186517,31.5102958 19.6904355,27.7639669 20.095528,27.4682772 C20.5006204,27.1725875 20.7969652,26.5522071 20.7969651,25.7441659 C20.7969649,24.9361247 20.7969651,18.2224765 20.7969651,18.2224765 L21.6163131,16.9859755 L18.152048,15.0670739 C18.152048,15.0670739 17.3822517,16.199685 17.2562629,16.4000338 C17.1302741,16.6003826 16.8393552,16.9992676 16.8393551,17.7062886 C16.8393549,18.4133095 16.8393551,24.9049733 16.8393551,24.9049733 L13.7519708,26.8089871 C13.7519708,26.8089871 13.7318369,18.3502323 13.7318367,17.820601 C13.7318366,17.2909696 13.8484216,16.6759061 13.2410236,15.87149 C12.6336257,15.0670739 5.59381579,4.76288686 5.59381579,4.76288686 L14.9359238,4.76288686 L14.9369872,0.791920724 Z', + funneloutline: { + path: 'M3.186 3.646C2.93 3.29 3.08 3 3.506 3h24.872c.541 0 .712.35.384.78L18.97 16.599v7.747c0 .544-.381 1.21-.837 1.48 0 0-5.563 3.3-5.566 3.17-.068-3.52.008-12.215.008-12.215L3.185 3.646z', + attrs: { + stroke: "currentcolor", + strokeWidth: "4", + fill: "none", + fillRule: "evenodd" + } + }, folder: "M3.96901618e-15,5.41206355 L0.00949677904,29 L31.8821132,29 L31.8821132,10.8928571 L18.2224205,10.8928571 L15.0267944,5.41206355 L3.96901618e-15,5.41206355 Z M16.8832349,5.42402804 L16.8832349,4.52140947 C16.8832349,3.68115822 17.5639241,3 18.4024298,3 L27.7543992,3 L30.36417,3 C31.2031259,3 31.8832341,3.67669375 31.8832341,4.51317691 L31.8832341,7.86669975 L31.8832349,8.5999999 L18.793039,8.5999999 L16.8832349,5.42402804 Z", gear: 'M14 0 H18 L19 6 L20.707 6.707 L26 3.293 L28.707 6 L25.293 11.293 L26 13 L32 14 V18 L26 19 L25.293 20.707 L28.707 26 L26 28.707 L20.707 25.293 L19 26 L18 32 L14 32 L13 26 L11.293 25.293 L6 28.707 L3.293 26 L6.707 20.707 L6 19 L0 18 L0 14 L6 13 L6.707 11.293 L3.293 6 L6 3.293 L11.293 6.707 L13 6 L14 0 z M16 10 A6 6 0 0 0 16 22 A6 6 0 0 0 16 10', grabber: 'M0,5 L32,5 L32,9.26666667 L0,9.26666667 L0,5 Z M0,13.5333333 L32,13.5333333 L32,17.8 L0,17.8 L0,13.5333333 Z M0,22.0666667 L32,22.0666667 L32,26.3333333 L0,26.3333333 L0,22.0666667 Z', @@ -150,6 +161,8 @@ export var ICON_PATHS = { path: 'M0 11.996A3.998 3.998 0 0 1 4.004 8h23.992A4 4 0 0 1 32 11.996v8.008A3.998 3.998 0 0 1 27.996 24H4.004A4 4 0 0 1 0 20.004v-8.008zM22 11h3.99A3.008 3.008 0 0 1 29 14v4c0 1.657-1.35 3-3.01 3H22V11z', attrs: { fillRule: 'evenodd' } }, + sort: 'M14.615.683c.765-.926 2.002-.93 2.77 0L26.39 11.59c.765.927.419 1.678-.788 1.678H6.398c-1.2 0-1.557-.747-.788-1.678L14.615.683zm2.472 30.774c-.6.727-1.578.721-2.174 0l-9.602-11.63c-.6-.727-.303-1.316.645-1.316h20.088c.956 0 1.24.595.645 1.316l-9.602 11.63z', + sum: 'M3 27.41l1.984 4.422L27.895 32l.04-5.33-17.086-.125 8.296-9.457-.08-3.602L11.25 5.33H27.43V0H5.003L3.08 4.51l10.448 10.9z', sync: 'M16 2 A14 14 0 0 0 2 16 A14 14 0 0 0 16 30 A14 14 0 0 0 26 26 L 23.25 23 A10 10 0 0 1 16 26 A10 10 0 0 1 6 16 A10 10 0 0 1 16 6 A10 10 0 0 1 23.25 9 L19 13 L30 13 L30 2 L26 6 A14 14 0 0 0 16 2', question: "M16,32 C24.836556,32 32,24.836556 32,16 C32,7.163444 24.836556,0 16,0 C7.163444,0 0,7.163444 0,16 C0,24.836556 7.163444,32 16,32 L16,32 Z M16,29.0909091 C8.77009055,29.0909091 2.90909091,23.2299095 2.90909091,16 C2.90909091,8.77009055 8.77009055,2.90909091 16,2.90909091 C23.2299095,2.90909091 29.0909091,8.77009055 29.0909091,16 C29.0909091,23.2299095 23.2299095,29.0909091 16,29.0909091 Z M12,9.56020942 C12.2727286,9.34380346 12.5694087,9.1413622 12.8900491,8.95287958 C13.2106896,8.76439696 13.5552807,8.59860455 13.9238329,8.45549738 C14.2923851,8.31239021 14.6885728,8.20069848 15.1124079,8.12041885 C15.5362429,8.04013921 15.9950835,8 16.4889435,8 C17.1818216,8 17.8065083,8.08725916 18.3630221,8.2617801 C18.919536,8.43630105 19.3931184,8.68586225 19.7837838,9.0104712 C20.1744491,9.33508016 20.4748147,9.7260012 20.6848894,10.1832461 C20.8949642,10.6404909 21,11.1483393 21,11.7068063 C21,12.2373499 20.9226052,12.6963331 20.7678133,13.0837696 C20.6130213,13.4712061 20.4176916,13.8080265 20.1818182,14.0942408 C19.9459448,14.3804552 19.6861194,14.6282712 19.4023342,14.8376963 C19.1185489,15.0471215 18.8495099,15.2408368 18.5952088,15.4188482 C18.3409078,15.5968595 18.1197798,15.773123 17.9318182,15.947644 C17.7438566,16.1221649 17.6240789,16.3176254 17.5724816,16.5340314 L17.2628993,18 L14.9189189,18 L14.6756757,16.3141361 C14.6167073,15.9720751 14.653562,15.6736487 14.7862408,15.4188482 C14.9189196,15.1640476 15.1013502,14.9336834 15.3335381,14.7277487 C15.565726,14.521814 15.8255514,14.3263535 16.1130221,14.1413613 C16.4004928,13.9563691 16.6695319,13.7574182 16.9201474,13.5445026 C17.1707629,13.3315871 17.3826773,13.0942421 17.5558968,12.8324607 C17.7291163,12.5706793 17.8157248,12.2582915 17.8157248,11.895288 C17.8157248,11.4764377 17.6701489,11.1431077 17.3789926,10.895288 C17.0878364,10.6474682 16.6879632,10.5235602 16.1793612,10.5235602 C15.7886958,10.5235602 15.462532,10.5619542 15.20086,10.6387435 C14.9391879,10.7155327 14.7143744,10.8010466 14.5264128,10.895288 C14.3384511,10.9895293 14.1744479,11.0750432 14.034398,11.1518325 C13.8943482,11.2286217 13.7543005,11.2670157 13.6142506,11.2670157 C13.2972957,11.2670157 13.0614258,11.1378721 12.9066339,10.8795812 L12,9.56020942 Z M14,22 C14,21.7192968 14.0511359,21.4580909 14.1534091,21.2163743 C14.2556823,20.9746577 14.3958324,20.7641335 14.5738636,20.5847953 C14.7518948,20.4054572 14.96212,20.2631584 15.2045455,20.1578947 C15.4469709,20.0526311 15.7121198,20 16,20 C16.2803044,20 16.5416655,20.0526311 16.7840909,20.1578947 C17.0265164,20.2631584 17.2386355,20.4054572 17.4204545,20.5847953 C17.6022736,20.7641335 17.7443177,20.9746577 17.8465909,21.2163743 C17.9488641,21.4580909 18,21.7192968 18,22 C18,22.2807032 17.9488641,22.5438584 17.8465909,22.7894737 C17.7443177,23.0350889 17.6022736,23.2475625 17.4204545,23.4269006 C17.2386355,23.6062387 17.0265164,23.7465882 16.7840909,23.8479532 C16.5416655,23.9493182 16.2803044,24 16,24 C15.7121198,24 15.4469709,23.9493182 15.2045455,23.8479532 C14.96212,23.7465882 14.7518948,23.6062387 14.5738636,23.4269006 C14.3958324,23.2475625 14.2556823,23.0350889 14.1534091,22.7894737 C14.0511359,22.5438584 14,22.2807032 14,22 Z", return:'M15.3040432,11.8500793 C22.1434689,13.0450349 27.291257,18.2496116 27.291257,24.4890512 C27.291257,25.7084278 27.0946472,26.8882798 26.7272246,28.0064033 L26.7272246,28.0064033 C25.214579,22.4825472 20.8068367,18.2141694 15.3040432,17.0604596 L15.3040432,25.1841972 L4.70874296,14.5888969 L15.3040432,3.99359668 L15.3040432,3.99359668 L15.3040432,11.8500793 Z', @@ -192,6 +205,7 @@ export var ICON_PATHS = { attrs: { fillRule: "evenodd" } }, x: 'm11.271709,16 l-3.19744231e-13,4.728291 l4.728291,0 l16,11.271709 l27.271709,2.39808173e-13 l32,4.728291 l20.728291,16 l31.1615012,26.4332102 l26.4332102,31.1615012 l16,20.728291 l5.56678976,31.1615012 l0.838498756,26.4332102 l11.271709,16 z', + zoom: 'M12.416 12.454V8.37h3.256v4.083h4.07v3.266h-4.07v4.083h-3.256V15.72h-4.07v-3.266h4.07zm10.389 13.28c-5.582 4.178-13.543 3.718-18.632-1.37-5.58-5.581-5.595-14.615-.031-20.179 5.563-5.563 14.597-5.55 20.178.031 5.068 5.068 5.545 12.985 1.422 18.563l5.661 5.661a2.08 2.08 0 0 1 .003 2.949 2.085 2.085 0 0 1-2.95-.003l-5.651-5.652zm-1.486-4.371c3.895-3.895 3.885-10.218-.021-14.125-3.906-3.906-10.23-3.916-14.125-.021-3.894 3.894-3.885 10.218.022 14.124 3.906 3.907 10.23 3.916 14.124.022z', "slack": { img: "app/assets/img/slack.png" } @@ -216,7 +230,7 @@ export function loadIcon(name) { } if (def.img) { - return def; + return { ...def, attrs: { ...def.attrs, className: 'Icon Icon-' + name } }; } var icon = { diff --git a/frontend/src/metabase/internal/__snapshots__/components.spec.js.snap b/frontend/src/metabase/internal/__snapshots__/components.spec.js.snap index b4c8cdfaf480f..ecd542cc21ab2 100644 --- a/frontend/src/metabase/internal/__snapshots__/components.spec.js.snap +++ b/frontend/src/metabase/internal/__snapshots__/components.spec.js.snap @@ -36,7 +36,7 @@ exports[`Button should render "with an icon" correctly 1`] = ` className="flex layout-centered" > { + const savedCardFields = { + name: "Example Saved Question", + description: "For satisfying your craving for information", + created_at: "2017-04-20T16:52:55.353Z", + id: CARD_ID + }; + + return { + "name": null, + "display": display, + "visualization_settings": {}, + "dataset_query": { + "database": database, + "type": isNative ? "native" : "query", + ...(!isNative ? { + query: { + ...(table ? {"source_table": table} : {}), + ...queryFields + } + } : {}), + ...(isNative ? { + native: { query: "SELECT * FROM ORDERS"} + } : {}) + }, + ...(newCard ? {} : savedCardFields), + ...(hasOriginalCard ? {"original_card_id": CARD_ID} : {}) + }; +}; + +describe("browser", () => { + describe("isCardDirty", () => { + it("should consider a new card clean if no db table or native query is defined", () => { + expect(isCardDirty( + getCard({newCard: true}), + null + )).toBe(false); + }); + it("should consider a new card dirty if a db table is chosen", () => { + expect(isCardDirty( + getCard({newCard: true, table: 5}), + null + )).toBe(true); + }); + it("should consider a new card dirty if there is any content on the native query", () => { + expect(isCardDirty( + getCard({newCard: true, table: 5}), + null + )).toBe(true); + }); + it("should consider a saved card and a matching original card identical", () => { + expect(isCardDirty( + getCard({hasOriginalCard: true}), + getCard({hasOriginalCard: false}) + )).toBe(false); + }); + it("should consider a saved card dirty if the current card doesn't match the last saved version", () => { + expect(isCardDirty( + getCard({hasOriginalCard: true, queryFields: [["field-id", 21]]}), + getCard({hasOriginalCard: false}) + )).toBe(true); + }); + }); + describe("serializeCardForUrl", () => { + it("should include `original_card_id` property to the serialized URL", () => { + const cardAfterSerialization = + deserializeCardFromUrl(serializeCardForUrl(getCard({hasOriginalCard: true}))); + expect(cardAfterSerialization).toHaveProperty("original_card_id", CARD_ID) + + }) + }) +}); diff --git a/frontend/src/metabase/lib/dom.js b/frontend/src/metabase/lib/dom.js index 26a11b0385b94..5a119f8620da3 100644 --- a/frontend/src/metabase/lib/dom.js +++ b/frontend/src/metabase/lib/dom.js @@ -90,7 +90,8 @@ export function getSelectionPosition(element) { else { try { const selection = window.getSelection(); - const range = selection.getRangeAt(0); + // Clone the Range otherwise setStart/setEnd will mutate the actual selection in Chrome 58+ and Firefox! + const range = selection.getRangeAt(0).cloneRange(); const { startContainer, startOffset } = range; range.setStart(element, 0); const end = range.toString().length; diff --git a/frontend/src/metabase/lib/formatting.js b/frontend/src/metabase/lib/formatting.js index 3aa487d2ec9f0..b1e978f32c361 100644 --- a/frontend/src/metabase/lib/formatting.js +++ b/frontend/src/metabase/lib/formatting.js @@ -1,3 +1,5 @@ +/* @flow */ + import d3 from "d3"; import inflection from "inflection"; import moment from "moment"; @@ -10,12 +12,25 @@ import { isDate, isNumber, isCoordinate } from "metabase/lib/schema_metadata"; import { isa, TYPE } from "metabase/lib/types"; import { parseTimestamp } from "metabase/lib/time"; +import type { Column, Value } from "metabase/meta/types/Dataset"; +import type { DatetimeUnit } from "metabase/meta/types/Query"; +import type { Moment } from "metabase/meta/types"; + +export type FormattingOptions = { + column?: Column, + majorWidth?: number, + type?: "axis"|"cell"|"tooltip", + comma?: boolean, + jsx?: boolean, + compact?: boolean, +} + const PRECISION_NUMBER_FORMATTER = d3.format(".2r"); const FIXED_NUMBER_FORMATTER = d3.format(",.f"); const FIXED_NUMBER_FORMATTER_NO_COMMA = d3.format(".f"); const DECIMAL_DEGREES_FORMATTER = d3.format(".08f"); -export function formatNumber(number, options = {}) { +export function formatNumber(number: number, options: FormattingOptions = {}) { options = { comma: true, ...options}; if (options.compact) { if (number === 0) { @@ -64,20 +79,64 @@ function formatMajorMinor(major, minor, options = {}) { } } -export function formatTimeWithUnit(value, unit, options = {}) { +/** This formats a time with unit as a date range */ +export function formatTimeRangeWithUnit(value: Value, unit: DatetimeUnit, options: FormattingOptions = {}) { + let m = parseTimestamp(value, unit); + if (!m.isValid()) { + return String(value); + } + + // Tooltips should show full month name, but condense "MMMM D, YYYY - MMMM D, YYYY" to "MMMM D - D, YYYY" etc + const monthFormat = options.type === "tooltip" ? "MMMM" : "MMM"; + const condensed = options.type === "tooltip"; + // use en dashes, for Maz + const separator = ` – `; + + const start = m.clone().startOf(unit); + const end = m.clone().endOf(unit); + if (start.isValid() && end.isValid()) { + if (!condensed || start.year() !== end.year()) { + return start.format(`${monthFormat} D, YYYY`) + separator + end.format(`${monthFormat} D, YYYY`); + } else if (start.month() !== end.month()) { + return start.format(`${monthFormat} D`) + separator + end.format(`${monthFormat} D, YYYY`); + } else { + return start.format(`${monthFormat} D`) + separator + end.format(`D, YYYY`); + } + } else { + return formatWeek(m, options); + } +} + +function formatWeek(m: Moment, options: FormattingOptions = {}) { + // force 'en' locale for now since our weeks currently always start on Sundays + m = m.locale("en"); + return formatMajorMinor(m.format("wo"), m.format("gggg"), options); +} + +export function formatTimeWithUnit(value: Value, unit: DatetimeUnit, options: FormattingOptions = {}) { let m = parseTimestamp(value, unit); if (!m.isValid()) { return String(value); } + switch (unit) { case "hour": // 12 AM - January 1, 2015 return formatMajorMinor(m.format("h A"), m.format("MMMM D, YYYY"), options); case "day": // January 1, 2015 return m.format("MMMM D, YYYY"); case "week": // 1st - 2015 - // force 'en' locale for now since our weeks currently always start on Sundays - m = m.locale("en"); - return formatMajorMinor(m.format("wo"), m.format("gggg"), options); + if (options.type === "tooltip") { + // tooltip show range like "January 1 - 7, 2017" + return formatTimeRangeWithUnit(value, unit, options); + } else if (options.type === "cell") { + // table cells show range like "Jan 1, 2017 - Jan 7, 2017" + return formatTimeRangeWithUnit(value, unit, options); + } else if (options.type === "axis") { + // axis ticks show start of the week as "Jan 1" + return m.clone().startOf(unit).format(`MMM D`); + } else { + return formatWeek(m, options); + } case "month": // January 2015 return options.jsx ?
    {m.format("MMMM")} {m.format("YYYY")}
    : @@ -89,12 +148,14 @@ export function formatTimeWithUnit(value, unit, options = {}) { case "hour-of-day": // 12 AM return moment().hour(value).format("h A"); case "day-of-week": // Sunday + // $FlowFixMe: return moment().day(value - 1).format("dddd"); case "day-of-month": return moment().date(value).format("D"); case "week-of-year": // 1st return moment().week(value).format("wo"); case "month-of-year": // January + // $FlowFixMe: return moment().month(value - 1).format("MMMM"); case "quarter-of-year": // January return moment().quarter(value).format("[Q]Q"); @@ -106,27 +167,29 @@ export function formatTimeWithUnit(value, unit, options = {}) { // https://github.com/angular/angular.js/blob/v1.6.3/src/ng/directive/input.js#L27 const EMAIL_WHITELIST_REGEX = /^(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/; -export function formatEmail(value, { jsx } = {}) { - if (jsx && EMAIL_WHITELIST_REGEX.test(value)) { - return {value}; +export function formatEmail(value: Value, { jsx }: FormattingOptions = {}) { + const email = String(value); + if (jsx && EMAIL_WHITELIST_REGEX.test(email)) { + return {email}; } else { - return value; + return email; } } // based on https://github.com/angular/angular.js/blob/v1.6.3/src/ng/directive/input.js#L25 const URL_WHITELIST_REGEX = /^(https?|mailto):\/*(?:[^:@]+(?::[^@]+)?@)?(?:[^\s:/?#]+|\[[a-f\d:]+])(?::\d+)?(?:\/[^?#]*)?(?:\?[^#]*)?(?:#.*)?$/i; -export function formatUrl(value, { jsx } = {}) { - if (jsx && URL_WHITELIST_REGEX.test(value)) { - return {value}; +export function formatUrl(value: Value, { jsx }: FormattingOptions = {}) { + const url = String(value); + if (jsx && URL_WHITELIST_REGEX.test(url)) { + return {url}; } else { - return value; + return url; } } // fallback for formatting a string without a column special_type -function formatStringFallback(value, options = {}) { +function formatStringFallback(value: Value, options: FormattingOptions = {}) { value = formatUrl(value, options); if (typeof value === 'string') { value = formatEmail(value, options); @@ -134,7 +197,7 @@ function formatStringFallback(value, options = {}) { return value; } -export function formatValue(value, options = {}) { +export function formatValue(value: Value, options: FormattingOptions = {}) { let column = options.column; options = { jsx: false, @@ -167,31 +230,37 @@ export function formatValue(value, options = {}) { } } +// $FlowFixMe export function singularize(...args) { return inflection.singularize(...args); } +// $FlowFixMe export function pluralize(...args) { return inflection.pluralize(...args); } +// $FlowFixMe export function capitalize(...args) { return inflection.capitalize(...args); } +// $FlowFixMe export function inflect(...args) { return inflection.inflect(...args); } +// $FlowFixMe export function titleize(...args) { return inflection.titleize(...args); } +// $FlowFixMe export function humanize(...args) { return inflection.humanize(...args); } -export function duration(milliseconds) { +export function duration(milliseconds: number) { if (milliseconds < 60000) { let seconds = Math.round(milliseconds / 1000); return seconds + " " + inflect("second", seconds); @@ -202,15 +271,15 @@ export function duration(milliseconds) { } // Removes trailing "id" from field names -export function stripId(name) { +export function stripId(name: string) { return name && name.replace(/ id$/i, ""); } -export function slugify(name) { +export function slugify(name: string) { return name && name.toLowerCase().replace(/[^a-z0-9_]/g, "_"); } -export function assignUserColors(userIds, currentUserId, colorClasses = ['bg-brand', 'bg-purple', 'bg-error', 'bg-green', 'bg-gold', 'bg-grey-2']) { +export function assignUserColors(userIds: number[], currentUserId: number, colorClasses: string[] = ['bg-brand', 'bg-purple', 'bg-error', 'bg-green', 'bg-gold', 'bg-grey-2']) { let assignments = {}; const currentUserColor = colorClasses[0]; @@ -230,7 +299,7 @@ export function assignUserColors(userIds, currentUserId, colorClasses = ['bg-bra return assignments; } -export function formatSQL(sql) { +export function formatSQL(sql: string) { if (typeof sql === "string") { sql = sql.replace(/\sFROM/, "\nFROM"); sql = sql.replace(/\sLEFT JOIN/, "\nLEFT JOIN"); diff --git a/frontend/src/metabase/lib/permissions.js b/frontend/src/metabase/lib/permissions.js index 8c1fd837bf3b9..cf2e925ee2deb 100644 --- a/frontend/src/metabase/lib/permissions.js +++ b/frontend/src/metabase/lib/permissions.js @@ -1,10 +1,11 @@ /* @flow */ import { getIn, setIn } from "icepick"; +import _ from "underscore"; import type Database from "metabase/meta/metadata/Database"; import type { DatabaseId } from "metabase/meta/types/Database"; -import type { SchemaName, TableId } from "metabase/meta/types/Table"; +import type { SchemaName, TableId, Table } from "metabase/meta/types/Table"; import Metadata from "metabase/meta/metadata/Metadata"; import type { Group, GroupId, GroupsPermissions } from "metabase/meta/types/Permissions"; @@ -12,6 +13,7 @@ import type { Group, GroupId, GroupsPermissions } from "metabase/meta/types/Perm type TableEntityId = { databaseId: DatabaseId, schemaName: SchemaName, tableId: TableId }; type SchemaEntityId = { databaseId: DatabaseId, schemaName: SchemaName }; type DatabaseEntityId = { databaseId: DatabaseId }; +type EntityId = TableEntityId | SchemaEntityId | DatabaseEntityId; export function getPermission( permissions: GroupsPermissions, @@ -92,7 +94,80 @@ export const getFieldsPermission = (permissions: GroupsPermissions, groupId: Gro } } -export function updateFieldsPermission(permissions: GroupsPermissions, groupId: GroupId, { databaseId, schemaName, tableId }: TableEntityId, value: string, metadata: Metadata): GroupsPermissions { +export function downgradeNativePermissionsIfNeeded(permissions: GroupsPermissions, groupId: GroupId, { databaseId }: DatabaseEntityId, value: string, metadata: Metadata): GroupsPermissions { + let currentSchemas = getSchemasPermission(permissions, groupId, { databaseId }); + let currentNative = getNativePermission(permissions, groupId, { databaseId }); + + if (value === "none") { + // if changing schemas to none, downgrade native to none + return updateNativePermission(permissions, groupId, { databaseId }, "none", metadata); + } else if (value === "controlled" && currentSchemas === "all" && currentNative === "write") { + // if changing schemas to controlled, downgrade native to read + return updateNativePermission(permissions, groupId, { databaseId }, "read", metadata); + } else { + return permissions; + } +} + +// $FlowFixMe +const metadataTableToTableEntityId = (table: Table): TableEntityId => ({ databaseId: table.db_id, schemaName: table.schema, tableId: table.id }); +const entityIdToMetadataTableFields = (entityId: EntityId) => ({ + ...(entityId.databaseId ? {db_id: entityId.databaseId} : {}), + ...(entityId.schemaName ? {schema: entityId.schemaName} : {}), + ...(entityId.tableId ? {tableId: entityId.tableId} : {}) +}) + +function inferEntityPermissionValueFromChildTables(permissions: GroupsPermissions, groupId: GroupId, entityId: DatabaseEntityId|SchemaEntityId, metadata: Metadata) { + const { databaseId } = entityId; + const database = metadata && metadata.database(databaseId); + + // $FlowFixMe + const entityIdsForDescendantTables: TableEntityId[] = _.chain(database.tables()) + .filter((t) => _.isMatch(t, entityIdToMetadataTableFields(entityId))) + .map(metadataTableToTableEntityId) + .value(); + + const entityIdsByPermValue = _.chain(entityIdsForDescendantTables) + .map((id) => getFieldsPermission(permissions, groupId, id)) + .groupBy(_.identity) + .value(); + + const keys = Object.keys(entityIdsByPermValue); + const allTablesHaveSamePermissions = keys.length === 1; + + if (allTablesHaveSamePermissions) { + // either "all" or "none" + return keys[0]; + } else { + return "controlled"; + } +} + +// Checks the child tables of a given entityId and updates the shared table and/or schema permission values according to table permissions +// This method was added for keeping the UI in sync when modifying child permissions +export function inferAndUpdateEntityPermissions(permissions: GroupsPermissions, groupId: GroupId, entityId: DatabaseEntityId|SchemaEntityId, metadata: Metadata) { + // $FlowFixMe + const { databaseId, schemaName } = entityId; + + if (schemaName) { + // Check all tables for current schema if their shared schema-level permission value should be updated + // $FlowFixMe + const tablesPermissionValue = inferEntityPermissionValueFromChildTables(permissions, groupId, { databaseId, schemaName }, metadata); + permissions = updateTablesPermission(permissions, groupId, { databaseId, schemaName }, tablesPermissionValue, metadata); + } + + if (databaseId) { + // Check all tables for current database if schemas' shared database-level permission value should be updated + const schemasPermissionValue = inferEntityPermissionValueFromChildTables(permissions, groupId, { databaseId }, metadata); + permissions = updateSchemasPermission(permissions, groupId, { databaseId }, schemasPermissionValue, metadata); + permissions = downgradeNativePermissionsIfNeeded(permissions, groupId, { databaseId }, schemasPermissionValue, metadata); + } + + return permissions; +} + +export function updateFieldsPermission(permissions: GroupsPermissions, groupId: GroupId, entityId: TableEntityId, value: string, metadata: Metadata): GroupsPermissions { + const { databaseId, schemaName, tableId } = entityId; permissions = updateTablesPermission(permissions, groupId, { databaseId, schemaName }, "controlled", metadata); permissions = updatePermission(permissions, groupId, [databaseId, "schemas", schemaName, tableId], value /* TODO: field ids, when enabled "controlled" fields */); @@ -102,7 +177,7 @@ export function updateFieldsPermission(permissions: GroupsPermissions, groupId: export function updateTablesPermission(permissions: GroupsPermissions, groupId: GroupId, { databaseId, schemaName }: SchemaEntityId, value: string, metadata: Metadata): GroupsPermissions { const database = metadata && metadata.database(databaseId); - const tableIds: ?number[] = database && database.tables().map(t => t.id); + const tableIds: ?number[] = database && database.tables().filter(t => t.schema === schemaName).map(t => t.id); permissions = updateSchemasPermission(permissions, groupId, { databaseId }, "controlled", metadata); permissions = updatePermission(permissions, groupId, [databaseId, "schemas", schemaName], value, tableIds); @@ -114,17 +189,7 @@ export function updateSchemasPermission(permissions: GroupsPermissions, groupId: let database = metadata.database(databaseId); let schemaNames = database && database.schemaNames(); - let currentSchemas = getSchemasPermission(permissions, groupId, { databaseId }); - let currentNative = getNativePermission(permissions, groupId, { databaseId }); - - if (value === "none") { - // if changing schemas to none, downgrade native to none - permissions = updateNativePermission(permissions, groupId, { databaseId }, "none", metadata); - } else if (value === "controlled" && currentSchemas === "all" && currentNative === "write") { - // if changing schemas to controlled, downgrade native to read - permissions = updateNativePermission(permissions, groupId, { databaseId }, "read", metadata); - } - + permissions = downgradeNativePermissionsIfNeeded(permissions, groupId, { databaseId }, value, metadata); return updatePermission(permissions, groupId, [databaseId, "schemas"], value, schemaNames); } diff --git a/frontend/src/metabase/lib/query.js b/frontend/src/metabase/lib/query.js index e8d0360e122e7..1b76eb778b6f5 100644 --- a/frontend/src/metabase/lib/query.js +++ b/frontend/src/metabase/lib/query.js @@ -390,7 +390,7 @@ var Query = { // TODO: we need to do something better here because filtering depends on knowing a sensible type for the field base_type: TYPE.Integer, operators_lookup: {}, - valid_operators: [], + operators: [], active: true, fk_target_field_id: null, parent_id: null, @@ -399,8 +399,8 @@ var Query = { target: null, visibility_type: "normal" }; - fieldDef.valid_operators = getOperators(fieldDef, tableDef); - fieldDef.operators_lookup = createLookupByProperty(fieldDef.valid_operators, "name"); + fieldDef.operators = getOperators(fieldDef, tableDef); + fieldDef.operators_lookup = createLookupByProperty(fieldDef.operators, "name"); return { table: tableDef, diff --git a/frontend/src/metabase/lib/schema_metadata.js b/frontend/src/metabase/lib/schema_metadata.js index ef30ddb624d8a..2228bcdd45ee1 100644 --- a/frontend/src/metabase/lib/schema_metadata.js +++ b/frontend/src/metabase/lib/schema_metadata.js @@ -169,7 +169,7 @@ function equivalentArgument(field, table) { if (isCategory(field)) { if (table.field_values && field.id in table.field_values && table.field_values[field.id].length > 0) { - let validValues = table.field_values[field.id]; + let validValues = [...table.field_values[field.id]]; // this sort function works for both numbers and strings: validValues.sort((a, b) => a === b ? 0 : (a < b ? -1 : 1)); return { @@ -475,7 +475,7 @@ export function getAggregator(short) { return _.findWhere(Aggregators, { short: short }); } -function getBreakouts(fields) { +export function getBreakouts(fields) { var result = populateFields(BreakoutAggregator, fields); result.fields = result.fields[0]; result.validFieldsFilter = result.validFieldsFilters[0]; @@ -484,7 +484,7 @@ function getBreakouts(fields) { export function addValidOperatorsToFields(table) { for (let field of table.fields) { - field.valid_operators = getOperators(field, table); + field.operators = getOperators(field, table); } table.aggregation_options = getAggregatorsWithFields(table); table.breakout_options = getBreakouts(table.fields); diff --git a/frontend/src/metabase/lib/table.js b/frontend/src/metabase/lib/table.js index ce06be09800af..b88b680136c80 100644 --- a/frontend/src/metabase/lib/table.js +++ b/frontend/src/metabase/lib/table.js @@ -35,7 +35,7 @@ export function augmentDatabase(database) { table.fields_lookup = createLookupByProperty(table.fields, "id"); for (let field of table.fields) { addFkTargets(field, database.tables_lookup); - field.operators_lookup = createLookupByProperty(field.valid_operators, "name"); + field.operators_lookup = createLookupByProperty(field.operators, "name"); } } return database; @@ -58,7 +58,7 @@ function populateQueryOptions(table) { _.each(table.fields, function(field) { table.fields_lookup[field.id] = field; field.operators_lookup = {}; - _.each(field.valid_operators, function(operator) { + _.each(field.operators, function(operator) { field.operators_lookup[operator.name] = operator; }); }); diff --git a/frontend/src/metabase/lib/urls.js b/frontend/src/metabase/lib/urls.js index 8ea28ce358eb0..3a83c230e064a 100644 --- a/frontend/src/metabase/lib/urls.js +++ b/frontend/src/metabase/lib/urls.js @@ -3,21 +3,31 @@ import MetabaseSettings from "metabase/lib/settings" // provides functions for building urls to things we care about -export function question(cardId, cardOrHash = "") { - if (cardOrHash && typeof cardOrHash === "object") { - cardOrHash = serializeCardForUrl(cardOrHash); +export function question(cardId, hash = "", query = "") { + if (hash && typeof hash === "object") { + hash = serializeCardForUrl(hash); } - if (cardOrHash && cardOrHash.charAt(0) !== "#") { - cardOrHash = "#" + cardOrHash; + if (query && typeof query === "object") { + query = Object.entries(query) + .map(kv => kv.map(encodeURIComponent).join("=")) + .join("&"); + } + if (hash && hash.charAt(0) !== "#") { + hash = "#" + hash; + } + if (query && query.charAt(0) !== "?") { + query = "?" + query; } // NOTE that this is for an ephemeral card link, not an editable card return cardId != null - ? `/question/${cardId}${cardOrHash}` - : `/question${cardOrHash}`; + ? `/question/${cardId}${query}${hash}` + : `/question${query}${hash}`; } -export function dashboard(dashboardId) { - return `/dashboard/${dashboardId}`; +export function dashboard(dashboardId, {addCardWithId} = {}) { + return addCardWithId != null + ? `/dashboard/${dashboardId}#add=${addCardWithId}` + : `/dashboard/${dashboardId}`; } export function modelToUrl(model, modelId) { diff --git a/frontend/src/metabase/meta/Card.js b/frontend/src/metabase/meta/Card.js index 9032a759139b6..ca2bc5d82cb5a 100644 --- a/frontend/src/metabase/meta/Card.js +++ b/frontend/src/metabase/meta/Card.js @@ -1,21 +1,24 @@ /* @flow */ -import type { StructuredQuery, NativeQuery, TemplateTag } from "./types/Query"; -import type { Card, DatasetQuery, StructuredDatasetQuery, NativeDatasetQuery } from "./types/Card"; -import type { Parameter, ParameterMapping, ParameterValues } from "./types/Parameter"; +import { getTemplateTagParameters, getParameterTargetFieldId, parameterToMBQLFilter } from "metabase/meta/Parameter"; -import { getTemplateTagParameters, getParameterTargetFieldId } from "metabase/meta/Parameter"; +import * as Query from "metabase/lib/query/query"; +import Q from "metabase/lib/query"; // legacy +import Utils from "metabase/lib/utils"; +import * as Urls from "metabase/lib/urls"; + +import _ from "underscore"; +import { assoc, updateIn } from "icepick"; -import { assoc } from "icepick"; +import type { StructuredQuery, NativeQuery, TemplateTag } from "metabase/meta/types/Query"; +import type { Card, DatasetQuery, StructuredDatasetQuery, NativeDatasetQuery } from "metabase/meta/types/Card"; +import type { Parameter, ParameterMapping, ParameterValues } from "metabase/meta/types/Parameter"; +import type { Metadata, TableMetadata } from "metabase/meta/types/Metadata"; declare class Object { static values(object: { [key:string]: T }): Array; } -import Query from "metabase/lib/query"; -import Utils from "metabase/lib/utils"; -import _ from "underscore"; - export const STRUCTURED_QUERY_TEMPLATE: StructuredDatasetQuery = { type: "query", database: null, @@ -56,6 +59,14 @@ export function canRun(card: Card): bool { } } +export function cardIsEquivalent(cardA: Card, cardB: Card): boolean { + cardA = updateIn(cardA, ["dataset_query", "parameters"], parameters => parameters || []); + cardB = updateIn(cardB, ["dataset_query", "parameters"], parameters => parameters || []); + cardA = _.pick(cardA, "dataset_query", "display", "visualization_settings"); + cardB = _.pick(cardB, "dataset_query", "display", "visualization_settings"); + return _.isEqual(cardA, cardB); +} + export function getQuery(card: Card): ?StructuredQuery { if (card.dataset_query.type === "query") { return card.dataset_query.query; @@ -64,8 +75,16 @@ export function getQuery(card: Card): ?StructuredQuery { } } +export function getTableMetadata(card: Card, metadata: Metadata): ?TableMetadata { + const query = getQuery(card); + if (query && query.source_table != null) { + return metadata.tables[query.source_table] || null; + } + return null; +} + export function getTemplateTags(card: ?Card): Array { - return card && card.dataset_query.type === "native" && card.dataset_query.native.template_tags ? + return card && card.dataset_query && card.dataset_query.type === "native" && card.dataset_query.native.template_tags ? Object.values(card.dataset_query.native.template_tags) : []; } @@ -103,7 +122,7 @@ export function applyParameters( const datasetQuery = Utils.copy(card.dataset_query); // clean the query if (datasetQuery.type === "query") { - datasetQuery.query = Query.cleanQuery(datasetQuery.query); + datasetQuery.query = Q.cleanQuery(datasetQuery.query); } datasetQuery.parameters = []; for (const parameter of parameters || []) { @@ -132,3 +151,48 @@ export function applyParameters( return datasetQuery; } + +/** returns a question URL with parameters added to query string or MBQL filters */ +export function questionUrlWithParameters( + card: Card, + metadata: Metadata, + parameters: Parameter[], + parameterValues: ParameterValues = {}, + parameterMappings: ParameterMapping[] = [] +): DatasetQuery { + if (!card.dataset_query) { + return Urls.question(card.id); + } + + card = Utils.copy(card); + + const cardParameters = getParameters(card); + const datasetQuery = applyParameters( + card, + parameters, + parameterValues, + parameterMappings + ); + + const query = {}; + for (const datasetParameter of datasetQuery.parameters || []) { + const cardParameter = _.find(cardParameters, p => + Utils.equals(p.target, datasetParameter.target)); + if (cardParameter) { + // if the card has a real parameter we can use, use that + query[cardParameter.slug] = datasetParameter.value; + } else if (isStructured(card)) { + // if the card is structured, try converting the parameter to an MBQL filter clause + const filter = parameterToMBQLFilter(datasetParameter, metadata); + if (filter) { + card = updateIn(card, ["dataset_query", "query"], query => + Query.addFilter(query, filter)); + } else { + console.warn("UNHANDLED PARAMETER", datasetParameter); + } + } else { + console.warn("UNHANDLED PARAMETER", datasetParameter); + } + } + return Urls.question(null, card.dataset_query ? card : undefined, query); +} diff --git a/frontend/src/metabase/meta/Card.spec.js b/frontend/src/metabase/meta/Card.spec.js new file mode 100644 index 0000000000000..8830d733ab259 --- /dev/null +++ b/frontend/src/metabase/meta/Card.spec.js @@ -0,0 +1,207 @@ +import * as Card from "./Card"; + +import { assocIn, dissoc } from "icepick"; + +describe("metabase/meta/Card", () => { + describe("questionUrlWithParameters", () => { + const metadata = { + fields: { + 2: { + base_type: "type/Integer" + } + } + } + + const parameters = [ + { + id: 1, + slug: "param_string", + type: "category" + }, + { + id: 2, + slug: "param_number", + type: "category" + }, + { + id: 3, + slug: "param_date", + type: "date/month" + }, + { + id: 4, + slug: "param_fk", + type: "date/month" + } + ]; + + describe("with SQL card", () => { + const card = { + id: 1, + dataset_query: { + type: "native", + native: { + template_tags: { + baz: { name: "baz", type: "text" } + } + } + } + }; + const parameterMappings = [ + { + card_id: 1, + parameter_id: 1, + target: ["variable", ["template-tag", "baz"]] + } + ]; + it("should return question URL with no parameters", () => { + const url = Card.questionUrlWithParameters(card, metadata, []); + expect(parseUrl(url)).toEqual({ + pathname: "/question", + query: {}, + card: dissoc(card, "id") + }); + }); + it("should return question URL with query string parameter", () => { + const url = Card.questionUrlWithParameters( + card, + metadata, + parameters, + { "1": "bar" }, + parameterMappings + ); + expect(parseUrl(url)).toEqual({ + pathname: "/question", + query: { baz: "bar" }, + card: dissoc(card, "id") + }); + }); + }); + describe("with structured card", () => { + const card = { + id: 1, + dataset_query: { + type: "query", + query: { + source_table: 1 + } + } + }; + const parameterMappings = [ + { + card_id: 1, + parameter_id: 1, + target: ["dimension", ["field-id", 1]] + }, + { + card_id: 1, + parameter_id: 2, + target: ["dimension", ["field-id", 2]] + }, + { + card_id: 1, + parameter_id: 3, + target: ["dimension", ["field-id", 3]] + }, + { + card_id: 1, + parameter_id: 4, + target: ["dimension", ["fk->", 4, 5]] + }, + ]; + it("should return question URL with no parameters", () => { + const url = Card.questionUrlWithParameters(card, metadata, []); + expect(parseUrl(url)).toEqual({ + pathname: "/question", + query: {}, + card: dissoc(card, "id") + }); + }); + it("should return question URL with string MBQL filter added", () => { + const url = Card.questionUrlWithParameters( + card, + metadata, + parameters, + { "1": "bar" }, + parameterMappings + ); + expect(parseUrl(url)).toEqual({ + pathname: "/question", + query: {}, + card: assocIn( + dissoc(card, "id"), + ["dataset_query", "query", "filter"], + ["AND", ["=", ["field-id", 1], "bar"]] + ) + }); + }); + it("should return question URL with number MBQL filter added", () => { + const url = Card.questionUrlWithParameters( + card, + metadata, + parameters, + { "2": "123" }, + parameterMappings + ); + expect(parseUrl(url)).toEqual({ + pathname: "/question", + query: {}, + card: assocIn( + dissoc(card, "id"), + ["dataset_query", "query", "filter"], + ["AND", ["=", ["field-id", 2], 123]] + ) + }); + }); + it("should return question URL with date MBQL filter added", () => { + const url = Card.questionUrlWithParameters( + card, + metadata, + parameters, + { "3": "2017-05" }, + parameterMappings + ); + + expect(parseUrl(url)).toEqual({ + pathname: "/question", + query: {}, + card: assocIn( + dissoc(card, "id"), + ["dataset_query", "query", "filter"], + ["AND", ["=", ["datetime-field", ["field-id", 3], "month"], "2017-05-01"]] + ) + }); + }); + it("should return question URL with date MBQL filter on a FK added", () => { + const url = Card.questionUrlWithParameters( + card, + metadata, + parameters, + { "4": "2017-05" }, + parameterMappings + ); + expect(parseUrl(url)).toEqual({ + pathname: "/question", + query: {}, + card: assocIn( + dissoc(card, "id"), + ["dataset_query", "query", "filter"], + ["AND", ["=", ["datetime-field", ["fk->", 4, 5], "month"], "2017-05-01"]] + ) + }); + }); + }); + }); +}); + +import { parse } from "url"; +import { deserializeCardFromUrl } from "metabase/lib/card"; + +function parseUrl(url) { + const parsed = parse(url, true); + return { + card: parsed.hash && deserializeCardFromUrl(parsed.hash), + query: parsed.query, + pathname: parsed.pathname + }; +} diff --git a/frontend/src/metabase/meta/Parameter.js b/frontend/src/metabase/meta/Parameter.js index f0f8d946b05c0..ecb7ef54d178c 100644 --- a/frontend/src/metabase/meta/Parameter.js +++ b/frontend/src/metabase/meta/Parameter.js @@ -1,12 +1,17 @@ /* @flow */ -import type { DatasetQuery } from "./types/Card"; -import type { TemplateTag } from "./types/Query"; -import type { Parameter, ParameterTarget, ParameterValues } from "./types/Parameter"; -import type { FieldId } from "./types/Field"; +import type { DatasetQuery } from "metabase/meta/types/Card"; +import type { TemplateTag, LocalFieldReference, ForeignFieldReference, FieldFilter } from "metabase/meta/types/Query"; +import type { Parameter, ParameterInstance, ParameterTarget, ParameterValue, ParameterValues } from "metabase/meta/types/Parameter"; +import type { FieldId } from "metabase/meta/types/Field"; +import type { Metadata } from "metabase/meta/types/Metadata"; + +import moment from "moment"; import Q from "metabase/lib/query"; import { mbqlEq } from "metabase/lib/query/util"; +import { isNumericBaseType } from "metabase/lib/schema_metadata"; + // NOTE: this should mirror `template-tag-parameters` in src/metabase/api/embed.clj export function getTemplateTagParameters(tags: TemplateTag[]): Parameter[] { @@ -50,3 +55,82 @@ export function getParameterTargetFieldId(target: ?ParameterTarget, datasetQuery } return null; } + +type Deserializer = { testRegex: RegExp, deserialize: DeserializeFn} +type DeserializeFn = (match: any[], fieldRef: LocalFieldReference | ForeignFieldReference) => FieldFilter; + +const timeParameterValueDeserializers: Deserializer[] = [ + {testRegex: /^past([0-9]+)([a-z]+)s$/, deserialize: (matches, fieldRef) => + ["time-interval", fieldRef, -parseInt(matches[0]), matches[1]] + }, + {testRegex: /^next([0-9]+)([a-z]+)s$/, deserialize: (matches, fieldRef) => + ["time-interval", fieldRef, parseInt(matches[0]), matches[1]] + }, + {testRegex: /^this([a-z]+)$/, deserialize: (matches, fieldRef) => + ["time-interval", fieldRef, "current", matches[0]] + }, + {testRegex: /^~([0-9-T:]+)$/, deserialize: (matches, fieldRef) => + ["<", fieldRef, matches[0]] + }, + {testRegex: /^([0-9-T:]+)~$/, deserialize: (matches, fieldRef) => + [">", fieldRef, matches[0]] + }, + {testRegex: /^(\d{4}-\d{2})$/, deserialize: (matches, fieldRef) => + ["=", ["datetime-field", fieldRef, "month"], moment(matches[0], "YYYY-MM").format("YYYY-MM-DD")] + }, + {testRegex: /^(Q\d-\d{4})$/, deserialize: (matches, fieldRef) => + ["=", ["datetime-field", fieldRef, "quarter"], moment(matches[0], "[Q]Q-YYYY").format("YYYY-MM-DD")] + }, + {testRegex: /^([0-9-T:]+)$/, deserialize: (matches, fieldRef) => + ["=", fieldRef, matches[0]] + }, + // TODO 3/27/17 Atte Keinänen + // Unify BETWEEN -> between, IS_NULL -> is-null, NOT_NULL -> not-null throughout the codebase + {testRegex: /^([0-9-T:]+)~([0-9-T:]+)$/, deserialize: (matches, fieldRef) => + // $FlowFixMe + ["BETWEEN", fieldRef, matches[0], matches[1]] + }, +]; + +export function dateParameterValueToMBQL(parameterValue: ParameterValue, fieldRef: LocalFieldReference|ForeignFieldReference): ?FieldFilter { + const deserializer: ?Deserializer = + timeParameterValueDeserializers.find((des) => des.testRegex.test(parameterValue)); + + if (deserializer) { + const substringMatches = deserializer.testRegex.exec(parameterValue).splice(1); + return deserializer.deserialize(substringMatches, fieldRef); + } else { + return null; + } +} + +export function stringParameterValueToMBQL(parameterValue: ParameterValue, fieldRef: LocalFieldReference|ForeignFieldReference): ?FieldFilter { + return ["=", fieldRef, parameterValue]; +} + +export function numberParameterValueToMBQL(parameterValue: ParameterValue, fieldRef: LocalFieldReference|ForeignFieldReference): ?FieldFilter { + return ["=", fieldRef, parseFloat(parameterValue)]; +} + +/** compiles a parameter with value to an MBQL clause */ +export function parameterToMBQLFilter(parameter: ParameterInstance, metadata: Metadata): ?FieldFilter { + if (!parameter.target || parameter.target[0] !== "dimension" || !Array.isArray(parameter.target[1]) || parameter.target[1][0] === "template-tag") { + return null; + } + + // $FlowFixMe: doesn't understand parameter.target[1] is a field reference + const fieldRef: LocalFieldReference|ForeignFieldReference = parameter.target[1] + + if (parameter.type.indexOf("date/") === 0) { + return dateParameterValueToMBQL(parameter.value, fieldRef); + } else { + const fieldId = Q.getFieldTargetId(fieldRef); + const field = metadata.fields[fieldId]; + // if the field is numeric, parse the value as a number + if (isNumericBaseType(field)) { + return numberParameterValueToMBQL(parameter.value, fieldRef); + } else { + return stringParameterValueToMBQL(parameter.value, fieldRef); + } + } +} diff --git a/frontend/src/metabase/meta/Parameter.spec.js b/frontend/src/metabase/meta/Parameter.spec.js new file mode 100644 index 0000000000000..65d3b82cc0615 --- /dev/null +++ b/frontend/src/metabase/meta/Parameter.spec.js @@ -0,0 +1,33 @@ +import { dateParameterValueToMBQL } from "./Parameter"; + +describe("metabase/meta/Parameter", () => { + describe("dateParameterValueToMBQL", () => { + it ("should parse past30days", () => { + expect(dateParameterValueToMBQL("past30days", null)).toEqual(["time-interval", null, -30, "day"]) + }) + it ("should parse next2years", () => { + expect(dateParameterValueToMBQL("next2years", null)).toEqual(["time-interval", null, 2, "year"]) + }) + it ("should parse thisday", () => { + expect(dateParameterValueToMBQL("thisday", null)).toEqual(["time-interval", null, "current", "day"]) + }) + it ("should parse ~2017-05-01", () => { + expect(dateParameterValueToMBQL("~2017-05-01", null)).toEqual(["<", null, "2017-05-01"]) + }) + it ("should parse 2017-05-01~", () => { + expect(dateParameterValueToMBQL("2017-05-01~", null)).toEqual([">", null, "2017-05-01"]) + }) + it ("should parse 2017-05", () => { + expect(dateParameterValueToMBQL("2017-05", null)).toEqual(["=", ["datetime-field", null, "month"], "2017-05-01"]) + }) + it ("should parse Q1-2017", () => { + expect(dateParameterValueToMBQL("Q1-2017", null)).toEqual(["=", ["datetime-field", null, "quarter"], "2017-01-01"]) + }) + it ("should parse 2017-05-01", () => { + expect(dateParameterValueToMBQL("2017-05-01", null)).toEqual(["=", null, "2017-05-01"]) + }) + it ("should parse 2017-05-01~2017-05-02", () => { + expect(dateParameterValueToMBQL("2017-05-01~2017-05-02", null)).toEqual(["BETWEEN", null, "2017-05-01", "2017-05-02"]) + }) + }) +}) diff --git a/frontend/src/metabase/meta/types/Card.js b/frontend/src/metabase/meta/types/Card.js index 9ef102d6db301..98e35d5c0e1e8 100644 --- a/frontend/src/metabase/meta/types/Card.js +++ b/frontend/src/metabase/meta/types/Card.js @@ -6,6 +6,14 @@ import type { Parameter, ParameterInstance } from "./Parameter"; export type CardId = number; +export type UnsavedCard = { + dataset_query: DatasetQuery, + display: string, + visualization_settings: VisualizationSettings, + parameters?: Array, + original_card_id?: CardId +} + export type Card = { id: CardId, name: ?string, @@ -13,7 +21,7 @@ export type Card = { dataset_query: DatasetQuery, display: string, visualization_settings: VisualizationSettings, - parameters?: Array + parameters?: Array, }; export type StructuredDatasetQuery = { diff --git a/frontend/src/metabase/meta/types/Dashboard.js b/frontend/src/metabase/meta/types/Dashboard.js index a839d322b9bdd..f9a6927988e89 100644 --- a/frontend/src/metabase/meta/types/Dashboard.js +++ b/frontend/src/metabase/meta/types/Dashboard.js @@ -8,7 +8,10 @@ export type DashboardId = number; export type Dashboard = { id: DashboardId, name: string, + favorite: boolean, + archived: boolean, created_at: ?string, + creator_id: number, description: ?string, caveats?: string, points_of_interest?: string, diff --git a/frontend/src/metabase/meta/types/Database.js b/frontend/src/metabase/meta/types/Database.js index 719ca45f769fa..2f5685e88898b 100644 --- a/frontend/src/metabase/meta/types/Database.js +++ b/frontend/src/metabase/meta/types/Database.js @@ -1,14 +1,43 @@ /* @flow */ +import type { ISO8601Time } from "."; import type { Table } from "./Table"; export type DatabaseId = number; -// TODO: incomplete +export type DatabaseType = string; // "h2" | "postgres" | etc + +export type DatabaseFeature = + "basic-aggregations" | + "standard-deviation-aggregations"| + "expression-aggregations" | + "foreign-keys" | + "native-parameters" | + "expressions" + +export type DatabaseDetails = { + [key: string]: any +} + +export type DatabaseNativePermission = "write" | "read"; + export type Database = { - id: DatabaseId, + id: DatabaseId, + name: string, + description: ?string, + + tables: Table[], + + details: DatabaseDetails, + engine: DatabaseType, + features: DatabaseFeature[], + is_full_sync: boolean, + is_sample: boolean, + native_permissions: DatabaseNativePermission, - name: string, + caveats: ?string, + points_of_interest: ?string, - tables: Array + created_at: ISO8601Time, + updated_at: ISO8601Time, }; diff --git a/frontend/src/metabase/meta/types/Dataset.js b/frontend/src/metabase/meta/types/Dataset.js index 90bbe3f1ed40b..e7de3b29a88d1 100644 --- a/frontend/src/metabase/meta/types/Dataset.js +++ b/frontend/src/metabase/meta/types/Dataset.js @@ -1,7 +1,9 @@ /* @flow */ +import type { ISO8601Time } from "."; import type { FieldId } from "./Field"; import type { DatasetQuery } from "./Card"; +import type { DatetimeUnit } from "./Query"; export type ColumnName = string; @@ -12,11 +14,11 @@ export type Column = { display_name: string, base_type: string, special_type: ?string, - source?: "fields"|"aggregation"|"breakout" + source?: "fields"|"aggregation"|"breakout", + unit?: DatetimeUnit }; -export type ISO8601Times = string; -export type Value = string|number|ISO8601Times|boolean|null|{}; +export type Value = string|number|ISO8601Time|boolean|null|{}; export type Row = Value[]; export type DatasetData = { diff --git a/frontend/src/metabase/meta/types/Field.js b/frontend/src/metabase/meta/types/Field.js index 0166238a2f4fe..f513dd18e9207 100644 --- a/frontend/src/metabase/meta/types/Field.js +++ b/frontend/src/metabase/meta/types/Field.js @@ -1,10 +1,44 @@ /* @flow */ +import type { ISO8601Time } from "."; +import type { TableId } from "./Table"; + export type FieldId = number; -// TODO: incomplete +export type BaseType = string; +export type SpecialType = string; + +export type FieldVisibilityType = "details-only" | "hidden" | "normal" | "retired"; + export type Field = { - id: FieldId, + id: FieldId, + + name: string, + display_name: string, + description: string, + base_type: BaseType, + special_type: SpecialType, + active: boolean, + visibility_type: FieldVisibilityType, + preview_display: boolean, + position: number, + parent_id: ?FieldId, + + // raw_column_id: number // unused? + + table_id: TableId, + + fk_target_field_id: ?FieldId, + + max_value: ?number, + min_value: ?number, + + caveats: ?string, + points_of_interest: ?string, + + last_analyzed: ISO8601Time, + created_at: ISO8601Time, + updated_at: ISO8601Time, // Metadata field "values" type is inconsistent // https://github.com/metabase/metabase/issues/3417 diff --git a/frontend/src/metabase/meta/types/Metadata.js b/frontend/src/metabase/meta/types/Metadata.js index 4fb281711ba44..6fced2d6f8bff 100644 --- a/frontend/src/metabase/meta/types/Metadata.js +++ b/frontend/src/metabase/meta/types/Metadata.js @@ -2,9 +2,53 @@ // Legacy "tableMetadata" etc -import type { Table } from "metabase/meta/types/Table"; -import type { Field } from "metabase/meta/types/Field"; -import type { Segment } from "metabase/meta/types/Segment"; +import type { Database, DatabaseId } from "metabase/meta/types/Database"; +import type { Table, TableId } from "metabase/meta/types/Table"; +import type { Field, FieldId } from "metabase/meta/types/Field"; +import type { Segment, SegmentId } from "metabase/meta/types/Segment"; +import type { Metric, MetricId } from "metabase/meta/types/Metric"; + +export type Metadata = { + databases: { [id: DatabaseId]: DatabaseMetadata }, + tables: { [id: TableId]: TableMetadata }, + fields: { [id: FieldId]: FieldMetadata }, + metrics: { [id: MetricId]: MetricMetadata }, + segments: { [id: SegmentId]: SegmentMetadata }, +} + +export type DatabaseMetadata = Database & { + tables: TableMetadata[], + tables_lookup: { [id: TableId]: TableMetadata }, +} + +export type TableMetadata = Table & { + db: DatabaseMetadata, + + fields: FieldMetadata[], + fields_lookup: { [id: FieldId]: FieldMetadata }, + + segments: SegmentMetadata[], + metrics: MetricMetadata[], + + aggregation_options: AggregationOption[], + breakout_options: BreakoutOption, +} + +export type FieldMetadata = Field & { + table: TableMetadata, + target: FieldMetadata, + + operators: Operator[], + operators_lookup: { [key: OperatorName]: Operator } +} + +export type SegmentMetadata = Segment & { + table: TableMetadata, +} + +export type MetricMetadata = Metric & { + table: TableMetadata, +} export type FieldValue = { name: string, @@ -31,10 +75,6 @@ export type OperatorField = { export type ValidArgumentsFilter = (field: Field, table: Table) => bool; -export type FieldMetadata = Field & { - operators_lookup: { [name: string]: Operator } -} - export type AggregationOption = { name: string, short: string, @@ -42,20 +82,13 @@ export type AggregationOption = { validFieldsFilter: (fields: Field[]) => Field[] } -export type BreakoutOptions = { +export type BreakoutOption = { name: string, short: string, fields: Field[], validFieldsFilter: (fields: Field[]) => Field[] } -export type TableMetadata = Table & { - segments: Segment[], - fields: FieldMetadata[], - aggregation_options: AggregationOption[], - breakout_options: BreakoutOptions -} - export type FieldOptions = { count: number, fields: Field[], diff --git a/frontend/src/metabase/meta/types/Metric.js b/frontend/src/metabase/meta/types/Metric.js new file mode 100644 index 0000000000000..ee5146bed28a0 --- /dev/null +++ b/frontend/src/metabase/meta/types/Metric.js @@ -0,0 +1,13 @@ +/* @flow */ + +import type { TableId } from "./Table"; + +export type MetricId = number; + +// TODO: incomplete +export type Metric = { + name: string, + id: MetricId, + table_id: TableId, + is_active: bool +}; diff --git a/frontend/src/metabase/meta/types/Parameter.js b/frontend/src/metabase/meta/types/Parameter.js index 207a938770a75..2d670dc641169 100644 --- a/frontend/src/metabase/meta/types/Parameter.js +++ b/frontend/src/metabase/meta/types/Parameter.js @@ -1,11 +1,16 @@ /* @flow */ import type { CardId } from "./Card"; -import type { ConcreteField } from "./Query"; +import type { LocalFieldReference, ForeignFieldReference } from "./Query"; export type ParameterId = string; + +// date/*, category, id, etc export type ParameterType = string; +// a URL-safe encoding of a parameter value +export type ParameterValue = string; + export type Parameter = { id: ParameterId, name: string, @@ -16,10 +21,8 @@ export type Parameter = { target?: ParameterTarget }; -export type ParameterValue = number | string; - export type VariableTarget = ["template-tag", string]; -export type DimensionTarget = ["template-tag", string] | ConcreteField +export type DimensionTarget = ["template-tag", string] | LocalFieldReference | ForeignFieldReference export type ParameterTarget = ["variable", VariableTarget] | @@ -45,7 +48,7 @@ export type ParameterOption = { export type ParameterInstance = { type: ParameterType, target: ParameterTarget, - value: string + value: ParameterValue }; export type ParameterMappingUIOption = ParameterMappingOption & { diff --git a/frontend/src/metabase/meta/types/Query.js b/frontend/src/metabase/meta/types/Query.js index d8223de5382cb..8df60b5832edc 100644 --- a/frontend/src/metabase/meta/types/Query.js +++ b/frontend/src/metabase/meta/types/Query.js @@ -3,10 +3,9 @@ import type { TableId } from "./Table"; import type { FieldId } from "./Field"; import type { SegmentId } from "./Segment"; +import type { MetricId } from "./Metric"; import type { ParameterType } from "./Parameter"; -export type MetricId = number; - export type ExpressionName = string; export type StringLiteral = string; @@ -22,12 +21,13 @@ export type DatetimeUnit = "default" | "minute" | "minute-of-hour" | "hour" | "h export type TemplateTagId = string; export type TemplateTagName = string; +export type TemplateTagType = "text" | "number" | "date" | "dimension"; export type TemplateTag = { id: TemplateTagId, name: TemplateTagName, display_name: string, - type: string, + type: TemplateTagType, dimension?: LocalFieldReference, widget_type?: ParameterType, required?: boolean, @@ -77,7 +77,8 @@ type StdDevAgg = ["stddev", ConcreteField]; type SumAgg = ["sum", ConcreteField]; type MinAgg = ["min", ConcreteField]; type MaxAgg = ["max", ConcreteField]; -type MetricAgg = ["metric", MetricId]; +// NOTE: currently the backend expects METRIC to be uppercase +type MetricAgg = ["METRIC", MetricId]; export type BreakoutClause = Array; export type Breakout = @@ -115,7 +116,8 @@ export type NotNullFilter = ["not-null", ConcreteField]; export type InsideFilter = ["inside", ConcreteField, ConcreteField, NumericLiteral, NumericLiteral, NumericLiteral, NumericLiteral]; export type TimeIntervalFilter = ["time-interval", ConcreteField, RelativeDatetimePeriod, RelativeDatetimeUnit]; -export type SegmentFilter = ["segment", SegmentId]; +// NOTE: currently the backend expects SEGMENT to be uppercase +export type SegmentFilter = ["SEGMENT", SegmentId]; export type OrderByClause = Array; export type OrderBy = ["asc"|"desc", Field]; diff --git a/frontend/src/metabase/meta/types/Segment.js b/frontend/src/metabase/meta/types/Segment.js index c928eb47c0e31..1074de8ab6c54 100644 --- a/frontend/src/metabase/meta/types/Segment.js +++ b/frontend/src/metabase/meta/types/Segment.js @@ -1,10 +1,13 @@ /* @flow */ +import type { TableId } from "./Table"; + export type SegmentId = number; // TODO: incomplete export type Segment = { name: string, id: SegmentId, + table_id: TableId, is_active: bool }; diff --git a/frontend/src/metabase/meta/types/Table.js b/frontend/src/metabase/meta/types/Table.js index 51ca40ef2b89d..30e7361a1e972 100644 --- a/frontend/src/metabase/meta/types/Table.js +++ b/frontend/src/metabase/meta/types/Table.js @@ -1,20 +1,51 @@ /* @flow */ -import type { Field } from "./Field"; +import type { ISO8601Time } from "."; + +import type { Field, FieldId } from "./Field"; +import type { Segment } from "./Segment"; +import type { Metric } from "./Metric"; import type { DatabaseId } from "./Database"; export type TableId = number; export type SchemaName = string; +type TableVisibilityType = string; // FIXME + +type FieldValue = any; +type FieldValues = { + [id: FieldId]: FieldValue[] +} + // TODO: incomplete export type Table = { - id: TableId, + id: TableId, + db_id: DatabaseId, + + schema: ?string, + name: string, + display_name: string, + + description: string, + active: boolean, + visibility_type: TableVisibilityType, + + // entity_name: null // unused? + // entity_type: null // unused? + // raw_table_id: number, // unused? + + fields: Field[], + segments: Segment[], + metrics: Metric[], + + field_values: FieldValues, - db_id: DatabaseId, + rows: number, - name: string, - display_name: string, - schema?: SchemaName, + caveats: ?string, + points_of_interest: ?string, + show_in_getting_started: boolean, - fields: Array + updated_at: ISO8601Time, + created_at: ISO8601Time, } diff --git a/frontend/src/metabase/meta/types/User.js b/frontend/src/metabase/meta/types/User.js new file mode 100644 index 0000000000000..2b1d4440ecfc5 --- /dev/null +++ b/frontend/src/metabase/meta/types/User.js @@ -0,0 +1,13 @@ +export type User = { + common_name: string, + date_joined: string, + email: string, + first_name: string, + google_auth: boolean, + id: number, + is_active: boolean, + is_qbnewb: false, + is_superuser: true, + last_login: string, + last_name: string +} \ No newline at end of file diff --git a/frontend/src/metabase/meta/types/Visualization.js b/frontend/src/metabase/meta/types/Visualization.js index 0c4631b4373e4..e172b2037c269 100644 --- a/frontend/src/metabase/meta/types/Visualization.js +++ b/frontend/src/metabase/meta/types/Visualization.js @@ -4,7 +4,7 @@ import type { DatasetData, Column } from "metabase/meta/types/Dataset"; import type { Card, VisualizationSettings } from "metabase/meta/types/Card"; import type { TableMetadata } from "metabase/meta/types/Metadata"; -export type ActionCreator = (props: ClickActionProps) => ?ClickAction +export type ActionCreator = (props: ClickActionProps) => ClickAction[] export type QueryMode = { name: string, @@ -29,17 +29,21 @@ export type DimensionValue = { export type ClickObject = { value?: Value, - column: Column, + column?: Column, dimensions?: DimensionValue[], event?: MouseEvent, element?: HTMLElement, + seriesIndex?: number, } export type ClickAction = { title: any, // React Element icon?: string, popover?: (props: ClickActionPopoverProps) => any, // React Element - card?: () => ?Card + card?: () => ?Card, + + section?: string, + name?: string, } export type ClickActionProps = { @@ -74,12 +78,12 @@ export type VisualizationProps = { isDashboard: boolean, isEditing: boolean, actionButtons: Node, - linkToCard?: bool, hovered: ?HoverObject, onHoverChange: (?HoverObject) => void, onVisualizationClick: (?ClickObject) => void, visualizationIsClickable: (?ClickObject) => boolean, + onChangeCardAndRun: (card: Card) => void, onUpdateVisualizationSettings: ({ [key: string]: any }) => void } diff --git a/frontend/src/metabase/meta/types/index.js b/frontend/src/metabase/meta/types/index.js index 98c46550150e6..db13f9a2096d6 100644 --- a/frontend/src/metabase/meta/types/index.js +++ b/frontend/src/metabase/meta/types/index.js @@ -1,5 +1,8 @@ /* @flow */ +// ISO8601 timestamp +export type ISO8601Time = string; + // dashboard, card, etc export type EntityType = string; @@ -24,3 +27,9 @@ export type ApiError = { status: number, // HTTP status // TODO: incomplete } + +// FIXME: actual moment.js type +export type Moment = { + locale: () => Moment, + format: (format: string) => string +}; diff --git a/frontend/src/metabase/nav/containers/Navbar.jsx b/frontend/src/metabase/nav/containers/Navbar.jsx index 843f3fe691325..81a395e7187a7 100644 --- a/frontend/src/metabase/nav/containers/Navbar.jsx +++ b/frontend/src/metabase/nav/containers/Navbar.jsx @@ -126,7 +126,7 @@ export default class Navbar extends Component {
  • - +
  • diff --git a/frontend/src/metabase/parameters/components/ParameterWidget.css b/frontend/src/metabase/parameters/components/ParameterWidget.css index d7704049805f4..87364a74632a1 100644 --- a/frontend/src/metabase/parameters/components/ParameterWidget.css +++ b/frontend/src/metabase/parameters/components/ParameterWidget.css @@ -10,7 +10,7 @@ :local(.container) legend { text-transform: none; position: relative; - height: 0; + height: 2px; line-height: 0; margin-left: -0.45em; padding: 0 0.5em; diff --git a/frontend/src/metabase/parameters/components/ParameterWidget.jsx b/frontend/src/metabase/parameters/components/ParameterWidget.jsx index 05d13def78633..2035928499797 100644 --- a/frontend/src/metabase/parameters/components/ParameterWidget.jsx +++ b/frontend/src/metabase/parameters/components/ParameterWidget.jsx @@ -19,7 +19,7 @@ export default class ParameterWidget extends Component { static propTypes = { parameter: PropTypes.object, - commitImmediately: PropTypes.object + commitImmediately: PropTypes.bool }; static defaultProps = { diff --git a/frontend/src/metabase/parameters/components/widgets/DateAllOptionsWidget.jsx b/frontend/src/metabase/parameters/components/widgets/DateAllOptionsWidget.jsx index 52b252b6de994..c29fd85d6f76f 100644 --- a/frontend/src/metabase/parameters/components/widgets/DateAllOptionsWidget.jsx +++ b/frontend/src/metabase/parameters/components/widgets/DateAllOptionsWidget.jsx @@ -5,14 +5,12 @@ import cx from "classnames"; import DatePicker, {DATE_OPERATORS} from "metabase/query_builder/components/filters/pickers/DatePicker.jsx"; import {generateTimeFilterValuesDescriptions} from "metabase/lib/query_time"; +import { dateParameterValueToMBQL } from "metabase/meta/Parameter"; import type {OperatorName} from "metabase/query_builder/components/filters/pickers/DatePicker.jsx"; -import type {FieldFilter, LocalFieldReference} from "metabase/meta/types/Query"; +import type {FieldFilter} from "metabase/meta/types/Query"; type UrlEncoded = string; -// $FlowFixMe -type RegexMatches = [string]; -type Deserializer = (RegexMatches) => FieldFilter; // Use a placeholder value as field references are not used in dashboard filters // $FlowFixMe @@ -47,34 +45,6 @@ function filterToUrlEncoded(filter: FieldFilter): ?UrlEncoded { } } -const deserializersWithTestRegex: [{ testRegex: RegExp, deserialize: Deserializer}] = [ - {testRegex: /^past([0-9]+)([a-z]+)s$/, deserialize: (matches) => { - return ["time-interval", noopRef, -parseInt(matches[0]), matches[1]] - }}, - {testRegex: /^next([0-9]+)([a-z]+)s$/, deserialize: (matches) => { - return ["time-interval", noopRef, parseInt(matches[0]), matches[1]] - }}, - {testRegex: /^this([a-z]+)$/, deserialize: (matches) => ["time-interval", noopRef, "current", matches[0]] }, - {testRegex: /^~([0-9-T:]+)$/, deserialize: (matches) => ["<", noopRef, matches[0]]}, - {testRegex: /^([0-9-T:]+)~$/, deserialize: (matches) => [">", noopRef, matches[0]]}, - {testRegex: /^([0-9-T:]+)$/, deserialize: (matches) => ["=", noopRef, matches[0]]}, - // TODO 3/27/17 Atte Keinänen - // Unify BETWEEN -> between, IS_NULL -> is-null, NOT_NULL -> not-null throughout the codebase - // $FlowFixMe - {testRegex: /^([0-9-T:]+)~([0-9-T:]+)$/, deserialize: (matches) => ["BETWEEN", noopRef, matches[0], matches[1]]}, -]; - -function urlEncodedToFilter(urlEncoded: UrlEncoded): ?FieldFilter { - const deserializer = - deserializersWithTestRegex.find((des) => urlEncoded.search(des.testRegex) !== -1); - - if (deserializer) { - const substringMatches = deserializer.testRegex.exec(urlEncoded).splice(1); - return deserializer.deserialize(substringMatches); - } else { - return null; - } -} const prefixedOperators: [OperatorName] = ["Before", "After", "On", "Is Empty", "Not Empty"]; function getFilterTitle(filter) { @@ -99,7 +69,7 @@ export default class DateAllOptionsWidget extends Component<*, Props, State> { this.state = { // $FlowFixMe - filter: props.value != null ? urlEncodedToFilter(props.value) || [] : [] + filter: props.value != null ? dateParameterValueToMBQL(props.value, noopRef) || [] : [] } } @@ -108,7 +78,7 @@ export default class DateAllOptionsWidget extends Component<*, Props, State> { static format = (urlEncoded: ?string) => { if (urlEncoded == null) return null; - const filter = urlEncodedToFilter(urlEncoded); + const filter = dateParameterValueToMBQL(urlEncoded, noopRef); return filter ? getFilterTitle(filter) : null; }; diff --git a/frontend/src/metabase/public/components/MetabaseEmbed.jsx b/frontend/src/metabase/public/components/MetabaseEmbed.jsx deleted file mode 100644 index 80635a96ca845..0000000000000 --- a/frontend/src/metabase/public/components/MetabaseEmbed.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, { Component } from "react"; - -import querystring from "querystring"; -import _ from "underscore"; - -const OPTION_NAMES = ["bordered"]; - -export default class MetabaseEmbed extends Component { - render() { - let { className, style, url } = this.props; - - let options = querystring.stringify(_.pick(this.props, ...OPTION_NAMES)); - if (options) { - url += "#" + options; - } - - return ( -