diff --git a/.babelrc b/.babelrc index f70183c4586f87b1be522ea5790997acb31a2c04..d9be3e07b25b13378ccdde7fe95c204c9c398ea4 100644 --- a/.babelrc +++ b/.babelrc @@ -19,7 +19,8 @@ "extract": { "output": "locales/metabase-frontend.pot" }, - "discover": ["t", "jt"] + "discover": ["t", "jt"], + "numberedExpressions": true }] ] } diff --git a/.eslintrc b/.eslintrc index aa4c8e9f63613906e5ab6efead0a560c877894db..fa465b469f66bf7d3acc53a9e49ca9e48ce871e7 100644 --- a/.eslintrc +++ b/.eslintrc @@ -39,7 +39,8 @@ "react/no-did-update-set-state": 0, "react/no-find-dom-node": 0, "flowtype/define-flow-type": 1, - "flowtype/use-flow-type": 1 + "flowtype/use-flow-type": 1, + "no-var": 1 }, "globals": { "pending": false diff --git a/.flowconfig b/.flowconfig index 712f08d8b461fae728baa2ce078c788401c62af5..fe8b9bfae494ecb33ca27674aca788de0216ece8 100644 --- a/.flowconfig +++ b/.flowconfig @@ -4,6 +4,10 @@ .*/node_modules/react-resizable/.* .*/node_modules/documentation/.* .*/node_modules/.*/\(lib\|test\).*\.json$ +.*/node_modules/react-element-to-jsx-string/.* +.*/node_modules/react-element-to-jsx-string/.* +.*/node_modules/resize-observer-polyfill/.* +.*/node_modules/react-virtualized/.* [include] .*/frontend/.* diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ef2e2989c07fffba2498ae2efe32368d508b6987..c6c1ab8111e2664387c94dba0c091c31e0d343fd 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,7 @@ -###### TODO +###### Before submitting the PR, please make sure you do the following +- [ ] If there are changes to the backend codebase, run the backend tests with `lein test && lein eastwood && lein bikeshed && lein docstring-checker && ./bin/reflection-linter` +- [ ] Run the frontend and integration tests with `yarn && yarn run prettier && yarn run lint && yarn run flow && ./bin/build version uberjar && yarn run test`) - [ ] Sign the [Contributor License Agreement](https://docs.google.com/a/metabase.com/forms/d/1oV38o7b9ONFSwuzwmERRMi9SYrhYeOrkbmNaq9pOJ_E/viewform) (unless it's a tiny documentation change). diff --git a/.gitignore b/.gitignore index a45c2e8a8167fb7f89916af744378f5b3058171c..f82f949b1705dec2e57dcb4152c14fdeaaf0eb2a 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,5 @@ bin/release/aws-eb/metabase-aws-eb.zip coverage-summary.json .DS_Store bin/node_modules/ -*.log \ No newline at end of file +*.log +*.trace.db diff --git a/bin/aws-eb-docker/.ebextensions/01_metabase.config b/bin/aws-eb-docker/.ebextensions/01_metabase.config index eea4d292fd344712e8e59cd108898b45c960db46..a961774d29394fc67ac586c96d8e3cdf2791200d 100644 --- a/bin/aws-eb-docker/.ebextensions/01_metabase.config +++ b/bin/aws-eb-docker/.ebextensions/01_metabase.config @@ -15,13 +15,14 @@ container_commands: #command: true #ignoreErrors: false - 01_server-name: - command: ".ebextensions/metabase_config/metabase-setup.sh server_name" - test: test $NGINX_SERVER_NAME + # do server_https first to avoid overwriting other config changes + 01_server_https: + command: ".ebextensions/metabase_config/metabase-setup.sh server_https" ignoreErrors: true - 02_server_https: - command: ".ebextensions/metabase_config/metabase-setup.sh server_https" + 02_server_name: + command: ".ebextensions/metabase_config/metabase-setup.sh server_name" + test: test $NGINX_SERVER_NAME ignoreErrors: true 03_log_x_real_ip: diff --git a/bin/build-for-test b/bin/build-for-test new file mode 100755 index 0000000000000000000000000000000000000000..9192d4a74ddc0352b0c260b96c270c26900005b3 --- /dev/null +++ b/bin/build-for-test @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +set -eu + +VERSION_PROPERTY_NAME="src_hash" + +source-hash() { + # hash all the files that might change a backend-only uberjar build (for integration tests) + ( + find src project.clj resources/sample-dataset.db.mv.db -type f -print0 | xargs -0 shasum ; + find resources -type f \( -iname \*.clj -o -iname \*.edn -o -iname \*.yaml -o -iname \*.properties \) -not -name "version.properties" -print0 | xargs -0 shasum ; + ) | shasum | awk '{ print $1 }' +} + +uberjar-hash() { + # java -jar target/uberjar/metabase.jar version | grep -oE 'source_hash [a-f0-9]+' | awk '{ print $2 }' + # pulling the version.properties directly from the jar is much faster + unzip -c target/uberjar/metabase.jar version.properties | grep "$VERSION_PROPERTY_NAME" | cut -f2 -d= +} + +check-uberjar-hash() { + expected_hash=$(source-hash) + actual_hash=$(uberjar-hash) + if [ "$expected_hash" == "$actual_hash" ]; then + return 0 + else + return 1 + fi +} + +build-uberjar-for-test() { + ./bin/build version + echo "$VERSION_PROPERTY_NAME=$(source-hash)" >> resources/version.properties + ./bin/build uberjar +} + +if [ ! -f "target/uberjar/metabase.jar" ] || ! check-uberjar-hash; then + echo "Building uberjar for testing" + build-uberjar-for-test +else + echo "Uberjar already up to date for testing" +fi diff --git a/bin/ci b/bin/ci index ab862b6ae2e4a9f76c9769b163b1c525f0c89745..67b431062dc3f0b0e5d387c815352321ec900c3a 100755 --- a/bin/ci +++ b/bin/ci @@ -4,10 +4,13 @@ set -eu node-0() { - is_enabled "drivers" && export ENGINES="h2,mongo,mysql,bigquery" || export ENGINES="h2" + is_enabled "drivers" && export ENGINES="h2,mongo,mysql,bigquery,sparksql" || export ENGINES="h2" if is_engine_enabled "mongo"; then run_step install-mongodb fi + if is_engine_enabled "sparksql"; then + run_step install-sparksql + fi MB_MYSQL_TEST_USER=ubuntu run_step lein-test } node-1() { @@ -58,12 +61,13 @@ node-5() { run_step lein with-profile +ci eastwood run_step yarn run test-karma - run_step yarn run test-unit + run_step yarn run test-unit --coverage report-frontend-coverage } node-6() { - run_step ./bin/build version sample-dataset uberjar - run_step yarn run test-integrated + run_step ./bin/build-for-test + run_step check-uberjar-file-count + run_step yarn run test-integrated-no-build } report() { @@ -124,6 +128,29 @@ install-presto() { sleep 10 } +install-sparksql() { + spark_version='2.1.1' # Java 7 support was removed in Spark 2.2 so don't upgrade until we upgrade CI + hadoop_version='2.7' + + spark_archive="spark-${spark_version}-bin-hadoop${hadoop_version}.tgz" + wget --progress dot -e dotbytes=250K "https://archive.apache.org/dist/spark/spark-${spark_version}/${spark_archive}" + tar -xf $spark_archive + rm $spark_archive + + spark_dir="$(pwd)/spark-${spark_version}-bin-hadoop${hadoop_version}" + java -Duser.timezone=Etc/UTC \ + -Xmx512m \ + -cp "${spark_dir}/conf:${spark_dir}/jars/*" \ + org.apache.spark.deploy.SparkSubmit \ + --master local[8] \ + --conf spark.executor.extraJavaOptions=-Duser.timezone=Etc/UTC \ + --conf spark.cores.max=1 \ + --class org.apache.spark.sql.hive.thriftserver.HiveThriftServer2 \ + --name "Thrift JDBC/ODBC Server" \ + --executor-memory 1g \ + spark-internal &>/dev/null & +} + lein-test() { lein with-profile +ci test } @@ -142,6 +169,22 @@ is_engine_enabled() { [[ "$ENGINES" == *"$1"* ]] } +# Make sure uberjar has less than 64k files because that is the Java 7 LIMIT +check-uberjar-file-count() { + if [ ! -f ./target/uberjar/metabase.jar ]; then + echo "Missing uberjar." + exit 1 + fi + + file_count=$(unzip -l target/uberjar/metabase.jar | wc -l) + echo "Uberjar has ${file_count} files." + + if [ $file_count -gt 65535 ]; then + echo "Uberjar exceeds the 64k Java 7 file limit! We can't allow this. ¡Lo siento!" + exit 1 + fi +} + # print a summary on exit status=0 summary="" diff --git a/bin/i18n/build-translation-frontend-resource b/bin/i18n/build-translation-frontend-resource index 623aa4c532787b50961b29be0faa9ca1203182b3..6ac27035b3eb45cf09bc03acb97cfe55349efbc2 100755 --- a/bin/i18n/build-translation-frontend-resource +++ b/bin/i18n/build-translation-frontend-resource @@ -7,30 +7,66 @@ const fs = require("fs"); const _ = require("underscore"); const gParser = require("gettext-parser"); +// NOTE: this function replace xgettext "{0}" style references with c-3po "${ 0 }" style references +function replaceReferences(str) { + return str.replace(/\{ *(\d+) *\}/g, "${ $1 }"); +} + if (process.argv.length !== 4) { - console.log("USAGE: build-translation-frontend-resource input.po output.json"); + console.log( + "USAGE: build-translation-frontend-resource input.po output.json" + ); process.exit(1); } const inputFile = process.argv[2]; const outputFile = process.argv[3]; +console.log(`Compiling ${inputFile} for frontend...`); + const translationObject = gParser.po.parse(fs.readFileSync(inputFile, "utf-8")); // NOTE: unsure why the headers are duplicated in a translation for "", but we don't need it -delete translationObject.translations[""][""] +delete translationObject.translations[""][""]; +let fuzzyCount = 0; +let emptyCount = 0; for (const id in translationObject.translations[""]) { const translation = translationObject.translations[""][id]; - if (!translation.comments.reference || _.any(translation.comments.reference.split("\n"), reference => reference.startsWith("frontend/"))) { - // remove comments: - delete translation.comments; - // NOTE: would be nice if we could remove the message id since it's redundant: - // delete translation.msgid; - } else { - // remove strings that aren't in the frontend - delete translationObject.translations[""][id]; + const { reference, flag } = translation.comments || {}; + // delete the translation, we'll add it back in if needed + delete translationObject.translations[""][id]; + if ( + // only include translations used on the frontend + !/(^|\n)frontend\//.test(reference) + ) { + continue; + } + // don't include empty translations + if (_.isEqual(translation.msgstr, [""])) { + emptyCount++; + continue; } + // don't include fuzzy translations + if (flag === "fuzzy") { + fuzzyCount++; + continue; + } + // remove comments + delete translation.comments; + // delete msgid since it's redundant, we have to add it back in on the frontend though + delete translation.msgid; + // replace references in translations + translation.msgstr = translation.msgstr.map(str => replaceReferences(str)); + // replace references in msgid + translationObject.translations[""][replaceReferences(id)] = translation; +} + +if (emptyCount > 0) { + console.warn(`+ Warning: removed ${emptyCount} empty translations`); +} +if (fuzzyCount > 0) { + console.warn(`+ Warning: removed ${fuzzyCount} fuzzy translations`); } -fs.writeFileSync(outputFile, JSON.stringify(translationObject, null, 2), "utf-8"); +fs.writeFileSync(outputFile, JSON.stringify(translationObject), "utf-8"); diff --git a/bin/i18n/build-translation-resources b/bin/i18n/build-translation-resources index 49013975c1f2286d241bc7601c74e193cd297212..a968e3169974edb3efc5a2d820df621620b7006f 100755 --- a/bin/i18n/build-translation-resources +++ b/bin/i18n/build-translation-resources @@ -9,7 +9,12 @@ fi POT_NAME="locales/metabase.pot" LOCALES=$(find locales -type f -name "*.po" -exec basename {} .po \;) -LOCALES_QUOTED=$(echo "$LOCALES" | awk '{ printf "\"%s\" ", $0 }') + +if [ -z "$LOCALES" ]; then + LOCALES_QUOTED="" +else + LOCALES_QUOTED=" $(echo "$LOCALES" | awk '{ printf "\"%s\" ", $0 }')" +fi FRONTEND_LANG_DIR="resources/frontend_client/app/locales" @@ -17,7 +22,7 @@ FRONTEND_LANG_DIR="resources/frontend_client/app/locales" # NOTE: include "en" even though we don't have a .po file for it because it's the default? cat << EOF > "resources/locales.clj" { - :locales #{"en" $LOCALES_QUOTED} + :locales #{"en"$LOCALES_QUOTED} :packages ["metabase"] :bundle "metabase.Messages" } diff --git a/bin/i18n/update-translation-template b/bin/i18n/update-translation-template index a7d2a7120c4495030d877db9cf163f117c849c54..15eba26961d183e6544630c77ba3d5933f57f39a 100755 --- a/bin/i18n/update-translation-template +++ b/bin/i18n/update-translation-template @@ -25,6 +25,10 @@ mkdir -p "locales" BABEL_ENV=extract ./node_modules/.bin/babel -q -x .js,.jsx -o /dev/null frontend/src # BABEL_ENV=extract BABEL_DISABLE_CACHE=1 yarn run build +# NOTE: replace c-3po's "${ 0 }" style references with xgettext "{0}" style references for consistency +sed -i".bak" -E 's/\$\{ *([0-9]+) *\}/{\1}/g' "$POT_FRONTEND_NAME" +rm "$POT_FRONTEND_NAME.bak" + # update backend pot # xgettext before 0.19 does not understand --add-location=file. Even CentOS @@ -48,7 +52,8 @@ find src -name "*.clj" | xgettext \ --add-comments --sort-by-file \ -o $POT_BACKEND_NAME -f - -sed -i "" -e 's/charset=CHARSET/charset=UTF-8/' "$POT_BACKEND_NAME" +sed -i".bak" 's/charset=CHARSET/charset=UTF-8/' "$POT_BACKEND_NAME" +rm "$POT_BACKEND_NAME.bak" # merge frontend and backend pots msgcat "$POT_FRONTEND_NAME" "$POT_BACKEND_NAME" > "$POT_NAME" diff --git a/bin/version b/bin/version index 9eac5b0dd8c57d5fdbed8446d7202674fe49232f..358f1cdb73914c882c023f94cec610ef72cb6eb2 100755 --- a/bin/version +++ b/bin/version @@ -1,6 +1,6 @@ #!/usr/bin/env bash -VERSION="v0.29.0-snapshot" +VERSION="v0.29.1" # 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/03-metadata-editing.md b/docs/administration-guide/03-metadata-editing.md index ecda1bad02a5280d93ad3fcf502ced2ebaec2676..648c538b5f84fdbb2a14b63891b422b19f333766 100644 --- a/docs/administration-guide/03-metadata-editing.md +++ b/docs/administration-guide/03-metadata-editing.md @@ -91,6 +91,13 @@ Another option is custom remapping, which is currently only possible for numeric  +### Picking the filter UI for a field + +Metabase will automatically try to pick the best kind of filter interface for each field based on that field's type and the number of different values in it. Fields with only a few possible choices, like a `Gender` field, will display a dropdown list by default when filtering on them; fields with more than 100 possible selections will show a search box with autocomplete. + +If Metabase picked the wrong kind of filter UI for one of your fields, you can manually change it. You can choose from a drop down list, a search box, or just a plain input box: + + --- diff --git a/docs/administration-guide/images/filter-options.png b/docs/administration-guide/images/filter-options.png new file mode 100644 index 0000000000000000000000000000000000000000..6e728d8dc9398225ae892015f7a804504f3804fe Binary files /dev/null and b/docs/administration-guide/images/filter-options.png differ diff --git a/docs/contributing.md b/docs/contributing.md index fc7f8ea400090107f433a0b5eac9887cd143a622..632bb4ecea3fb7df195066ecf19ddb2afa5c9567 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -6,7 +6,7 @@ In this guide, we'll discuss how Metabase is built. This should give you a good ## What we're trying to build -Metabase is all about letting non-technical users get access to the their organization's data. We're trying to maximize the amount of power that can be comfortably used by someone who understands their business, is quantitatively bent, but probably only comfortable with Excel. +Metabase is all about letting non-technical users get access to the their organization's data. We're trying to maximize the amount of power that can be comfortably used by someone who understands their business, is quantitatively bent, but probably only comfortable with Excel. It's important to keep in mind these goals of the Metabase project. Many times proposals will be marked "Out of Scope" or otherwise deprioritized. This doesn't mean the proposal isn't useful, or that we wouldn't be interested in seeing it done as a side project or as an experimental branch. However, it does mean that we won't point the core team or contributors to it in the near term. Issues that are slightly out of scope will be kept open in case there is community support (and ideally contributions). @@ -15,7 +15,7 @@ To get a sense for the end goals, make sure to read the [Zen of Metabase](../zen ## Our product process: -The core team runs a pretty well defined product process. It is actively being tweaked, but the below is a pretty faithful description of it at the time of writing. You should have a clear idea of how we work before jumping in with a PR. +The core team runs a pretty well defined product process. It is actively being tweaked, but the below is a pretty faithful description of it at the time of writing. You should have a clear idea of how we work before jumping in with a PR. ### A) Identify product needs from the community @@ -29,22 +29,25 @@ We typically will collect a group of issues or suggestions into a new topline fe Once a feature has been defined, typically it will be taken on by a product designer. Here, they will produce low fi mocks, get feedback from our users and community, and iterate. -Once the main UX flows have been dialed in, there will be a hi-fidelity visual design. +Once the main UX flows have been dialed in, there will be a hi-fidelity visual design. Features that are ready for design are are tagged [Design Needed](https://github.com/metabase/metabase/labels/Design%2FNeeded). Once a feature has had a reasonably complete visual design it should be tagged [Help Wanted](https://github.com/metabase/metabase/labels/Help%20Wanted). ### D) Build the feature Once a feature is tagged [Help Wanted](https://github.com/metabase/metabase/labels/Help%20Wanted), it is considered ready to be built. A core team member (or you, awesomely helpful person that you are) can start working on it. -Once one or more people have started to work on a feature, it should be marked [In Progress](https://github.com/metabase/metabase/labels/In%20Progress). Once there is a branch+some code, a pull request is opened, linked to the feature + any issues that were pulled together to inform the feature. + +If you're building something that users will see in Metabase, please refer to the [Style Guide](https://localhost:3000/_internal) while running the development environment to learn how and when to use various Metabase UI elements. + +Once one or more people have started to work on a feature, it should be marked [In Progress](https://github.com/metabase/metabase/labels/In%20Progress). Once there is a branch+some code, a pull request is opened, linked to the feature + any issues that were pulled together to inform the feature. ### E) Verification and merging All PRs that involve more than an insignificant change should be reviewed. See our [Code Review Process](code-reviews.md). - -If all goes well, the feature gets coded up, verified and then the pull request gets merged! High-fives all around. -If there are tests missing, code style concerns or specific architectural issues in the pull request, they should be fixed before merging. We have a very high bar on both code and product quality and it's important that this be maintained going forward, so please be patient with us here. +If all goes well, the feature gets coded up, verified and then the pull request gets merged! High-fives all around. + +If there are tests missing, code style concerns or specific architectural issues in the pull request, they should be fixed before merging. We have a very high bar on both code and product quality and it's important that this be maintained going forward, so please be patient with us here. ## Ways to help: @@ -72,14 +75,14 @@ By our definition, "Bugs" are situations where the program doesn't do what it wa There are a lot of docs. We often have difficulties keeping them up to date. If you are reading them and you notice inconsistencies, errors or outdated information, please help up keep them current! -### Working on features +### Working on features -Some features, eg Database drivers, don't have any user facing pixels. These are a great place to start off contributing as they don't require as much communication, discussions about tradeoffs and process in general. +Some features, eg Database drivers, don't have any user facing pixels. These are a great place to start off contributing as they don't require as much communication, discussions about tradeoffs and process in general. -In situations where a design has already been done, we can always use some help. Chime in on a pull request or an issue and offer to help. +In situations where a design has already been done, we can always use some help. Chime in on a pull request or an issue and offer to help. Generally speaking, any issue in [Help Wanted](https://github.com/metabase/metabase/labels/Help%20Wanted) is fair game. ### #YOLO JUST SUBMIT A PR -If you come up with something really cool, and want to share it with us, just submit a PR. If it hasn't gone through the above process, we probably won't merge it as is, but if it's compelling, we're more than willing to help you via code review, design review and generally OCD nitpicking so that it fits into the rest of our codebase. +If you come up with something really cool, and want to share it with us, just submit a PR. If it hasn't gone through the above process, we probably won't merge it as is, but if it's compelling, we're more than willing to help you via code review, design review and generally OCD nitpicking so that it fits into the rest of our codebase. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 7c925ca75e35195832f291221786e30639079c3a..115ee34fa09b2845364382d83462335db678333d 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -124,6 +124,7 @@ Integration tests use an enforced file naming convention `<test-suite-name>.inte Useful commands: ```bash ./bin/build version uberjar # Builds the JAR without frontend assets; run this every time you need to update the backend +lein run refresh-integration-test-db-metadata # Scan the sample dataset and re-run sync/classification/field values caching yarn run test-integrated-watch # Watches for file changes and runs the tests that have changed yarn run test-integrated-watch -- TestFileName # Watches the files in paths that match the given (regex) string ``` @@ -265,7 +266,8 @@ Start up an instant cheatsheet for the project + dependencies by running lein instant-cheatsheet ## Internationalization -We are an application with lots of users all over the world. To help them use Metabase in their own language, we mark all of our strings as i18n. The general workflow is: +We are an application with lots of users all over the world. To help them use Metabase in their own language, we mark all of our strings as i18n. +### The general workflow for developers is: 1. Tag strings in the frontend using `t` and `jt` ES6 template literals (see more details in https://c-3po.js.org/): @@ -281,10 +283,16 @@ and in the backend using `trs` and related macros (see more details in https://g ``` 2. When you have added/edited tagged strings in the code, run `./bin/i18n/update-translations` to update the base `locales/metabase.pot` template and each existing `locales/LOCALE.po` -3. To add a new translaction run `./bin/i18n/update-translation LOCALE` -4. Edit translation in `locales/LOCALE.po` -5. Run `./bin/i18n/build-translation-resources` to compile translations for frontend and backend -6. Restart or rebuild Metabase + +### The workflow for translators in starting a new translation, or editing an existing one: + +1. You should run `./bin/i18n/update-translations` first to ensure the latest strings have been extracted. +2. If you're starting a new translation or didn't run update-translations then run `./bin/i18n/update-translation LOCALE` +3. Edit ./locales/LOCALE.po +4. `Run ./bin/i18n/build-translation-resources` +5. Restart or rebuild Metabase, Test, repeat 2 and 3 +6. Commit changes to ./locales/LOCALE.po and ./resources/frontend_client/app/locales/LOCALE.json + To try it out, change your browser's language (e.x. chrome://settings/?search=language) to one of the locales to see it working. Run metabase with the `JAVA_TOOL_OPTIONS=-Duser.language=LOCALE` environment variable set to set the locale on the backend, e.x. for pulses and emails (eventually we'll also add a setting in the app) diff --git a/docs/operations-guide/running-metabase-on-debian.md b/docs/operations-guide/running-metabase-on-debian.md index 2fa2a4ac96115a488caebd2773597b6ad6b6008c..4178d8b451a598307f507fb4b30516a7ad76c01d 100644 --- a/docs/operations-guide/running-metabase-on-debian.md +++ b/docs/operations-guide/running-metabase-on-debian.md @@ -104,7 +104,7 @@ In `/etc/init.d/metabase`, replace configurable items (they look like `<some-var uninstall) uninstall ;; - retart) + restart) stop start ;; diff --git a/docs/operations-guide/running-metabase-on-elastic-beanstalk.md b/docs/operations-guide/running-metabase-on-elastic-beanstalk.md index 1e8e23c4fdab762c149f7d30032af6c143c8f21b..6e65b2b924418f186b6905b3c972ae9ca2f3cc5e 100644 --- a/docs/operations-guide/running-metabase-on-elastic-beanstalk.md +++ b/docs/operations-guide/running-metabase-on-elastic-beanstalk.md @@ -26,11 +26,11 @@ The rest of this guide will follow each phase of the Elastic Beanstalk setup ste ### New Application -You should now see a screen that looks like +You should now see a screen that looks like  -NOTE: If this screenshot does not match what you see in the Elastic Beanstalk console, it is likely that you are on an old version of the Elastic Beanstalk UI. At the time of writing this documentation, both versions of the UI are being reported in the wild. You can view our older documenatation [here](running-metabase-on-elastic-beanstalk-old.md) +NOTE: If this screenshot does not match what you see in the Elastic Beanstalk console, it is likely that you are on an old version of the Elastic Beanstalk UI. At the time of writing this documentation, both versions of the UI are being reported in the wild. You can view our older documentation [here](running-metabase-on-elastic-beanstalk-old.md) Elastic Beanstalk is organized into Applications and Environments, so to get started we must create a new Application. Enter the application name `Metabase` and continue by clicking `Next`. @@ -52,7 +52,7 @@ And of course if you don't care about the URL you can simply leave it to whateve ### New Environment -Elastic Beanstalk provides two choices for environments within an Application, but you should leave the setting to `Web Server` on that landing page. +Elastic Beanstalk provides two choices for environments within an Application, but you should leave the setting to `Web Server` on that landing page.  @@ -80,7 +80,7 @@ You will need to enable enhanced health checks for your Beanstalk Monitoring. Click on the `modify` link under the Monitoring section as below.  -Then make sure enhanced health checks are enabled. This is a free option, unless you later add specific metrics to CloudWatch. +Then make sure enhanced health checks are enabled. This is a free option, unless you later add specific metrics to CloudWatch.  @@ -95,7 +95,7 @@ To set the database password from the Beanstalk template, hit "Review and Launch  -Once there, enter a database username and password. We suggest you hold onto this in a password manager, as it can be useful for things like backups or troubleshooting. +Once there, enter a database username and password. We suggest you hold onto this in a password manager, as it can be useful for things like backups or troubleshooting.  diff --git a/docs/operations-guide/running-metabase-on-heroku.md b/docs/operations-guide/running-metabase-on-heroku.md index 4ef364c065b57c15af571904e7feae51328a7030..7895a2926f22f3595444fd1f319ad2bd62d9b2c9 100644 --- a/docs/operations-guide/running-metabase-on-heroku.md +++ b/docs/operations-guide/running-metabase-on-heroku.md @@ -11,7 +11,9 @@ If you've got a Heroku account then all there is to do is follow this one-click [](http://downloads.metabase.com/launch-heroku.html) -This will launch a Heroku deployment using a github repository that Metabase maintains. +This will launch a Heroku deployment using a GitHub repository that Metabase maintains. + +It should only take a few minutes for Metabase to start. You can check on the progress by viewing the logs at [https://dashboard.heroku.com/apps/YOUR_APPLICATION_NAME/logs](https://dashboard.heroku.com/apps/YOUR_APPLICATION_NAME/logs) or using the Heroku command line tool with the `heroku logs -t -a YOUR_APPLICATION_NAME` command. ### Upgrading beyond the `Free` tier @@ -27,6 +29,7 @@ Heroku is very kind and offers a free tier to be used for very small/non-critica * Heroku’s 30 second timeouts on all web requests can cause a few issues if you happen to have longer running database queries. Most people don’t run into this but be aware that it’s possible. * When using the `free` tier, if you don’t access the application for a while Heroku will sleep your Metabase environment. This prevents things like Pulses and Metabase background tasks from running when scheduled and at times makes the app appear to be slow when really it's just Heroku reloading your app. You can resolve this by upgrading to the `hobby` tier or higher. + * Sometimes Metabase may run out of memory and you will see messages like `Error R14 (Memory quota exceeded)` in the Heroku logs. If this happens regularly we recommend upgrading to the `standard-2x` tier dyno. Now that you’ve installed Metabase, it’s time to [set it up and connect it to your database](../setting-up-metabase.md). diff --git a/docs/operations-guide/start.md b/docs/operations-guide/start.md index ee86cdd0431b1917f9c12a1a533f72d6f5a9f8d8..276099552c53326dfb897f2a466bfbeb42ca84d2 100644 --- a/docs/operations-guide/start.md +++ b/docs/operations-guide/start.md @@ -12,6 +12,7 @@ * [Changing password complexity](#changing-metabase-password-complexity) * [Handling Timezones](#handling-timezones-in-metabase) * [Configuring Emoji Logging](#configuring-emoji-logging) +* [Configuring Logging Level](#configuring-logging-level) * [How to setup monitoring via JMX](#monitoring-via-jmx) * [A word on Java versions](#java-versions) @@ -81,6 +82,7 @@ The application database is where Metabase stores information about users, saved **NOTE:** currently Metabase does not provide automated support for migrating data from one application database to another, so if you start with H2 and then want to move to Postgres you'll have to dump the data from H2 and import it into Postgres before relaunching the application. #### [H2](http://www.h2database.com/) (default) + To use the H2 database for your Metabase instance you don't need to do anything at all. When the application is first launched it will attempt to create a new H2 database in the same filesystem location the application is launched from. You can see these database files from the terminal: @@ -98,6 +100,7 @@ If for any reason you want to use an H2 database file in a separate location fro export MB_DB_FILE=/the/path/to/my/h2.db java -jar metabase.jar +Note that H2 automatically appends `.mv.db` or `.h2.db` to the path you specify; do not include those in you path! In other words, `MB_DB_FILE` should be something like `/path/to/metabase.db`, rather than something like `/path/to/metabase.db.mv.db` (even though this is the file that actually gets created). #### [Postgres](http://www.postgresql.org/) @@ -132,13 +135,13 @@ This will tell Metabase to look for its application database using the supplied # Migrating from using the H2 database to MySQL or Postgres -If you decide to use the default application database (H2) when you initially start using Metabase, but decide later that you'd like to switch to a more production ready database such as MySQL or Postgres we make the transition easy for you. +If you decide to use the default application database (H2) when you initially start using Metabase, but later decide that you'd like to switch to a more production-ready database such as MySQL or Postgres, we make the transition easy for you. Metabase provides a custom migration command for upgrading H2 application database files by copying their data to a new database. Here's what you'll want to do: 1. Shutdown your Metabase instance so that it's not running. This ensures no accidental data gets written to the db while migrating. 2. Make a backup copy of your H2 application database by following the instructions in [Backing up Metabase Application Data](#backing-up-metabase-application-data). Safety first! -3. Run the Metabase data migration command using the appropriate environment variables for the target database you want to migrate to. You can find details about specifying MySQL and Postgres databases at [Configuring the application database](#configuring-the-metabase-application-database). Here's an example of migrating to Postgres. +3. Run the Metabase data migration command using the appropriate environment variables for the target database you want to migrate to. You can find details about specifying MySQL and Postgres databases at [Configuring the application database](#configuring-the-metabase-application-database). Here's an example of migrating to Postgres: ``` export MB_DB_TYPE=postgres @@ -147,20 +150,21 @@ export MB_DB_PORT=5432 export MB_DB_USER=<username> export MB_DB_PASS=<password> export MB_DB_HOST=localhost -java -jar metabase.jar load-from-h2 <path-to-metabase-h2-database-file> +java -jar metabase.jar load-from-h2 /path/to/metabase.db # do not include .mv.db or .h2.db suffix ``` -It is expected that you will run the command against a brand new (empty!) database and Metabase will handle all of the work of creating the database schema and migrating the data for you. +It is expected that you will run the command against a brand-new (empty!) database; Metabase will handle all of the work of creating the database schema and migrating the data for you. ###### Notes -* It is required that wherever you are running this migration command can connect to the target MySQL or Postgres database. So if you are attempting to move the data to a cloud database make sure you take that into consideration. +* It is required that you can connect to the target MySQL or Postgres database in whatever environment you are running this migration command in. So, if you are attempting to move the data to a cloud database, make sure you take that into consideration. * The code that handles these migrations uses a Postgres SQL command that is only available in Postgres 9.4 or newer versions. Please make sure you Postgres database is version 9.4 or newer. +* H2 automatically adds a `.h2.db` or `.mv.db` extension to the database path you specify, so make sure the path to the DB file you pass to the command *does not* include it. For example, if you have a file named `/path/to/metabase.db.h2.db`, call the command with `load-from-h2 /path/to/metabase.db`. # Running Metabase database migrations manually -When Metabase is starting up it will typically attempt to determine if any changes are required to the application database and it will execute those changes automatically. If for some reason you wanted to see what these changes are and run them manually on your database then we let you do that. +When Metabase is starting up, it will typically attempt to determine if any changes are required to the application database, and, if so, will execute those changes automatically. If for some reason you wanted to see what these changes are and run them manually on your database then we let you do that. Simply set the following environment variable before launching Metabase: diff --git a/docs/users-guide/05-visualizing-results.md b/docs/users-guide/05-visualizing-results.md index 1febf66335ee5029cb7a47b8841e2403df65195d..a9189aa701ef190d6e66824e9cab496346baf500 100644 --- a/docs/users-guide/05-visualizing-results.md +++ b/docs/users-guide/05-visualizing-results.md @@ -9,6 +9,7 @@ In Metabase, an answer to a question can be visualized in a number of ways: * Table * Line chart * Bar chart +* Row chart * Area chart * Scatterplot or bubble chart * Pie/donut chart @@ -43,14 +44,19 @@ The Table option is good for looking at tabular data (duh), or for lists of thin  #### Line, bar, and area charts -Line charts are best for displaying the trend of a number over time, especially when you have lots of x-axis values. Bar charts are great for displaying a metric grouped by a category (e.g., the number of users you have by country), and they can also be useful for showing a number over time if you have a smaller number of x-axis values (like orders per month this year). +Line charts are best for displaying the trend of a number over time, especially when you have lots of x-axis values. Bar charts are great for displaying a number grouped by a category (e.g., the number of users you have by country), and they can also be useful for showing a number over time if you have a smaller number of x-axis values (like orders per month this year).  -Area charts are useful when comparing the the proportions between two metrics over time. Both bar and area charts can be stacked. +Area charts are useful when comparing the proportions of two metrics over time. Both bar and area charts can be stacked.  +#### Row charts +If you're trying to group a number by a field that has a lot of possible values, like a Vendor or Product Title field, try visualizing it as a row chart. Metabase will show you the bars in descending order of size, with a final bar at the bottom for items that didn't fit. + + + ##### Histograms If you have a bar chart like Count of Users by Age, where the x-axis is a number, you'll get a special kind of chart called a **histogram**, where each bar represents a range of values (called a "bin"). Note that Metabase will automatically bin your results any time you use a number as a grouping, even if you aren't viewing a bar chart. Questions that use latitude and longitude will also get binned automatically. diff --git a/docs/users-guide/08-dashboard-filters.md b/docs/users-guide/08-dashboard-filters.md index 51c01f38ac97d7ca9ed6ff9e3c76d63cb78df728..89f302bb448c91e6a0e06bb92bd3e11f209cd3b1 100644 --- a/docs/users-guide/08-dashboard-filters.md +++ b/docs/users-guide/08-dashboard-filters.md @@ -29,8 +29,7 @@ Now we’ve entered a new mode where we’ll need to wire up each card on our da So here’s what we’re doing — when we pick a month and year with our new filter, the filter needs to know which field in the card to filter on. For example, if we have a `Total Orders` card, and each order has a `Date Ordered` as well as a `Date Delivered`, we have to pick which of those fields to filter — do we want to see all the orders *placed* in January, or do we want to see all the orders *delivered* in January? So, for each card on our dashboard, we’ll pick a date field to connect to the filter. If one of your cards says there aren’t any valid fields, that just means that card doesn’t contain any fields that match the kind of filter you chose. #### Filtering SQL-based cards -Note that if your dashboard includes cards that were created using the SQL/native query editor, you'll need to add a bit of additional markup to the SQL in those cards in order to use a dashboard filter on them. [Using SQL parameters](13-sql-parameters.md) - +**Important:** note that if your dashboard includes saved questions that were created using the SQL/native query editor, you'll need to [add a bit of additional markup to your query](13-sql-parameters.md) to add a "field filter variable" in order to use a dashboard filter with your SQL/native questions.  @@ -58,6 +57,24 @@ Once you’ve added a filter to your dashboard, just click on it to select a val  +### Choosing between a dropdown or autocomplete for your filter + +Picking selections for a filter with lots of options is easier than ever before. If the field you're using for a filter has more than 100 unique values, you'll now automatically see a search box with autocomplete suggestions. + + + +Fields with fewer than 100 distinct values will have display a list of all the options. + + + +In both cases, you can pick one or multiple selections for your filter. + + + +If Metabase somehow picked the wrong behavior for your field, admins can go to the Data Model section of the admin panel and click on the gear icon by the field in question to manually choose between a list, a search box, or just a plain input box. + + + ### Best practices Here are a few tips to get the most out of dashboard filters: diff --git a/docs/users-guide/10-pulses.md b/docs/users-guide/10-pulses.md index dfd7c5fba0b1dab35ef2276d168e14a16e8b6ed2..511f5cdc65d2b33c24cd41a5c941f2aa8fb00ba2 100644 --- a/docs/users-guide/10-pulses.md +++ b/docs/users-guide/10-pulses.md @@ -1,18 +1,18 @@ -## Sharing Updates with Pulses +## Sharing updates with pulses The Pulses feature in Metabase gives you the ability to automatically send regular updates to your teammates to help everyone keep track of changes to the metrics that matter to you most. You can deliver a pulse via email or [Slack](https://slack.com/), on the schedule of your choice. -You can create a pulse and view all of the existing pulses by clicking the `Pulses` link from the top menu. Click `Create a pulse` to get started. +Click the `Pulses` link in the top menu to view all of your pulses, and click `Create a pulse` to make a new one.  -### Name It +### Name it First, choose a name for your pulse. This will show up in the email subject line and the Slack message title, so choose something that will let people know what kind of updates the pulse will contain, like “Daily Marketing Update,†or “Users Metrics.† -### Pick Your Data -Before you can create a pulse, you’ll need to have some [saved questions](06-sharing-answers.md). You can choose up to five of them to put into a single pulse. Click the dropdown to see a list of all your saved questions. You can type in the dropdown to help filter and find the question you’re looking for. +### Pick your data +Before you can create a pulse, you’ll need to have some [saved questions](06-sharing-answers.md). Click the dropdown to see a list of all your saved questions. You can type in the dropdown to help filter and find the question you’re looking for.  @@ -20,6 +20,10 @@ When you select a saved question, Metabase will show you a preview of how it’l  +Now you can include tables in your pulses as well. They'll be capped to 10 columns and 20 rows, and for emailed pulses the rest of the results will be included automatically as a file attachment, with a limit of 2,000 rows. + + + #### Attaching a .csv or .xls with results You can also optionally include the results of a saved question in an emailed pulse as a .csv or .xls file attachment. Just click the paperclip icon on an included saved question to add the attachment. Click the paperclip again to remove the attachment. @@ -36,12 +40,12 @@ Your attachments will be included in your emailed pulse just like a regular emai #### Limitations Currently, there are a few restrictions on what kinds of saved questions you can put into a pulse: -* Raw data questions won't be displayed, but will be included as a file attachment -* Pivot tables will be cropped to a maximum of three columns and 10 rows -* Bar charts (and pie charts which get turned into bar charts) will be cropped to one column for the labels, one column for the values, and 10 total rows +* Raw data questions are capped to 10 columns and 20 rows. For emailed pulses, the rest of the results will be included automatically as a file attachment, with a limit of 2,000 rows. +* Pivot tables will be cropped to a maximum of three columns and 10 rows. +* Bar charts (and pie charts which get turned into bar charts) will be cropped to one column for the labels, one column for the values, and 10 total rows. -### Choose How and When to Deliver Your Data -Each pulse you create can be delivered by email, Slack, or both. You can also set a different delivery schedule for email versus Slack. To deliver by email, just type in the email addresses you want to send the pulse to, separated by commas. Then, choose to either send it daily, weekly, or monthly, and the time you want it to be sent. +### Choose how and when to deliver your data +Each pulse you create can be delivered by email, Slack, or both. You can also set a different delivery schedule for email versus Slack. To deliver by email, just type in the Metabase user names, or email addresses you want to send the pulse to, separated by commas. Then, choose to either send it daily, weekly, or monthly, and the time at which you want it to be sent.  diff --git a/docs/users-guide/14-x-rays.md b/docs/users-guide/14-x-rays.md index 2c9a64fca48924675011fd246e31ce6e67b423a9..09946cf01a2d6df3a1876b3c35a4f3b0dfbff5bd 100644 --- a/docs/users-guide/14-x-rays.md +++ b/docs/users-guide/14-x-rays.md @@ -1,58 +1,40 @@ -## X-rays and Comparisons +## X-rays --- -X-rays and comparisons are two powerful new features in Metabase that allow you to get deeper statistical reports about your segments, fields, and time series. +X-rays are a fast and easy way to get automatic insights and explorations of your data. -### Time series x-rays +### Exploring newly added datasets -To view an x-ray report for a time series, open up a saved time series question (any kind of chart or table with a metric broken out by time), click on the Action Menu in the bottom-right of the screen, and select "X-ray this question:" +When you first connect a database to Metabase, Metabot will offer to show you some automated explorations of your data. - + -You'll get an in-depth analysis of your time series question, including growth rates, the distribution of values, and seasonality: +Click on one of these to see an x-ray. - + -### Segment, table, and field x-rays -To view an x-ray for a segment, table, or field, first go to the Data Reference, then navigate to the thing you want to x-ray, then select the x-ray option in the lefthand menu: +You can see more suggested x-rays over on the right-hand side of the screen. Browsing through x-rays like this is a pretty fun way of getting a quick overview of your data. - +### Saving x-rays -If you have a saved Raw Data question that uses one or more segments as filters, you can also x-ray one of those segments from the Action Menu in the bottom-right of the screen when viewing that question: +If you're logged in as an Administrator and you come across an x-ray that's particularly interesting, you can save it as a dashboard by clicking the green Save button. Metabase will create a new dashboard for you and put all of its charts in a new collection. The new collection and dashboard will only be visible to other Administrators by default. - +To quickly make your new dashboard visible to other users, go to the collection with its charts, click the lock icon to edit the collection's permissions, and choose which groups should be allowed to view the charts in this collection. Note that this might allow users to see charts and data that they might not normally have access to. For more about how Metabase handles permissions, check out these posts about [collection permissions](../administration-guide/06-collections.md) and [data access permissions](../administration-guide/05-setting-permissions.md). -An x-ray report for a segment called "Californians" looks like this, displaying a summary of the distribution of values for each field in the segment, and the maximal and minimal values if applicable: +### Creating x-rays by clicking on charts or tables - +One great way to explore your data in general in Metabase is to click on points of interest in charts or tables, which shows you ways to further explore that point. We've added x-rays to this action menu, so if you for example find a point on your line chart that seems extra interesting, give it a click and x-ray it! We think you'll like what you see. -Clicking on the summary for any field will take you to the detailed x-ray report for that single field. + -### Changing the fidelity of an x-ray +### X-rays in the Data Reference -X-rays can be a somewhat costly or slow operation for your database to run, so by default Metabase only does a quick sampling of the segment or field you're x-raying. You can increase the fidelity in the top-right of the x-ray page: +You can also create an x-ray by navigating to a table, field, metric, or segment in the [Data Reference](./12-data-model-reference.md). Just click the x-ray link in the left sidebar. - + -Administrators can also set the maximum allowed fidelity for x-rays in the Admin Panel. Note that the `Extended` setting is required for time series x-rays to work. Admins can even turn x-rays off entirely, but that makes Simon cry. No one likes it when Simon cries. +### Where did the old x-rays go? -### Comparing segments - -Segments are a subset of a larger table or list, so one thing you can do when viewing an x-ray of a segment is compare it to its "parent" table. For example, if I have a segment called "Californians," which is a subset of the "People" table, I can click on the button that says "Compare to all People" to see a comparison report: - - - -The comparison report shows how many rows there are in the segment versus the parent table, and also gives you a breakdown of how the fields in the segment differ from that of the parent table: - - - -An example for where this can be especially useful is a scenario where you've defined many different segments for your users or customers, like "Repeat Customers," "Users between 18 and 35," or "Female customers in Kalamazoo who dislike cheese." You can open up the x-ray for any of these segments, and then compare them to the larger Users or Customers table to see if there are any interesting patterns or differences. - -## Automated insights -Metabase hasn't quite achieved self-awareness, but it has gotten smarter recently. It will now show you relevant insights about your data at the top of x-rays about time series or numeric fields, provided there's something insightful to say. - - - -Insights include things like whether or not your data has an overall trend, has uncharacteristic spikes or dips, or if it follows a similar pattern at regular intervals. +We're reworking the way we do things like time series growth analysis and segment comparison, which were present in the previous version of x-rays. In the meantime, we've removed those previous x-rays, and will bring those features back in a more elegant and streamlined way in a future version of Metabase. ## Need help? If you still have questions about x-rays or comparisons, you can head over to our [discussion forum](http://discourse.metabase.com/). See you there! diff --git a/docs/users-guide/images/dashboard-filters/autocomplete.png b/docs/users-guide/images/dashboard-filters/autocomplete.png new file mode 100644 index 0000000000000000000000000000000000000000..78355b601c7db381ba6301a4d08f08f39570cba1 Binary files /dev/null and b/docs/users-guide/images/dashboard-filters/autocomplete.png differ diff --git a/docs/users-guide/images/dashboard-filters/dashboard-filters.png b/docs/users-guide/images/dashboard-filters/dashboard-filters.png index 4803a544d0660dbc830837e33ec0341a1783a845..fb7047329b4c72431aa7f0fb2ed14b4f2ffa2dcf 100644 Binary files a/docs/users-guide/images/dashboard-filters/dashboard-filters.png and b/docs/users-guide/images/dashboard-filters/dashboard-filters.png differ diff --git a/docs/users-guide/images/dashboard-filters/list.png b/docs/users-guide/images/dashboard-filters/list.png new file mode 100644 index 0000000000000000000000000000000000000000..c514f64969fadbc0744c6c580fd8e8560bb4227f Binary files /dev/null and b/docs/users-guide/images/dashboard-filters/list.png differ diff --git a/docs/users-guide/images/dashboard-filters/multi-select.png b/docs/users-guide/images/dashboard-filters/multi-select.png new file mode 100644 index 0000000000000000000000000000000000000000..f21b6e58c34897c2d812ee3a4ba4924cf1562ddd Binary files /dev/null and b/docs/users-guide/images/dashboard-filters/multi-select.png differ diff --git a/docs/users-guide/images/dashboard-filters/search-options.png b/docs/users-guide/images/dashboard-filters/search-options.png new file mode 100644 index 0000000000000000000000000000000000000000..ef938395734980a26e30a9926182651c3f2c0ef2 Binary files /dev/null and b/docs/users-guide/images/dashboard-filters/search-options.png differ diff --git a/docs/users-guide/images/pulses/table.png b/docs/users-guide/images/pulses/table.png new file mode 100644 index 0000000000000000000000000000000000000000..b859a009021d56f2479589593cb98e497eb56e92 Binary files /dev/null and b/docs/users-guide/images/pulses/table.png differ diff --git a/docs/users-guide/images/visualizations/row.png b/docs/users-guide/images/visualizations/row.png new file mode 100644 index 0000000000000000000000000000000000000000..28b451b60f9c9b557de03848f1273ad4ef7b1b3a Binary files /dev/null and b/docs/users-guide/images/visualizations/row.png differ diff --git a/docs/users-guide/images/x-rays/data-reference.png b/docs/users-guide/images/x-rays/data-reference.png new file mode 100644 index 0000000000000000000000000000000000000000..e4521e8bdd945bf5574efc157f5b51c9e4cf270d Binary files /dev/null and b/docs/users-guide/images/x-rays/data-reference.png differ diff --git a/docs/users-guide/images/x-rays/drill-through.png b/docs/users-guide/images/x-rays/drill-through.png new file mode 100644 index 0000000000000000000000000000000000000000..749d9df5f11adc09a75869b3c47f0ab0bdfcc453 Binary files /dev/null and b/docs/users-guide/images/x-rays/drill-through.png differ diff --git a/docs/users-guide/images/x-rays/example.png b/docs/users-guide/images/x-rays/example.png new file mode 100644 index 0000000000000000000000000000000000000000..629cb481dc3b92199c8907ebe025f02b77d15c2c Binary files /dev/null and b/docs/users-guide/images/x-rays/example.png differ diff --git a/docs/users-guide/images/x-rays/suggestions.png b/docs/users-guide/images/x-rays/suggestions.png new file mode 100644 index 0000000000000000000000000000000000000000..ee2e1f1c5e63bbf8058cadd70ab1ea055e47d532 Binary files /dev/null and b/docs/users-guide/images/x-rays/suggestions.png differ diff --git a/frontend/interfaces/underscore.js b/frontend/interfaces/underscore.js index 43d9d747f6583b79885d2742a9ac2e640cec3cae..b66b10caa1e0b88b15f66d32b7f80a73f1df980a 100644 --- a/frontend/interfaces/underscore.js +++ b/frontend/interfaces/underscore.js @@ -111,6 +111,11 @@ declare module "underscore" { declare function debounce<T: any => any>(func: T): T; + declare function partition<T>( + array: T[], + pred: (val: T) => boolean, + ): [T[], T[]]; + // TODO: improve this declare function chain<S>(obj: S): any; diff --git a/frontend/src/metabase-lib/lib/Question.js b/frontend/src/metabase-lib/lib/Question.js index 65ca7614916ab9107b954ca2923ebbf1ea8f7dbe..c319b89fec0603cde367b2c54c6bea12684fde0c 100644 --- a/frontend/src/metabase-lib/lib/Question.js +++ b/frontend/src/metabase-lib/lib/Question.js @@ -14,7 +14,7 @@ import NativeQuery from "./queries/NativeQuery"; import { memoize } from "metabase-lib/lib/utils"; import * as Card_DEPRECATED from "metabase/lib/card"; -import { getParametersWithExtras } from "metabase/meta/Card"; +import { getParametersWithExtras, isTransientId } from "metabase/meta/Card"; import { summarize, @@ -382,6 +382,25 @@ export default class Question { : Urls.question(this.id(), ""); } + getAutomaticDashboardUrl(filters /*?: Filter[] = []*/) { + let cellQuery = ""; + if (filters.length > 0) { + const mbqlFilter = filters.length > 1 ? ["and", ...filters] : filters[0]; + cellQuery = `/cell/${Card_DEPRECATED.utf8_to_b64url( + JSON.stringify(mbqlFilter), + )}`; + } + const questionId = this.id(); + if (questionId != null && !isTransientId(questionId)) { + return `/auto/dashboard/question/${questionId}${cellQuery}`; + } else { + const adHocQuery = Card_DEPRECATED.utf8_to_b64url( + JSON.stringify(this.card().dataset_query), + ); + return `/auto/dashboard/adhoc/${adHocQuery}${cellQuery}`; + } + } + setResultsMetadata(resultsMetadata) { let metadataColumns = resultsMetadata && resultsMetadata.columns; let metadataChecksum = resultsMetadata && resultsMetadata.checksum; diff --git a/frontend/src/metabase-lib/lib/metadata/Field.js b/frontend/src/metabase-lib/lib/metadata/Field.js index 752fe9905dbd94a296a61d20cfb5cead0089fb47..e89b438d96ec6246ea028607b2748903b80bd634 100644 --- a/frontend/src/metabase-lib/lib/metadata/Field.js +++ b/frontend/src/metabase-lib/lib/metadata/Field.js @@ -27,8 +27,6 @@ import { import type { FieldValues } from "metabase/meta/types/Field"; -import _ from "underscore"; - /** * Wrapper class for field metadata objects. Belongs to a Table. */ @@ -37,6 +35,7 @@ export default class Field extends Base { description: string; table: Table; + name_field: ?Field; fieldType() { return getFieldType(this); @@ -147,11 +146,8 @@ export default class Field extends Base { } // this enables "implicit" remappings from type/PK to type/Name on the same table, // used in FieldValuesWidget, but not table/object detail listings - if (this.isPK()) { - const nameField = _.find(this.table.fields, f => f.isEntityName()); - if (nameField) { - return nameField; - } + if (this.name_field) { + return this.name_field; } return null; } diff --git a/frontend/src/metabase-lib/lib/metadata/Metadata.js b/frontend/src/metabase-lib/lib/metadata/Metadata.js index 2a2eb8a208ac33c6dbae7782678ac4055234cae5..f268e06cad2cf6b5ea4775cde8df0439a30246c0 100644 --- a/frontend/src/metabase-lib/lib/metadata/Metadata.js +++ b/frontend/src/metabase-lib/lib/metadata/Metadata.js @@ -44,6 +44,14 @@ export default class Metadata extends Base { return (Object.values(this.segments): Segment[]); } + segment(segmentId): ?Segment { + return (segmentId != null && this.segments[segmentId]) || null; + } + + metric(metricId): ?Metric { + return (metricId != null && this.metrics[metricId]) || null; + } + database(databaseId): ?Database { return (databaseId != null && this.databases[databaseId]) || null; } diff --git a/frontend/src/metabase-lib/lib/queries/StructuredQuery.js b/frontend/src/metabase-lib/lib/queries/StructuredQuery.js index 0ab5d5a636cdd8c3afdc4180f68b489316f659f3..5ce7821eab3bf3a51df6cbfe99bdd8b66874adf5 100644 --- a/frontend/src/metabase-lib/lib/queries/StructuredQuery.js +++ b/frontend/src/metabase-lib/lib/queries/StructuredQuery.js @@ -49,6 +49,8 @@ import AggregationWrapper from "./Aggregation"; import AggregationOption from "metabase-lib/lib/metadata/AggregationOption"; import Utils from "metabase/lib/utils"; +import { isSegmentFilter } from "metabase/lib/query/filter"; + export const STRUCTURED_QUERY_TEMPLATE = { database: null, type: "query", @@ -173,6 +175,16 @@ export default class StructuredQuery extends AtomicQuery { return this._structuredDatasetQuery.query; } + setQuery(query: StructuredQueryObject): StructuredQuery { + return this._updateQuery(() => query, []); + } + + updateQuery( + fn: (q: StructuredQueryObject) => StructuredQueryObject, + ): StructuredQuery { + return this._updateQuery(fn, []); + } + /** * @returns a new query with the provided Database set. */ @@ -478,6 +490,20 @@ export default class StructuredQuery extends AtomicQuery { return this.table().segments.filter(sgmt => sgmt.is_active === true); } + /** + * @returns @type {Segment}s that are currently applied to the question + */ + segments() { + return this.filters() + .filter(f => isSegmentFilter(f)) + .map(segmentFilter => { + // segment id is stored as the second part of the filter clause + // e.x. ["SEGMENT", 1] + const segmentId = segmentFilter[1]; + return this.metadata().segment(segmentId); + }); + } + /** * @returns whether a new filter can be added or not */ diff --git a/frontend/src/metabase/App.jsx b/frontend/src/metabase/App.jsx index 0fcd8adccb0bdac56e42de1582dd15357dd7680a..c5e2211d4f6d0f65d5d03847f04fb1d467b955ad 100644 --- a/frontend/src/metabase/App.jsx +++ b/frontend/src/metabase/App.jsx @@ -10,6 +10,7 @@ import UndoListing from "metabase/containers/UndoListing"; import NotFound from "metabase/components/NotFound.jsx"; import Unauthorized from "metabase/components/Unauthorized.jsx"; import Archived from "metabase/components/Archived.jsx"; +import GenericError from "metabase/components/GenericError.jsx"; const mapStateToProps = (state, props) => ({ errorPage: state.app.errorPage, @@ -18,6 +19,8 @@ const mapStateToProps = (state, props) => ({ const getErrorComponent = ({ status, data, context }) => { if (status === 403) { return <Unauthorized />; + } else if (status === 404) { + return <NotFound />; } else if ( data && data.error_code === "archived" && @@ -31,15 +34,28 @@ const getErrorComponent = ({ status, data, context }) => { ) { return <Archived entityName="question" linkTo="/questions/archive" />; } else { - return <NotFound />; + return <GenericError details={data && data.message} />; } }; @connect(mapStateToProps) export default class App extends Component { + state = { + hasError: false, + }; + + componentDidCatch(error, info) { + console.error("Error caught in <App>", error, info); + this.setState({ hasError: true }); + } + render() { const { children, location, errorPage } = this.props; + if (this.state.hasError) { + return <div>😢</div>; + } + return ( <div className="spread flex flex-column"> <Navbar location={location} className="flex-no-shrink" /> diff --git a/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx b/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx index f6eecbacf615870c7721b6f5427aa24a4d0c4700..8f5c4e6c7c04ca62455224e25ac94fd4746d3183 100644 --- a/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx +++ b/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx @@ -1,17 +1,17 @@ +/* @flow */ import React, { Component } from "react"; -import PropTypes from "prop-types"; import { Link } from "react-router"; -import { t, jt } from "c-3po"; -import ModalContent from "metabase/components/ModalContent.jsx"; +import { t } from "c-3po"; -import * as Urls from "metabase/lib/urls"; +import ModalContent from "metabase/components/ModalContent.jsx"; +type Props = { + databaseId: number, + onClose: () => void, + onDone: () => void, +}; export default class CreatedDatabaseModal extends Component { - static propTypes = { - databaseId: PropTypes.number.isRequired, - onClose: PropTypes.func.isRequired, - onDone: PropTypes.func.isRequired, - }; + props: Props; render() { const { onClose, onDone, databaseId } = this.props; @@ -19,26 +19,18 @@ export default class CreatedDatabaseModal extends Component { <ModalContent title={t`Your database has been added!`} onClose={onClose}> <div className="Form-inputs mb4"> <p> - {jt`We're analyzing its schema now to make some educated guesses about its - metadata. ${( - <Link to={`/admin/datamodel/database/${databaseId}`}> - View this database - </Link> - )} in the Data Model section to see what we've found and to - make edits, or ${( - <Link to={Urls.question(null, `?db=${databaseId}`)}> - ask a question - </Link> - )} about - this database.`} + {t`We took a look at your data, and we have some automated explorations that we can show you!`} </p> </div> <div className="Form-actions flex layout-centered"> - <button - className="Button Button--primary px3" - onClick={onDone} - >{t`Done`}</button> + <a className="link" onClick={onDone}>{t`I'm good thanks`}</a> + <Link + to={`/explore/${databaseId}`} + className="Button Button--primary ml-auto" + > + {t`Explore this data`} + </Link> </div> </ModalContent> ); diff --git a/frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx b/frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx index 810243a9adaa8d397ae978180cec2826b5e3b3e6..5411d963137fdb1a66dc0f321e75220efb4c21d9 100644 --- a/frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx +++ b/frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx @@ -33,9 +33,9 @@ export default class DeleteDatabaseModal extends Component { render() { const { database } = this.props; - var formError; + let formError; if (this.state.error) { - var errorMessage = t`Server error encountered`; + let errorMessage = t`Server error encountered`; if (this.state.error.data && this.state.error.data.message) { errorMessage = this.state.error.data.message; } else { diff --git a/frontend/src/metabase/admin/datamodel/components/database/MetadataHeader.jsx b/frontend/src/metabase/admin/datamodel/components/database/MetadataHeader.jsx index 01fe92caa4c01f0980b4db39d07bc8a99afe85ca..a79d1b05dc285f47a86115aa16d28ecd06b85e7a 100644 --- a/frontend/src/metabase/admin/datamodel/components/database/MetadataHeader.jsx +++ b/frontend/src/metabase/admin/datamodel/components/database/MetadataHeader.jsx @@ -31,11 +31,11 @@ export default class MetadataHeader extends Component { } renderDbSelector() { - var database = this.props.databases.filter( + let database = this.props.databases.filter( db => db.id === this.props.databaseId, )[0]; if (database) { - var columns = [ + let columns = [ { selectedItem: database, items: this.props.databases, @@ -46,7 +46,7 @@ export default class MetadataHeader extends Component { }, }, ]; - var triggerElement = ( + let triggerElement = ( <span className="text-bold cursor-pointer text-default"> {database.name} <Icon className="ml1" name="chevrondown" size={8} /> diff --git a/frontend/src/metabase/admin/datamodel/components/database/MetadataSchema.jsx b/frontend/src/metabase/admin/datamodel/components/database/MetadataSchema.jsx index 678edabf687630f1b3e44020bde4cda6d2b61fbd..66faef1acd8b6c9acf9f4e4e93e206eb8423f807 100644 --- a/frontend/src/metabase/admin/datamodel/components/database/MetadataSchema.jsx +++ b/frontend/src/metabase/admin/datamodel/components/database/MetadataSchema.jsx @@ -13,7 +13,7 @@ export default class MetadataSchema extends Component { return false; } - var fields = tableMetadata.fields.map(field => { + let fields = tableMetadata.fields.map(field => { return ( <li key={field.id} className="px1 py2 flex border-bottom"> <div className="flex-full flex flex-column mr1"> diff --git a/frontend/src/metabase/admin/datamodel/components/database/MetadataTable.jsx b/frontend/src/metabase/admin/datamodel/components/database/MetadataTable.jsx index 071b7c245bc6ea34d97d457119e642db69a024eb..4419983652715d157d4b9406bb2a6e3af633f852 100644 --- a/frontend/src/metabase/admin/datamodel/components/database/MetadataTable.jsx +++ b/frontend/src/metabase/admin/datamodel/components/database/MetadataTable.jsx @@ -8,6 +8,8 @@ import { t } from "c-3po"; import Input from "metabase/components/Input.jsx"; import ProgressBar from "metabase/components/ProgressBar.jsx"; +import { normal } from "metabase/lib/colors"; + import _ from "underscore"; import cx from "classnames"; @@ -50,7 +52,7 @@ export default class MetadataTable extends Component { } renderVisibilityType(text, type, any) { - var classes = cx( + let classes = cx( "mx1", "text-bold", "text-brand-hover", @@ -73,7 +75,7 @@ export default class MetadataTable extends Component { } renderVisibilityWidget() { - var subTypes; + let subTypes; if (this.props.tableMetadata.visibility_type) { subTypes = ( <span id="VisibilitySubTypes" className="border-left mx2"> @@ -120,7 +122,12 @@ export default class MetadataTable extends Component { {this.renderVisibilityWidget()} <span className="flex-align-right flex align-center"> <span className="text-uppercase mr1">{t`Metadata Strength`}</span> - <ProgressBar percentage={tableMetadata.metadataStrength} /> + <span style={{ width: 64 }}> + <ProgressBar + percentage={tableMetadata.metadataStrength} + color={normal.grey2} + /> + </span> </span> </div> <div className={"mt2 " + (this.isHidden() ? "disabled" : "")}> diff --git a/frontend/src/metabase/admin/datamodel/components/database/MetadataTableList.jsx b/frontend/src/metabase/admin/datamodel/components/database/MetadataTableList.jsx index ae85f8470de77c1e2a7fe8bb42d5a8455b1f721e..d72811510cec253ac7d86a59bbe15676af1b0aae 100644 --- a/frontend/src/metabase/admin/datamodel/components/database/MetadataTableList.jsx +++ b/frontend/src/metabase/admin/datamodel/components/database/MetadataTableList.jsx @@ -5,6 +5,7 @@ import ProgressBar from "metabase/components/ProgressBar.jsx"; import Icon from "metabase/components/Icon.jsx"; import { t } from "c-3po"; import { inflect } from "metabase/lib/formatting"; +import { normal } from "metabase/lib/colors"; import _ from "underscore"; import cx from "classnames"; @@ -37,30 +38,33 @@ export default class MetadataTableList extends Component { } render() { - var queryableTablesHeader, hiddenTablesHeader; - var queryableTables = []; - var hiddenTables = []; + let queryableTablesHeader, hiddenTablesHeader; + let queryableTables = []; + let hiddenTables = []; if (this.props.tables) { - var tables = _.sortBy(this.props.tables, "display_name"); + let tables = _.sortBy(this.props.tables, "display_name"); _.each(tables, table => { - var row = ( + const selected = this.props.tableId === table.id; + let row = ( <li key={table.id}> <a className={cx("AdminList-item flex align-center no-decoration", { - selected: this.props.tableId === table.id, + selected, })} onClick={this.props.selectTable.bind(null, table)} > {table.display_name} - <ProgressBar - className="ProgressBar ProgressBar--mini flex-align-right" - percentage={table.metadataStrength} - /> + <span className="flex-align-right" style={{ width: 17 }}> + <ProgressBar + percentage={table.metadataStrength} + color={selected ? normal.grey2 : normal.grey1} + /> + </span> </a> </li> ); - var regex = this.state.searchRegex; + let regex = this.state.searchRegex; if ( !regex || regex.test(table.display_name) || @@ -78,7 +82,7 @@ export default class MetadataTableList extends Component { if (queryableTables.length > 0) { queryableTablesHeader = ( <li className="AdminList-section"> - {queryableTables.length} Queryable{" "} + {queryableTables.length} {t`Queryable`}{" "} {inflect("Table", queryableTables.length)} </li> ); @@ -86,7 +90,8 @@ export default class MetadataTableList extends Component { if (hiddenTables.length > 0) { hiddenTablesHeader = ( <li className="AdminList-section"> - {hiddenTables.length} Hidden {inflect("Table", hiddenTables.length)} + {hiddenTables.length} {t`Hidden`}{" "} + {inflect("Table", hiddenTables.length)} </li> ); } diff --git a/frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx b/frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx index 611de99bc2643d160eac1083849e48b3272fd69b..be7fd863718bd5c12440d1258244a6eefde12323 100644 --- a/frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx @@ -39,9 +39,9 @@ import { DatetimeFieldDimension } from "metabase-lib/lib/Dimension"; import { rescanFieldValues, discardFieldValues } from "../field"; const HAS_FIELD_VALUES_OPTIONS = [ - { name: "Search box", value: "search" }, - { name: "A list of all values", value: "list" }, - { name: "Plain input box", value: "none" }, + { name: t`Search box`, value: "search" }, + { name: t`A list of all values`, value: "list" }, + { name: t`Plain input box`, value: "none" }, ]; const SelectClasses = @@ -743,7 +743,7 @@ export class FieldRemapping extends Component { /> </PopoverWithTrigger>, dismissedInitialFkTargetPopover && ( - <div className="text-danger my2">{t`Please select a column to use for display.`}</div> + <div className="text-error my2">{t`Please select a column to use for display.`}</div> ), hasChanged && hasFKMappingValue && <RemappingNamingTip />, ]} diff --git a/frontend/src/metabase/admin/datamodel/containers/MetadataEditorApp.jsx b/frontend/src/metabase/admin/datamodel/containers/MetadataEditorApp.jsx index d3bbf25846e088142dc3ae04053b4c890d846ccf..8212cd7bdabe5ca7a8652f90be824fbceac90729 100644 --- a/frontend/src/metabase/admin/datamodel/containers/MetadataEditorApp.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/MetadataEditorApp.jsx @@ -77,12 +77,12 @@ export default class MetadataEditor extends Component { } render() { - var tableMetadata = this.props.databaseMetadata + let tableMetadata = this.props.databaseMetadata ? _.findWhere(this.props.databaseMetadata.tables, { id: this.props.editingTable, }) : null; - var content; + let content; if (tableMetadata) { if (this.state.isShowingSchema) { content = <MetadataSchema tableMetadata={tableMetadata} />; diff --git a/frontend/src/metabase/admin/people/components/GroupDetail.jsx b/frontend/src/metabase/admin/people/components/GroupDetail.jsx index af59810d6a39886b1b56079f1a7682f569ae592d..2d8d5d647edc146638fa5cc536ac4569e97e5949 100644 --- a/frontend/src/metabase/admin/people/components/GroupDetail.jsx +++ b/frontend/src/metabase/admin/people/components/GroupDetail.jsx @@ -28,7 +28,9 @@ const GroupDescription = ({ group }) => isDefaultGroup(group) ? ( <div className="px2 text-measure"> <p> - {t`All users belong to the {group.name} group and can't be removed from it. Setting permissions for this group is a great way to + {t`All users belong to the ${ + group.name + } group and can't be removed from it. Setting permissions for this group is a great way to make sure you know what new Metabase users will be able to see.`} </p> </div> diff --git a/frontend/src/metabase/admin/people/components/GroupSummary.jsx b/frontend/src/metabase/admin/people/components/GroupSummary.jsx index 1665ac6c6b92646574bc81cdb67ba18bc96a6e0c..f23ddfe946633b896a4d441d9b482e9758598eab 100644 --- a/frontend/src/metabase/admin/people/components/GroupSummary.jsx +++ b/frontend/src/metabase/admin/people/components/GroupSummary.jsx @@ -18,7 +18,9 @@ const GroupSummary = ({ groups, selectedGroups }) => { {otherGroups.length > 0 && ( <span className="text-brand"> {otherGroups.length + - " other " + + " " + + t`other` + + " " + inflect("group", otherGroups.length)} </span> )} diff --git a/frontend/src/metabase/admin/people/components/GroupsListing.jsx b/frontend/src/metabase/admin/people/components/GroupsListing.jsx index 785bda2aba2d9a248237c608844a307794e21320..657b199b7ba308d32f3fd65615f84ba923e56c84 100644 --- a/frontend/src/metabase/admin/people/components/GroupsListing.jsx +++ b/frontend/src/metabase/admin/people/components/GroupsListing.jsx @@ -54,7 +54,7 @@ function DeleteGroupModal({ group, onConfirm = () => {}, onClose = () => {} }) { return ( <ModalContent title={t`Remove this group?`} onClose={onClose}> <p className="px4 pb4"> - {t`Are you sure? All members of this group will lose any permissions settings the have based on this group. + {t`Are you sure? All members of this group will lose any permissions settings they have based on this group. This can't be undone.`} </p> <div className="Form-actions"> diff --git a/frontend/src/metabase/admin/people/containers/AdminPeopleApp.jsx b/frontend/src/metabase/admin/people/containers/AdminPeopleApp.jsx index 31cd184a93cc6ea73d466eecb0fb74bf3fc10270..57507ab1f4ee8bc5a655ccefd8143e7c3de9bbef 100644 --- a/frontend/src/metabase/admin/people/containers/AdminPeopleApp.jsx +++ b/frontend/src/metabase/admin/people/containers/AdminPeopleApp.jsx @@ -1,6 +1,7 @@ /* eslint "react/prop-types": "warn" */ import React, { Component } from "react"; import PropTypes from "prop-types"; +import { t } from "c-3po"; import { LeftNavPane, @@ -20,8 +21,8 @@ export default class AdminPeopleApp extends Component { <AdminLayout sidebar={ <LeftNavPane> - <LeftNavPaneItem name="People" path="/admin/people" index /> - <LeftNavPaneItem name="Groups" path="/admin/people/groups" /> + <LeftNavPaneItem name={t`People`} path="/admin/people" index /> + <LeftNavPaneItem name={t`Groups`} path="/admin/people/groups" /> </LeftNavPane> } > diff --git a/frontend/src/metabase/admin/people/people.js b/frontend/src/metabase/admin/people/people.js index e8b31f0580ba6941910a8437cfe4104f620ad5ad..75b119790424680bcb7ad4c30b838e1026bb6044 100644 --- a/frontend/src/metabase/admin/people/people.js +++ b/frontend/src/metabase/admin/people/people.js @@ -125,7 +125,7 @@ export const fetchUsers = createThunkAction(FETCH_USERS, function() { return async function(dispatch, getState) { let users = await UserApi.list(); - for (var u of users) { + for (let u of users) { u.date_joined = u.date_joined ? moment(u.date_joined) : null; u.last_login = u.last_login ? moment(u.last_login) : null; } diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsConfirm.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsConfirm.jsx index 65e37b6ef3f276134c1618fd07f1b72edaa1eb4f..8243e0f1c1b83c3e01fc5673778b40817e5442d1 100644 --- a/frontend/src/metabase/admin/permissions/components/PermissionsConfirm.jsx +++ b/frontend/src/metabase/admin/permissions/components/PermissionsConfirm.jsx @@ -51,11 +51,11 @@ const PermissionsConfirm = ({ diff }) => ( tables={database.grantedTables} /> )} - {database.grantedTables && database.revokedTables && t` and `}} + {database.grantedTables && database.revokedTables && t` and `} {database.revokedTables && ( <TableAccessChange verb={t`denied access to`} - color="text-warning" + color="text-error" tables={database.revokedTables} /> )} @@ -68,7 +68,7 @@ const PermissionsConfirm = ({ diff }) => ( <div> <GroupName group={group} /> {database.native === "none" - ? t` will no longer able to ` + ? t` will no longer be able to ` : t` will now be able to `} {database.native === "read" ? ( <span className="text-gold">read</span> diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx index 2f22653f941f4ab01973dfaa9b4167329aaf0cea..f0b4b12a737f1399aebc2351370634c7ca2f1b24 100644 --- a/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx +++ b/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx @@ -224,7 +224,7 @@ class GroupPermissionCell extends Component { {warning && ( <div className="absolute top right p1"> <Tooltip tooltip={warning} maxWidth="24em"> - <Icon name="warning2" className="text-slate" /> + <Icon name="warning" className="text-slate" /> </Tooltip> </div> )} diff --git a/frontend/src/metabase/admin/permissions/selectors.js b/frontend/src/metabase/admin/permissions/selectors.js index b4cf5a762c278ace0b1215ce5201dd92e5d0950d..347ae36892f91b0b284cdce1029ebb0464b198cf 100644 --- a/frontend/src/metabase/admin/permissions/selectors.js +++ b/frontend/src/metabase/admin/permissions/selectors.js @@ -142,9 +142,10 @@ function getPermissionWarningModal( ); if (permissionWarning) { return { - title: t`${ - value === "controlled" ? "Limit" : "Revoke" - } access even though "${defaultGroup.name}" has greater access?`, + title: + (value === "controlled" ? t`Limit` : t`Revoke`) + + " " + + t`access even though "${defaultGroup.name}" has greater access?`, message: permissionWarning, confirmButtonText: value === "controlled" ? t`Limit access` : t`Revoke access`, diff --git a/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx b/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx index aa9bfb9c715ce807351c1dc4755e6323192a165a..f5a54c35abf8c6b739d2216b7f25c62a7977e27b 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx @@ -7,25 +7,24 @@ import MetabaseAnalytics from "metabase/lib/analytics"; import MetabaseUtils from "metabase/lib/utils"; import SettingsSetting from "./SettingsSetting.jsx"; +import Button from "metabase/components/Button"; + export default class SettingsEmailForm extends Component { - constructor(props, context) { - super(props, context); - - this.state = { - dirty: false, - formData: {}, - sendingEmail: "default", - submitting: "default", - valid: false, - validationErrors: {}, - }; - } + state = { + dirty: false, + formData: {}, + sendingEmail: "default", + submitting: "default", + valid: false, + validationErrors: {}, + }; static propTypes = { elements: PropTypes.array.isRequired, formErrors: PropTypes.object, sendTestEmail: PropTypes.func.isRequired, updateEmailSettings: PropTypes.func.isRequired, + clearEmailSettings: PropTypes.func.isRequired, }; componentWillMount() { @@ -166,6 +165,12 @@ export default class SettingsEmailForm extends Component { ); } + clear = () => { + this.props.clearEmailSettings().then(() => { + this.setState({ formData: {} }); + }); + }; + updateEmailSettings(e) { e.preventDefault(); @@ -252,40 +257,42 @@ export default class SettingsEmailForm extends Component { saveButtonText = saveSettingsButtonStates[submitting]; return ( - <form noValidate> - <ul> - {settings} - <li className="m2 mb4"> - <button - className={cx( - "Button mr2", - { "Button--primary": !disabled }, - { "Button--success-new": submitting === "success" }, - )} + <ul> + {settings} + <li className="m2 mb4"> + <Button + primary={!disabled} + className={cx({ "Button--success-new": submitting === "success" })} + disabled={disabled} + onClick={this.updateEmailSettings.bind(this)} + > + {saveButtonText} + </Button> + {valid && !dirty && submitting === "default" ? ( + <Button + className={cx("ml1", { + "Button--success-new": sendingEmail === "success", + })} disabled={disabled} - onClick={this.updateEmailSettings.bind(this)} + onClick={this.sendTestEmail.bind(this)} > - {saveButtonText} - </button> - {valid && !dirty && submitting === "default" ? ( - <button - className={cx("Button", { - "Button--success-new": sendingEmail === "success", - })} - disabled={disabled} - onClick={this.sendTestEmail.bind(this)} - > - {emailButtonText} - </button> - ) : null} - {formErrors && formErrors.message ? ( - <span className="pl2 text-error text-bold"> - {formErrors.message} - </span> - ) : null} - </li> - </ul> - </form> + {emailButtonText} + </Button> + ) : null} + <Button + className="ml1" + onClick={() => this.clear()} + disabled={disabled} + > + {t`Clear`} + </Button> + {formErrors && formErrors.message ? ( + <span className="pl2 text-error text-bold"> + {formErrors.message} + </span> + ) : null} + </li> + </ul> ); } } diff --git a/frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx b/frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx index 9fec2c97f58b64b13d9076af9b9144dd378bc6b3..7ea9849659e2726891cfd69a05f2be93a899b27b 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx @@ -1,4 +1,5 @@ -import React, { Component, PropTypes } from "react"; +import React, { Component } from "react"; +import PropTypes from "prop-types"; import _ from "underscore"; import cx from "classnames"; diff --git a/frontend/src/metabase/admin/settings/components/SettingsXrayForm.jsx b/frontend/src/metabase/admin/settings/components/SettingsXrayForm.jsx index f426b8de0052ec9160ea87254861a8251230593b..d1cc135aa9c04a0220ece047f73f99eda5df13de 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsXrayForm.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsXrayForm.jsx @@ -43,7 +43,7 @@ const SettingsXrayForm = ({ settings, elements, updateSetting }) => { </p> <p className="text-paragraph"> <em>{jt`${( - <strong>Note:</strong> + <strong>{t`Note`}:</strong> )} "Extended" is required for viewing time series x-rays.`}</em> </p> diff --git a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingLegalese.jsx b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingLegalese.jsx index 4d04483001d724d56060ab50366be5e0d5640493..6bd1b1d2a2a9eac9c8d8cb5fd74ebbc53201520b 100644 --- a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingLegalese.jsx +++ b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingLegalese.jsx @@ -16,7 +16,7 @@ const EmbeddingLegalese = ({ onChange }) => ( </a>. </p> <p className="text-grey-4" style={{ lineHeight: 1.48 }}> - {t`In plain english, when you embed charts or dashboards from Metabase in your own application, that application isn't subject to the Affero General Public License that covers the rest of Metabase, provided you keep the Metabase logo and the "Powered by Metabase" visible on those embeds. You should however, read the license text linked above as that is the actual license that you will be agreeing to by enabling this feature.`} + {t`In plain English, when you embed charts or dashboards from Metabase in your own application, that application isn't subject to the Affero General Public License that covers the rest of Metabase, provided you keep the Metabase logo and the "Powered by Metabase" visible on those embeds. You should, however, read the license text linked above as that is the actual license that you will be agreeing to by enabling this feature.`} </p> <div className="flex layout-centered mt4"> <button diff --git a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingLevel.jsx b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingLevel.jsx index d69767a01267531f8dba1179902f61e665e3e268..b7fe24ea5f5cbe4a16b72f26a34dab34bc3f26ff 100644 --- a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingLevel.jsx +++ b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingLevel.jsx @@ -2,30 +2,55 @@ import React, { Component } from "react"; import ReactRetinaImage from "react-retina-image"; import { t } from "c-3po"; import SettingsInput from "./SettingInput"; +import cx from "classnames"; const PREMIUM_EMBEDDING_STORE_URL = "https://store.metabase.com/product/embedding"; const PREMIUM_EMBEDDING_SETTING_KEY = "premium-embedding-token"; -const PremiumTokenInput = ({ token, onChangeSetting }) => ( - <div className="mb3"> - <h3 className="mb1"> - {token - ? t`Premium embedding enabled` - : t`Enter the token you bought from the Metabase Store`} - </h3> - <SettingsInput - onChange={value => onChangeSetting(PREMIUM_EMBEDDING_SETTING_KEY, value)} - setting={{ value: token }} - autoFocus={!token} - /> - </div> -); +class PremiumTokenInput extends Component { + state = { + errorMessage: "", + }; + render() { + const { token, onChangeSetting } = this.props; + const { errorMessage } = this.state; + + let message; + + if (errorMessage) { + message = errorMessage; + } else if (token) { + message = t`Premium embedding enabled`; + } else { + message = t`Enter the token you bought from the Metabase Store`; + } + + return ( + <div className="mb3"> + <h3 className={cx("mb1", { "text-danger": errorMessage })}> + {message} + </h3> + <SettingsInput + onChange={async value => { + try { + await onChangeSetting(PREMIUM_EMBEDDING_SETTING_KEY, value); + } catch (error) { + this.setState({ errorMessage: error.data }); + } + }} + setting={{ value: token }} + autoFocus={!token} + /> + </div> + ); + } +} const PremiumExplanation = ({ showEnterScreen }) => ( <div> <h2>Premium embedding</h2> - <p className="mt1">{t`Premium embedding lets you disable "Powered by Metabase" on your embeded dashboards and questions.`}</p> + <p className="mt1">{t`Premium embedding lets you disable "Powered by Metabase" on your embedded dashboards and questions.`}</p> <div className="mt2 mb3"> <a className="link mx1" diff --git a/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx b/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx index 7378fa0f291e1cda6b748bd68e78799e155d498d..87eecc50b87a56ec360f3c50c1b18d26241adf83 100644 --- a/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx +++ b/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx @@ -119,7 +119,7 @@ export default class PublicLinksListing extends Component { <td className="flex layout-centered"> <Confirm title={t`Disable this link?`} - content={t`They won't work any more, and can't be restored, but you can create new links.`} + content={t`They won't work anymore, and can't be restored, but you can create new links.`} action={() => { this.revoke(link); this.trackEvent("Revoked link"); @@ -159,7 +159,7 @@ export const PublicLinksQuestionListing = () => ( revoke={CardApi.deletePublicLink} type={t`Public Card Listing`} getUrl={({ id }) => Urls.question(id)} - getPublicUrl={({ public_uuid }) => Urls.publicCard(public_uuid)} + getPublicUrl={({ public_uuid }) => Urls.publicQuestion(public_uuid)} noLinksMessage={t`No questions have been publicly shared yet.`} /> ); diff --git a/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx b/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx index 2daf7d58dabce599a731a18598c52a2056b36c99..fa3ed26c964f97c3b3a15ed4f0f40804ecf24485 100644 --- a/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx +++ b/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx @@ -59,6 +59,7 @@ export default class SettingsEditorApp extends Component { updateSlackSettings: PropTypes.func.isRequired, updateLdapSettings: PropTypes.func.isRequired, sendTestEmail: PropTypes.func.isRequired, + clearEmailSettings: PropTypes.func.isRequired, }; componentWillMount() { @@ -132,6 +133,7 @@ export default class SettingsEditorApp extends Component { elements={activeSection.settings} updateEmailSettings={this.props.updateEmailSettings} sendTestEmail={this.props.sendTestEmail} + clearEmailSettings={this.props.clearEmailSettings} /> ); } else if (activeSection.name === "Setup") { diff --git a/frontend/src/metabase/admin/settings/selectors.js b/frontend/src/metabase/admin/settings/selectors.js index b9a6f0be5e94578342be8281b5f0e1cd5ec77b3b..321a72ff8f1ca428100bd4bcfffaac0ea2fbd926 100644 --- a/frontend/src/metabase/admin/settings/selectors.js +++ b/frontend/src/metabase/admin/settings/selectors.js @@ -2,7 +2,6 @@ import _ from "underscore"; import { createSelector } from "reselect"; import MetabaseSettings from "metabase/lib/settings"; import { t } from "c-3po"; -import { slugify } from "metabase/lib/formatting"; import CustomGeoJSONWidget from "./components/widgets/CustomGeoJSONWidget.jsx"; import { PublicLinksDashboardListing, @@ -17,13 +16,16 @@ import LdapGroupMappingsWidget from "./components/widgets/LdapGroupMappingsWidge import { UtilApi } from "metabase/services"; +/* Note - do not translate slugs */ const SECTIONS = [ { name: t`Setup`, + slug: "setup", settings: [], }, { name: t`General`, + slug: "general", settings: [ { key: "site-name", @@ -92,6 +94,7 @@ const SECTIONS = [ }, { name: t`Updates`, + slug: "updates", settings: [ { key: "check-for-updates", @@ -102,6 +105,7 @@ const SECTIONS = [ }, { name: t`Email`, + slug: "email", settings: [ { key: "email-smtp-host", @@ -153,6 +157,7 @@ const SECTIONS = [ }, { name: "Slack", + slug: "slack", settings: [ { key: "slack-token", @@ -176,6 +181,7 @@ const SECTIONS = [ }, { name: t`Single Sign-On`, + slug: "single_sign_on", sidebar: false, settings: [ { @@ -188,10 +194,12 @@ const SECTIONS = [ }, { name: t`Authentication`, + slug: "authentication", settings: [], }, { name: t`LDAP`, + slug: "ldap", sidebar: false, settings: [ { @@ -268,7 +276,7 @@ const SECTIONS = [ }, { key: "ldap-group-base", - display_name: t`"Group search base`, + display_name: t`Group search base`, type: "string", }, { @@ -278,6 +286,7 @@ const SECTIONS = [ }, { name: t`Maps`, + slug: "maps", settings: [ { key: "map-tile-server-url", @@ -296,6 +305,7 @@ const SECTIONS = [ }, { name: t`Public Sharing`, + slug: "public_sharing", settings: [ { key: "enable-public-sharing", @@ -318,6 +328,7 @@ const SECTIONS = [ }, { name: t`Embedding in other Applications`, + slug: "embedding_in_other_applications", settings: [ { key: "enable-embedding", @@ -373,6 +384,7 @@ const SECTIONS = [ }, { name: t`Caching`, + slug: "caching", settings: [ { key: "enable-query-caching", @@ -404,6 +416,7 @@ const SECTIONS = [ }, { name: t`X-Rays`, + slug: "x_rays", settings: [ { key: "enable-xrays", @@ -431,9 +444,6 @@ const SECTIONS = [ } */ ]; -for (const section of SECTIONS) { - section.slug = slugify(section.name); -} export const getSettings = createSelector( state => state.settings.settings, diff --git a/frontend/src/metabase/admin/settings/settings.js b/frontend/src/metabase/admin/settings/settings.js index cc821ae6838bbc865a5e0a25cd6fc50157cc0bdf..1077276fcb31d73c2289c7421f77c8a2fe2061de 100644 --- a/frontend/src/metabase/admin/settings/settings.js +++ b/frontend/src/metabase/admin/settings/settings.js @@ -1,4 +1,5 @@ import { + createAction, createThunkAction, handleActions, combineReducers, @@ -71,6 +72,13 @@ export const sendTestEmail = createThunkAction(SEND_TEST_EMAIL, function() { }; }); +export const CLEAR_EMAIL_SETTINGS = + "metabase/admin/settings/CLEAR_EMAIL_SETTINGS"; + +export const clearEmailSettings = createAction(CLEAR_EMAIL_SETTINGS, () => + EmailApi.clear(), +); + export const UPDATE_SLACK_SETTINGS = "metabase/admin/settings/UPDATE_SLACK_SETTINGS"; export const updateSlackSettings = createThunkAction( diff --git a/frontend/src/metabase/auth/containers/LoginApp.jsx b/frontend/src/metabase/auth/containers/LoginApp.jsx index df109650d7b0e98afd4c37b2e6ef0f6510dc7f66..3133d35bc293fba4b5159faa8032949fb1795c4f 100644 --- a/frontend/src/metabase/auth/containers/LoginApp.jsx +++ b/frontend/src/metabase/auth/containers/LoginApp.jsx @@ -109,6 +109,7 @@ export default class LoginApp extends Component { render() { const { loginError } = this.props; + const ldapEnabled = Settings.ldapEnabled(); return ( <div className="full-height full bg-white flex flex-column flex-full md-layout-centered"> @@ -121,7 +122,6 @@ export default class LoginApp extends Component { className="Form-new bg-white bordered rounded shadowed" name="form" onSubmit={e => this.formSubmitted(e)} - noValidate > <h3 className="Login-header Form-offset">{t`Sign in to Metabase`}</h3> @@ -162,7 +162,15 @@ export default class LoginApp extends Component { className="Form-input Form-offset full py1" name="username" placeholder="youlooknicetoday@email.com" - type="text" + type={ + /* + * if a user has ldap enabled, use a text input to allow for + * ldap username && schemes. if not and they're using built + * in auth, set the input type to email so we get built in + * validation in modern browsers + * */ + ldapEnabled ? "text" : "email" + } onChange={e => this.onChange("username", e.target.value)} autoFocus /> @@ -203,7 +211,7 @@ export default class LoginApp extends Component { })} disabled={!this.state.valid} > - Sign in + {t`Sign in`} </button> <Link to={ diff --git a/frontend/src/metabase/auth/containers/PasswordResetApp.jsx b/frontend/src/metabase/auth/containers/PasswordResetApp.jsx index d6729eddb702478d688ffc238597632914321d05..f551e17961a4da007652fca7d09e6cef63944836 100644 --- a/frontend/src/metabase/auth/containers/PasswordResetApp.jsx +++ b/frontend/src/metabase/auth/containers/PasswordResetApp.jsx @@ -3,7 +3,7 @@ import { connect } from "react-redux"; import { Link } from "react-router"; import cx from "classnames"; -import { t } from "c-3po"; +import { t, jt } from "c-3po"; import AuthScene from "../components/AuthScene.jsx"; import FormField from "metabase/components/form/FormField.jsx"; import FormLabel from "metabase/components/form/FormLabel.jsx"; @@ -95,9 +95,15 @@ export default class PasswordResetApp extends Component { const { resetError, resetSuccess, newUserJoining } = this.props; const passwordComplexity = MetabaseSettings.passwordComplexity(false); + const requestLink = ( + <Link to="/auth/forgot_password" className="link"> + {t`request a new reset email`} + </Link> + ); + if (!this.state.tokenValid) { return ( - <div> + <div className="full-height"> <div className="full-height bg-white flex flex-column flex-full md-layout-centered"> <div className="wrapper"> <div className="Login-wrapper Grid Grid--full md-Grid--1of2"> @@ -111,8 +117,8 @@ export default class PasswordResetApp extends Component { <div className="Grid-cell bordered rounded shadowed"> <h3 className="Login-header Form-offset mt4">{t`Whoops, that's an expired link`}</h3> <p className="Form-offset mb4 mr4"> - {t`For security reasons, password reset links expire after a little while. If you still need - to reset your password, you can <Link to="/auth/forgot_password" className="link">request a new reset email</Link>.`} + {jt`For security reasons, password reset links expire after a little while. If you still need + to reset your password, you can ${requestLink}.`} </p> </div> </div> diff --git a/frontend/src/metabase/components/AccordianList.info.js b/frontend/src/metabase/components/AccordianList.info.js index 45c762688925003d3459bd3e55193b722a2daff8..6da03a28b6b5024a644210af6e05ebd8c1c0fd4d 100644 --- a/frontend/src/metabase/components/AccordianList.info.js +++ b/frontend/src/metabase/components/AccordianList.info.js @@ -1,16 +1,5 @@ import React from "react"; import AccordianList from "metabase/components/AccordianList"; -import PopoverWithTrigger from "metabase/components/PopoverWithTrigger"; - -const DemoPopover = ({ children }) => ( - <PopoverWithTrigger - triggerElement={<button className="Button">Click me!</button>} - verticalAttachments={["top"]} - isInitiallyOpen - > - {children} - </PopoverWithTrigger> -); export const component = AccordianList; @@ -34,42 +23,34 @@ const sections = [ export const examples = { Default: ( - <DemoPopover> - <AccordianList - className="text-brand" - sections={sections} - itemIsSelected={item => item.name === "Foo"} - /> - </DemoPopover> + <AccordianList + className="text-brand full" + sections={sections} + itemIsSelected={item => item.name === "Foo"} + /> ), "Always Expanded": ( - <DemoPopover> - <AccordianList - className="text-brand" - sections={sections} - itemIsSelected={item => item.name === "Foo"} - alwaysExpanded - /> - </DemoPopover> + <AccordianList + className="text-brand full" + sections={sections} + itemIsSelected={item => item.name === "Foo"} + alwaysExpanded + /> ), Searchable: ( - <DemoPopover> - <AccordianList - className="text-brand" - sections={sections} - itemIsSelected={item => item.name === "Foo"} - searchable - /> - </DemoPopover> + <AccordianList + className="text-brand full" + sections={sections} + itemIsSelected={item => item.name === "Foo"} + searchable + /> ), "Hide Single Section Title": ( - <DemoPopover> - <AccordianList - className="text-brand" - sections={sections.slice(0, 1)} - itemIsSelected={item => item.name === "Foo"} - hideSingleSectionTitle - /> - </DemoPopover> + <AccordianList + className="text-brand full" + sections={sections.slice(0, 1)} + itemIsSelected={item => item.name === "Foo"} + hideSingleSectionTitle + /> ), }; diff --git a/frontend/src/metabase/components/AccordianList.jsx b/frontend/src/metabase/components/AccordianList.jsx index ae9c3110aa572647ebeffb8a066bb7a0f04e1cb3..45f4b6cf41e036252bce82caa720c4495943bb17 100644 --- a/frontend/src/metabase/components/AccordianList.jsx +++ b/frontend/src/metabase/components/AccordianList.jsx @@ -205,7 +205,7 @@ export default class AccordianList extends Component { renderSectionIcon(section, sectionIndex) { if (this.props.renderSectionIcon) { return ( - <span className="List-section-icon mr2"> + <span className="List-section-icon mr1 flex align-center"> {this.props.renderSectionIcon(section, sectionIndex)} </span> ); diff --git a/frontend/src/metabase/components/ActionButton.jsx b/frontend/src/metabase/components/ActionButton.jsx index 639cadda539130b85f54db1c4a07f0f90b137692..95cdce0a31a6378ad71bff27b7d215516450e424 100644 --- a/frontend/src/metabase/components/ActionButton.jsx +++ b/frontend/src/metabase/components/ActionButton.jsx @@ -134,7 +134,7 @@ export default class ActionButton extends Component { ? cx("Button", "Button--waiting") : cx(className, { "Button--waiting pointer-events-none": active, - "Button--success": result === "success", + "Button--success pointer-events-none": result === "success", "Button--danger": result === "failed", }) } diff --git a/frontend/src/metabase/components/Archived.jsx b/frontend/src/metabase/components/Archived.jsx index d3ab55d5b0c99ac5ddbc7eadeed9ffe268576a57..dd31c4a2602b059167b0cb764e6da8fb0a6c946b 100644 --- a/frontend/src/metabase/components/Archived.jsx +++ b/frontend/src/metabase/components/Archived.jsx @@ -2,6 +2,9 @@ import React from "react"; import EmptyState from "metabase/components/EmptyState"; import Link from "metabase/components/Link"; import { t } from "c-3po"; + +// TODO: port to ErrorMessage for more consistent style + const Archived = ({ entityName, linkTo }) => ( <div className="full-height flex justify-center align-center"> <EmptyState diff --git a/frontend/src/metabase/components/Button.jsx b/frontend/src/metabase/components/Button.jsx index 9e2e57f4a9c999d00b812d5d5fe0c63f8c695c57..08a3ee671f854d7db817f7aeef5e27a0571e4b1a 100644 --- a/frontend/src/metabase/components/Button.jsx +++ b/frontend/src/metabase/components/Button.jsx @@ -20,7 +20,14 @@ const BUTTON_VARIANTS = [ "onlyIcon", ]; -const Button = ({ className, icon, iconSize, children, ...props }) => { +const Button = ({ + className, + icon, + iconRight, + iconSize, + children, + ...props +}) => { let variantClasses = BUTTON_VARIANTS.filter(variant => props[variant]).map( variant => "Button--" + variant, ); @@ -39,6 +46,13 @@ const Button = ({ className, icon, iconSize, children, ...props }) => { /> )} <div>{children}</div> + {iconRight && ( + <Icon + name={iconRight} + size={iconSize ? iconSize : 14} + className={cx({ ml1: !props.onlyIcon })} + /> + )} </div> </button> ); diff --git a/frontend/src/metabase/components/ButtonWithStatus.jsx b/frontend/src/metabase/components/ButtonWithStatus.jsx index 6b88199867f0c525b8ca71ff3577e180afbdb552..d74b6367584a0dab148c553d8ccb30b3f7a983b5 100644 --- a/frontend/src/metabase/components/ButtonWithStatus.jsx +++ b/frontend/src/metabase/components/ButtonWithStatus.jsx @@ -12,6 +12,8 @@ let defaultTitleForState = { // TODO Atte Keinänen 7/14/17: This could use Button component underneath and pass parameters to it // (Didn't want to generalize too much for the first version of this component +// TODO: Tom Robinson 4/16/2018: Is this the same functionality as ActionButton? + /** * Renders a button that triggers a promise-returning `onClickOperation` when user clicks the button. * When the button is clicked, `inProgress` text is shown, and when the promise resolves, `completed` text is shown. diff --git a/frontend/src/metabase/components/Calendar.jsx b/frontend/src/metabase/components/Calendar.jsx index 5018c5168aea8719ec87fdb263a764aafd788722..28ef9c248b058fa25ebb5ab146161f4069e4d7e5 100644 --- a/frontend/src/metabase/components/Calendar.jsx +++ b/frontend/src/metabase/components/Calendar.jsx @@ -31,15 +31,25 @@ export default class Calendar extends Component { componentWillReceiveProps(nextProps) { if ( - !moment(nextProps.selected).isSame(this.props.selected, "day") || - !moment(nextProps.selectedEnd).isSame(this.props.selectedEnd, "day") + // `selected` became null or not null + (nextProps.selected == null) !== (this.props.selected == null) || + // `selectedEnd` became null or not null + (nextProps.selectedEnd == null) !== (this.props.selectedEnd == null) || + // `selected` is not null and doesn't match previous `selected` + (nextProps.selected != null && + !moment(nextProps.selected).isSame(this.props.selected, "day")) || + // `selectedEnd` is not null and doesn't match previous `selectedEnd` + (nextProps.selectedEnd != null && + !moment(nextProps.selectedEnd).isSame(this.props.selectedEnd, "day")) ) { let resetCurrent = false; - if (nextProps.selected && nextProps.selectedEnd) { + if (nextProps.selected != null && nextProps.selectedEnd != null) { + // reset if `current` isn't between `selected` and `selectedEnd` month resetCurrent = nextProps.selected.isAfter(this.state.current, "month") && nextProps.selectedEnd.isBefore(this.state.current, "month"); - } else if (nextProps.selected) { + } else if (nextProps.selected != null) { + // reset if `current` isn't in `selected` month resetCurrent = nextProps.selected.isAfter(this.state.current, "month") || nextProps.selected.isBefore(this.state.current, "month"); @@ -119,7 +129,7 @@ export default class Calendar extends Component { } renderWeeks(current) { - var weeks = [], + let weeks = [], done = false, date = moment(current) .startOf("month") diff --git a/frontend/src/metabase/components/ColumnarSelector.jsx b/frontend/src/metabase/components/ColumnarSelector.jsx index cd014ff3ba58960c0bc66cb9f774342824da3c6d..f378b5f385db931b7c9e54e63a084f9af8af8b7c 100644 --- a/frontend/src/metabase/components/ColumnarSelector.jsx +++ b/frontend/src/metabase/components/ColumnarSelector.jsx @@ -22,15 +22,15 @@ export default class ColumnarSelector extends Component { ? column.disabledOptionIds.includes(item.id) : false; - var columns = this.props.columns.map((column, columnIndex) => { - var sectionElements; + let columns = this.props.columns.map((column, columnIndex) => { + let sectionElements; if (column) { - var lastColumn = columnIndex === this.props.columns.length - 1; - var sections = column.sections || [column]; + let lastColumn = columnIndex === this.props.columns.length - 1; + let sections = column.sections || [column]; sectionElements = sections.map((section, sectionIndex) => { - var title = section.title; - var items = section.items.map((item, rowIndex) => { - var itemClasses = cx({ + let title = section.title; + let items = section.items.map((item, rowIndex) => { + let itemClasses = cx({ "ColumnarSelector-row": true, "ColumnarSelector-row--selected": isItemSelected(item, column), "ColumnarSelector-row--disabled": isItemDisabled(item, column), @@ -38,9 +38,9 @@ export default class ColumnarSelector extends Component { "no-decoration": true, "cursor-default": isItemDisabled(item, column), }); - var checkIcon = lastColumn ? <Icon name="check" size={14} /> : null; - var descriptionElement; - var description = + let checkIcon = lastColumn ? <Icon name="check" size={14} /> : null; + let descriptionElement; + let description = column.itemDescriptionFn && column.itemDescriptionFn(item); if (description) { descriptionElement = ( @@ -67,7 +67,7 @@ export default class ColumnarSelector extends Component { </li> ); }); - var titleElement; + let titleElement; if (title) { titleElement = ( <div className="ColumnarSelector-title">{title}</div> diff --git a/frontend/src/metabase/components/CreateDashboardModal.jsx b/frontend/src/metabase/components/CreateDashboardModal.jsx index 5836047e34fdde08e0ef46775eded042c6ee475c..33eb43f171a875c98b4e96fdab69aa3fa30a1e19 100644 --- a/frontend/src/metabase/components/CreateDashboardModal.jsx +++ b/frontend/src/metabase/components/CreateDashboardModal.jsx @@ -35,17 +35,17 @@ export default class CreateDashboardModal extends Component { createNewDash(event) { event.preventDefault(); - var name = this.state.name && this.state.name.trim(); - var description = this.state.description && this.state.description.trim(); + let name = this.state.name && this.state.name.trim(); + let description = this.state.description && this.state.description.trim(); // populate a new Dash object - var newDash = { + let newDash = { name: name && name.length > 0 ? name : null, description: description && description.length > 0 ? description : null, }; // create a new dashboard - var component = this; + let component = this; this.props.createDashboardFn(newDash).then(null, function(error) { component.setState({ errors: error, @@ -54,9 +54,9 @@ export default class CreateDashboardModal extends Component { } render() { - var formError; + let formError; if (this.state.errors) { - var errorMessage = t`Server error encountered`; + let errorMessage = t`Server error encountered`; if (this.state.errors.data && this.state.errors.data.message) { errorMessage = this.state.errors.data.message; } @@ -65,9 +65,9 @@ export default class CreateDashboardModal extends Component { formError = <span className="text-error px2">{errorMessage}</span>; } - var name = this.state.name && this.state.name.trim(); + let name = this.state.name && this.state.name.trim(); - var formReady = name !== null && name !== ""; + let formReady = name !== null && name !== ""; return ( <ModalContent diff --git a/frontend/src/metabase/components/DatabaseDetailsForm.jsx b/frontend/src/metabase/components/DatabaseDetailsForm.jsx index f90ae68d62311a578519c34e08c430f766cd909a..8002abaa3e0bdaf906420462c770276ea60ea118 100644 --- a/frontend/src/metabase/components/DatabaseDetailsForm.jsx +++ b/frontend/src/metabase/components/DatabaseDetailsForm.jsx @@ -242,7 +242,7 @@ export default class DatabaseDetailsForm extends Component { <h3 >{t`This is a large database, so let me choose when Metabase syncs and scans`}</h3> <div style={{ maxWidth: "40rem" }} className="pt1"> - {t`By default, Metabase does a lightweight hourly sync, and an intensive daily scan of field values. + {t`By default, Metabase does a lightweight hourly sync and an intensive daily scan of field values. If you have a large database, we recommend turning this on and reviewing when and how often the field value scans happen.`} </div> </div> @@ -252,7 +252,7 @@ export default class DatabaseDetailsForm extends Component { } else if (field.name === "client-id" && CREDENTIALS_URL_PREFIXES[engine]) { let { details } = this.state; let projectID = details && details["project-id"]; - var credentialsURLLink; + let credentialsURLLink; // if (projectID) { let credentialsURL = CREDENTIALS_URL_PREFIXES[engine] + (projectID || ""); credentialsURLLink = ( @@ -260,7 +260,7 @@ export default class DatabaseDetailsForm extends Component { <div className="Grid-cell--top"> {jt`${( <a href={credentialsURL} target="_blank"> - Click here + {t`Click here`} </a> )} to generate a Client ID and Client Secret for your project.`} {t`Choose "Other" as the application type. Name it whatever you'd like.`} @@ -279,7 +279,7 @@ export default class DatabaseDetailsForm extends Component { } else if (field.name === "auth-code" && AUTH_URL_PREFIXES[engine]) { let { details } = this.state; const clientID = details && details["client-id"]; - var authURLLink; + let authURLLink; if (clientID) { let authURL = AUTH_URL_PREFIXES[engine] + clientID; authURLLink = ( @@ -287,7 +287,7 @@ export default class DatabaseDetailsForm extends Component { <div className="Grid-cell--top"> {jt`${( <a href={authURL} target="_blank"> - Click here + {t`Click here`} </a> )} to get an auth code`} {engine === "bigquery" && ( @@ -322,7 +322,7 @@ export default class DatabaseDetailsForm extends Component { <div className="Grid-cell--top ml1"> {jt`${( <a href={enableAPIURL} target="_blank"> - Click here + {t`Click here`} </a> )} to go to the console if you haven't already done so.`} </div> diff --git a/frontend/src/metabase/components/EmojiIcon.jsx b/frontend/src/metabase/components/EmojiIcon.jsx deleted file mode 100644 index 91524b5236a4af41ebed636a92f43c482d4e7f14..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/components/EmojiIcon.jsx +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint "react/prop-types": "warn" */ -import React from "react"; -import PropTypes from "prop-types"; - -import { emoji } from "metabase/lib/emoji"; - -const EmojiIcon = ({ size = 18, style, className, name }) => ( - <span className={className} style={{ width: size, height: size, ...style }}> - {emoji[name].react} - </span> -); - -EmojiIcon.propTypes = { - className: PropTypes.string, - style: PropTypes.object, - size: PropTypes.number, - name: PropTypes.string.isRequired, -}; - -export default EmojiIcon; diff --git a/frontend/src/metabase/components/ErrorDetails.jsx b/frontend/src/metabase/components/ErrorDetails.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2a5047a3d36b8544f12bb546e8066e95b8b9503d --- /dev/null +++ b/frontend/src/metabase/components/ErrorDetails.jsx @@ -0,0 +1,37 @@ +import React from "react"; +import { t } from "c-3po"; +import cx from "classnames"; + +export default class ErrorDetails extends React.Component { + state = { + showError: false, + }; + render() { + const { details, centered, className } = this.props; + if (!details) { + return null; + } + return ( + <div className={className}> + <div className={centered ? "text-centered" : "text-left"}> + <a + onClick={() => this.setState({ showError: true })} + className="link cursor-pointer" + >{t`Show error details`}</a> + </div> + <div + style={{ display: this.state.showError ? "inherit" : "none" }} + className={cx("pt3", centered ? "text-centered" : "text-left")} + > + <h2>{t`Here's the full error message`}</h2> + <div + style={{ fontFamily: "monospace" }} + className="QueryError2-detailBody bordered rounded bg-grey-0 text-bold p2 mt1" + > + {details} + </div> + </div> + </div> + ); + } +} diff --git a/frontend/src/metabase/query_builder/components/VisualizationErrorMessage.jsx b/frontend/src/metabase/components/ErrorMessage.jsx similarity index 75% rename from frontend/src/metabase/query_builder/components/VisualizationErrorMessage.jsx rename to frontend/src/metabase/components/ErrorMessage.jsx index 41ec6628e7962070089998bcde40dfaeb2e5d573..d15c235f3298980dc69399f99aeacfb159ad3398 100644 --- a/frontend/src/metabase/query_builder/components/VisualizationErrorMessage.jsx +++ b/frontend/src/metabase/components/ErrorMessage.jsx @@ -3,7 +3,9 @@ import React from "react"; import PropTypes from "prop-types"; -const VisualizationErrorMessage = ({ title, type, message, action }) => { +// NOTE: currently relies on .QueryError CSS selectors residing in query_builder.css + +const ErrorMessage = ({ title, type, message, action }) => { return ( <div className="QueryError flex full align-center"> <div className={`QueryError-image QueryError-image--${type}`} /> @@ -16,11 +18,11 @@ const VisualizationErrorMessage = ({ title, type, message, action }) => { ); }; -VisualizationErrorMessage.propTypes = { +ErrorMessage.propTypes = { title: PropTypes.string.isRequired, type: PropTypes.string.isRequired, message: PropTypes.string.isRequired, action: PropTypes.node, }; -export default VisualizationErrorMessage; +export default ErrorMessage; diff --git a/frontend/src/metabase/components/ExplicitSize.jsx b/frontend/src/metabase/components/ExplicitSize.jsx index b34e0830ad1665f3a64b7e5ace0abeccbc8aedb9..fbf065ad95be80e5ee768f85583109dd4debf686 100644 --- a/frontend/src/metabase/components/ExplicitSize.jsx +++ b/frontend/src/metabase/components/ExplicitSize.jsx @@ -24,19 +24,22 @@ export default ComposedComponent => this._mql.addListener(this._updateSize); } - // resize observer, ensure re-layout when container element changes size - this._ro = new ResizeObserver((entries, observer) => { - const element = ReactDOM.findDOMNode(this); - for (const entry of entries) { - if (entry.target === element) { - this._updateSize(); - break; + const element = ReactDOM.findDOMNode(this); + if (element) { + // resize observer, ensure re-layout when container element changes size + this._ro = new ResizeObserver((entries, observer) => { + const element = ReactDOM.findDOMNode(this); + for (const entry of entries) { + if (entry.target === element) { + this._updateSize(); + break; + } } - } - }); - this._ro.observe(ReactDOM.findDOMNode(this)); + }); + this._ro.observe(element); - this._updateSize(); + this._updateSize(); + } } componentDidUpdate() { @@ -53,11 +56,12 @@ export default ComposedComponent => } _updateSize = () => { - const { width, height } = ReactDOM.findDOMNode( - this, - ).getBoundingClientRect(); - if (this.state.width !== width || this.state.height !== height) { - this.setState({ width, height }); + const element = ReactDOM.findDOMNode(this); + if (element) { + const { width, height } = element.getBoundingClientRect(); + if (this.state.width !== width || this.state.height !== height) { + this.setState({ width, height }); + } } }; diff --git a/frontend/src/metabase/components/ExplorePane.jsx b/frontend/src/metabase/components/ExplorePane.jsx new file mode 100644 index 0000000000000000000000000000000000000000..cc5c21b0ed230f187c8c62138d72eebaaf56cb47 --- /dev/null +++ b/frontend/src/metabase/components/ExplorePane.jsx @@ -0,0 +1,145 @@ +/* @flow */ + +import React from "react"; +import { Link } from "react-router"; + +import Icon from "metabase/components/Icon"; +import MetabotLogo from "metabase/components/MetabotLogo"; +import Select, { Option } from "metabase/components/Select"; + +import { t } from "c-3po"; +import _ from "underscore"; + +import type { DatabaseCandidates, Candidate } from "metabase/meta/types/Auto"; + +const DEFAULT_TITLE = t`Hi, Metabot here.`; +const DEFAULT_DESCRIPTION = ""; + +type Props = { + candidates?: ?DatabaseCandidates, + title?: ?string, + description?: ?string, +}; +type State = { + schemaName: ?string, + visibleItems: number, +}; + +const DEFAULT_VISIBLE_ITEMS = 4; + +export class ExplorePane extends React.Component { + props: Props; + state: State = { + schemaName: null, + visibleItems: DEFAULT_VISIBLE_ITEMS, + }; + static defaultProps = { + title: DEFAULT_TITLE, + description: DEFAULT_DESCRIPTION, + }; + + render() { + let { candidates, title, description } = this.props; + let { schemaName, visibleItems } = this.state; + + let schemaNames; + let tables; + let hasMore = false; + if (candidates && candidates.length > 0) { + schemaNames = candidates.map(schema => schema.schema); + if (schemaName == null) { + schemaName = schemaNames[0]; + } + const schema = _.findWhere(candidates, { schema: schemaName }); + tables = (schema && schema.tables) || []; + } + + return ( + <div className="pt4 pb2"> + {title && ( + <div className="px4 flex align-center mb2"> + <MetabotLogo /> + <h3 className="ml2"> + <span className="block" style={{ marginTop: 8 }}> + {title} + </span> + </h3> + </div> + )} + {description && ( + <div className="px4 mb4 text-paragraph"> + <span>{description}</span> + </div> + )} + {schemaNames && + schemaNames.length > 1 && ( + <div className="px4 inline-block mb4"> + <div className="pb1 text-paragraph"> + Here's the schema I looked at: + </div> + <Select + value={schemaName} + onChange={e => + this.setState({ + schemaName: e.target.value, + visibleItems: DEFAULT_VISIBLE_ITEMS, + }) + } + > + {schemaNames.map(schemaName => ( + <Option key={schemaName} value={schemaName}> + {schemaName} + </Option> + ))} + </Select> + </div> + )} + {tables && ( + <div className="px4"> + <ExploreList candidates={tables} /> + </div> + )} + {hasMore && ( + <div + className="border-top cursor-pointer text-brand-hover flex layout-centered text-grey-2 px2 pt2 mt4" + onClick={() => this.setState({ visibleItems: visibleItems + 4 })} + > + <Icon name="chevrondown" size={20} /> + </div> + )} + </div> + ); + } +} + +export const ExploreList = ({ candidates }: { candidates: Candidate[] }) => ( + <ol className="Grid Grid--1of2 Grid--gutters"> + {candidates && + candidates.map((option, index) => ( + <li className="Grid-cell mb1" key={index}> + <ExploreOption option={option} /> + </li> + ))} + </ol> +); + +export const ExploreOption = ({ option }: { option: Candidate }) => ( + <Link to={option.url} className="flex align-center text-bold no-decoration"> + <div + className="bg-grey-0 flex align-center rounded mr2 p2 justify-center text-gold" + style={{ width: 48, height: 48 }} + > + <Icon name="bolt" size={24} className="flex-no-shrink" /> + </div> + <div> + <div className="link">{option.title}</div> + {option.description && ( + <div className="text-grey-4 text-small" style={{ marginTop: "0.25em" }}> + {option.description} + </div> + )} + </div> + </Link> +); + +export default ExplorePane; diff --git a/frontend/src/metabase/components/FieldValuesWidget.jsx b/frontend/src/metabase/components/FieldValuesWidget.jsx index 5bfe6afcf59420c696318418b597328c3205b705..a50f868f628dfb16554a5ef2977d8c3bd09373aa 100644 --- a/frontend/src/metabase/components/FieldValuesWidget.jsx +++ b/frontend/src/metabase/components/FieldValuesWidget.jsx @@ -7,7 +7,6 @@ import { t, jt } from "c-3po"; import TokenField from "metabase/components/TokenField"; import RemappedValue from "metabase/containers/RemappedValue"; import LoadingSpinner from "metabase/components/LoadingSpinner"; -import Icon from "metabase/components/Icon"; import AutoExpanding from "metabase/hoc/AutoExpanding"; @@ -20,6 +19,7 @@ import { stripId } from "metabase/lib/formatting"; import type Field from "metabase-lib/lib/metadata/Field"; import type { FieldId } from "metabase/meta/types/Field"; import type { Value } from "metabase/meta/types/Dataset"; +import type { FormattingOptions } from "metabase/lib/formatting"; import type { LayoutRendererProps } from "metabase/components/TokenField"; const MAX_SEARCH_RESULTS = 100; @@ -41,6 +41,7 @@ type Props = { maxResults: number, style?: { [key: string]: string | number }, placeholder?: string, + formatOptions?: FormattingOptions, maxWidth?: number, minWidth?: number, alwaysShowOptions?: boolean, @@ -73,6 +74,7 @@ export class FieldValuesWidget extends Component { maxResults: MAX_SEARCH_RESULTS, alwaysShowOptions: true, style: {}, + formatOptions: {}, maxWidth: 500, }; @@ -148,7 +150,7 @@ export class FieldValuesWidget extends Component { } this.setState({ - loadingState: "INIT", + loadingState: "LOADING", }); if (this._cancel) { @@ -204,16 +206,10 @@ export class FieldValuesWidget extends Component { return <EveryOptionState />; } } else if (this.isSearchable()) { - if (loadingState === "INIT") { - return alwaysShowOptions && <SearchState />; - } else if (loadingState === "LOADING") { + if (loadingState === "LOADING") { return <LoadingState />; } else if (loadingState === "LOADED") { - if (isAllSelected) { - return alwaysShowOptions && <SearchState />; - } else { - return <NoMatchState field={searchField || field} />; - } + return <NoMatchState field={searchField || field} />; } } } @@ -228,6 +224,7 @@ export class FieldValuesWidget extends Component { multi, autoFocus, color, + formatOptions, } = this.props; const { loadingState } = this.state; @@ -288,7 +285,9 @@ export class FieldValuesWidget extends Component { <RemappedValue value={value} column={field} + {...formatOptions} round={false} + compact={false} autoLoad={true} /> )} @@ -298,6 +297,7 @@ export class FieldValuesWidget extends Component { column={field} round={false} autoLoad={false} + {...formatOptions} /> )} layoutRenderer={props => ( @@ -341,17 +341,14 @@ export class FieldValuesWidget extends Component { } const LoadingState = () => ( - <div className="flex layout-centered align-center" style={{ minHeight: 100 }}> + <div + className="flex layout-centered align-center border-bottom" + style={{ minHeight: 82 }} + > <LoadingSpinner size={32} /> </div> ); -const SearchState = () => ( - <div className="flex layout-centered align-center" style={{ minHeight: 100 }}> - <Icon name="search" size={35} className="text-grey-1" /> - </div> -); - const NoMatchState = ({ field }) => ( <OptionsMessage message={jt`No matching ${( @@ -367,7 +364,7 @@ const EveryOptionState = () => ( ); const OptionsMessage = ({ message }) => ( - <div className="flex layout-centered p4">{message}</div> + <div className="flex layout-centered p4 border-bottom">{message}</div> ); export default connect(null, mapDispatchToProps)(FieldValuesWidget); diff --git a/frontend/src/metabase/components/GenericError.jsx b/frontend/src/metabase/components/GenericError.jsx new file mode 100644 index 0000000000000000000000000000000000000000..0be78663ab6bd38a468ae85c2cd5de861b1d41b7 --- /dev/null +++ b/frontend/src/metabase/components/GenericError.jsx @@ -0,0 +1,18 @@ +import React from "react"; +import { t } from "c-3po"; + +import ErrorMessage from "metabase/components/ErrorMessage"; +import ErrorDetails from "metabase/components/ErrorDetails"; + +const GenericError = ({ + title = t`Something's gone wrong`, + message = t`We've run into an error. You can try refreshing the page, or just go back.`, + details = null, +}) => ( + <div className="flex flex-column layout-centered full-height"> + <ErrorMessage type="serverError" title={title} message={message} /> + <ErrorDetails className="pt2" details={details} centered /> + </div> +); + +export default GenericError; diff --git a/frontend/src/metabase/components/Header.jsx b/frontend/src/metabase/components/Header.jsx index 396bb1ca6f6e195cfe9fc0b3a40bd079b539740f..414e0ac4cf78c4fdb11c585e01def58c722bd0e8 100644 --- a/frontend/src/metabase/components/Header.jsx +++ b/frontend/src/metabase/components/Header.jsx @@ -75,7 +75,7 @@ export default class Header extends Component { } render() { - var titleAndDescription; + let titleAndDescription; if (this.props.isEditingInfo) { titleAndDescription = ( <div className="Header-title flex flex-column flex-full bordered rounded my1"> @@ -112,7 +112,7 @@ export default class Header extends Component { } } - var attribution; + let attribution; if (this.props.item && this.props.item.creator) { attribution = ( <div className="Header-attribution"> @@ -121,7 +121,7 @@ export default class Header extends Component { ); } - var headerButtons = this.props.headerButtons.map( + let headerButtons = this.props.headerButtons.map( (section, sectionIndex) => { return ( section && diff --git a/frontend/src/metabase/components/HistoryModal.jsx b/frontend/src/metabase/components/HistoryModal.jsx index a5cf9f406c4091bb57681f0f0b9aae861250f906..08aca01df27dd7bd97132fd456b82f6c76b69b61 100644 --- a/frontend/src/metabase/components/HistoryModal.jsx +++ b/frontend/src/metabase/components/HistoryModal.jsx @@ -8,7 +8,7 @@ import ModalContent from "metabase/components/ModalContent.jsx"; import moment from "moment"; function formatDate(date) { - var m = moment(date); + let m = moment(date); if (m.isSame(moment(), "day")) { return t`Today, ` + m.format("h:mm a"); } else if (m.isSame(moment().subtract(1, "day"), "day")) { @@ -74,7 +74,7 @@ export default class HistoryModal extends Component { } render() { - var { revisions } = this.props; + let { revisions } = this.props; return ( <ModalContent title={t`Revision history`} diff --git a/frontend/src/metabase/components/LabelIcon.jsx b/frontend/src/metabase/components/LabelIcon.jsx index e805c0e136627c9509999089371e102055a826a5..a6a145c60948bb8e6dea7c5b5c16a6cb5dbdd139 100644 --- a/frontend/src/metabase/components/LabelIcon.jsx +++ b/frontend/src/metabase/components/LabelIcon.jsx @@ -5,18 +5,10 @@ import PropTypes from "prop-types"; import S from "./LabelIcon.css"; import Icon from "./Icon.jsx"; -import EmojiIcon from "./EmojiIcon.jsx"; import cx from "classnames"; const LabelIcon = ({ icon, size = 18, className, style }) => - !icon ? null : icon.charAt(0) === ":" ? ( - <EmojiIcon - className={cx(S.icon, S.emojiIcon, className)} - name={icon} - size={size} - style={style} - /> - ) : icon.charAt(0) === "#" ? ( + icon.charAt(0) === "#" ? ( <span className={cx(S.icon, S.colorIcon, className)} style={{ backgroundColor: icon, width: size, height: size }} diff --git a/frontend/src/metabase/components/ListSearchField.jsx b/frontend/src/metabase/components/ListSearchField.jsx index a3dfd9d9f72886fc4374cd5a8ac6a61fc94498b4..18db099aeb928953665481bcdd9bd2a2f046dbb6 100644 --- a/frontend/src/metabase/components/ListSearchField.jsx +++ b/frontend/src/metabase/components/ListSearchField.jsx @@ -2,6 +2,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import Icon from "metabase/components/Icon.jsx"; +import Input from "metabase/components/Input.jsx"; import { t } from "c-3po"; export default class ListSearchField extends Component { @@ -43,7 +44,7 @@ export default class ListSearchField extends Component { <span className="px1"> <Icon name="search" size={16} /> </span> - <input + <Input className={inputClassName} type="text" placeholder={placeholder} diff --git a/frontend/src/metabase/components/LoadingSpinner.jsx b/frontend/src/metabase/components/LoadingSpinner.jsx index a5f34b07d96cfe8027c1379c5d146bed21bea5fc..e82e8b8e4aa20d34bc4b689aa40cbd67a561e759 100644 --- a/frontend/src/metabase/components/LoadingSpinner.jsx +++ b/frontend/src/metabase/components/LoadingSpinner.jsx @@ -11,7 +11,7 @@ export default class LoadingSpinner extends Component { }; render() { - var { size, borderWidth, className, spinnerClassName } = this.props; + let { size, borderWidth, className, spinnerClassName } = this.props; return ( <div className={className}> <div diff --git a/frontend/src/metabase/components/MetabotLogo.jsx b/frontend/src/metabase/components/MetabotLogo.jsx new file mode 100644 index 0000000000000000000000000000000000000000..47797dd164c13c6175e78ad8d2863555becde58a --- /dev/null +++ b/frontend/src/metabase/components/MetabotLogo.jsx @@ -0,0 +1,5 @@ +import React from "react"; + +const MetabotLogo = () => <img src="app/assets/img/metabot.svg" />; + +export default MetabotLogo; diff --git a/frontend/src/metabase/components/Modal.jsx b/frontend/src/metabase/components/Modal.jsx index 7714591c5af05ae42766c937b17df5184fc473ec..ecbbbd9ee661de56bb966d8ac5101e6efd742152 100644 --- a/frontend/src/metabase/components/Modal.jsx +++ b/frontend/src/metabase/components/Modal.jsx @@ -5,7 +5,7 @@ import cx from "classnames"; import { getScrollX, getScrollY } from "metabase/lib/dom"; -import ReactCSSTransitionGroup from "react-addons-css-transition-group"; +import { CSSTransitionGroup } from "react-transition-group"; import { Motion, spring } from "react-motion"; import OnClickOutsideWrapper from "./OnClickOutsideWrapper.jsx"; @@ -88,7 +88,7 @@ export class WindowModal extends Component { "flex justify-center align-center fixed top left bottom right"; ReactDOM.unstable_renderSubtreeIntoContainer( this, - <ReactCSSTransitionGroup + <CSSTransitionGroup transitionName="Modal" transitionAppear={true} transitionAppearTimeout={250} @@ -104,7 +104,7 @@ export class WindowModal extends Component { {this._modalComponent()} </div> )} - </ReactCSSTransitionGroup>, + </CSSTransitionGroup>, this._modalElement, ); } diff --git a/frontend/src/metabase/components/NewsletterForm.jsx b/frontend/src/metabase/components/NewsletterForm.jsx index 93f9cd285b8e1886ac7c7b84c49f54e35242f6ad..8fa100920c2c6f78cde7ab4a7f8fdc9caeeedbf0 100644 --- a/frontend/src/metabase/components/NewsletterForm.jsx +++ b/frontend/src/metabase/components/NewsletterForm.jsx @@ -35,7 +35,7 @@ export default class NewsletterForm extends Component { subscribeUser(e) { e.preventDefault(); - var formData = new FormData(); + let formData = new FormData(); formData.append("EMAIL", ReactDOM.findDOMNode(this.refs.email).value); formData.append("b_869fec0e4689e8fd1db91e795_b9664113a8", ""); diff --git a/frontend/src/metabase/components/NotFound.jsx b/frontend/src/metabase/components/NotFound.jsx index 28279e7d8b0a8f42530a47e170228e039b7c4a1b..94dfb7803a56defbf9626e9aa18fe9f8d087daee 100644 --- a/frontend/src/metabase/components/NotFound.jsx +++ b/frontend/src/metabase/components/NotFound.jsx @@ -3,6 +3,8 @@ import { Link } from "react-router"; import { t } from "c-3po"; import * as Urls from "metabase/lib/urls"; +// TODO: port to ErrorMessage for more consistent style + export default class NotFound extends Component { render() { return ( diff --git a/frontend/src/metabase/components/OnClickOutsideWrapper.jsx b/frontend/src/metabase/components/OnClickOutsideWrapper.jsx index 270ef2dfa07dabaf03c3987628538c0581319f13..0d15cc3dc9d54217897729a901ba58555f0b4f94 100644 --- a/frontend/src/metabase/components/OnClickOutsideWrapper.jsx +++ b/frontend/src/metabase/components/OnClickOutsideWrapper.jsx @@ -44,7 +44,7 @@ export default class OnClickOutsideWrapper extends Component { // remove from the stack after a delay, if it is removed through some other // means this will happen too early causing parent modal to close setTimeout(() => { - var index = popoverStack.indexOf(this); + let index = popoverStack.indexOf(this); if (index >= 0) { popoverStack.splice(index, 1); } diff --git a/frontend/src/metabase/components/Popover.css b/frontend/src/metabase/components/Popover.css index a17586adbb7eb4053c0e5e4eda9f4751e730bce3..a71d1393f7e201e549ac5439b79f3773eb02ce7b 100644 --- a/frontend/src/metabase/components/Popover.css +++ b/frontend/src/metabase/components/Popover.css @@ -9,7 +9,7 @@ pointer-events: auto; min-width: 1em; /* ewwwwwwww */ border: 1px solid #ddd; - box-shadow: 0 1px 7px rgba(0, 0, 0, 0.18); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); background-color: #fff; border-radius: 4px; display: flex; diff --git a/frontend/src/metabase/components/Popover.jsx b/frontend/src/metabase/components/Popover.jsx index b37ea39e7e2a6c06f86c95f80e5153a2986bb00a..6dbf2ab6b9a62a0046cf30bc85e9423390260b65 100644 --- a/frontend/src/metabase/components/Popover.jsx +++ b/frontend/src/metabase/components/Popover.jsx @@ -1,7 +1,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import ReactDOM from "react-dom"; -import ReactCSSTransitionGroup from "react-addons-css-transition-group"; +import { CSSTransitionGroup } from "react-transition-group"; import OnClickOutsideWrapper from "./OnClickOutsideWrapper"; import Tether from "tether"; @@ -46,6 +46,16 @@ export default class Popover extends Component { // noMaxWidth allows that to be overridden in cases where popovers should // expand alongside their contents contents autoWidth: PropTypes.bool, + // prioritized vertical attachments points on the popover + verticalAttachments: PropTypes.array, + // prioritized horizontal attachment points on the popover + horizontalAttachments: PropTypes.array, + // by default we align the top edge of the target to the bottom edge of the + // popover or vice versa. This causes the same edges to be aligned + alignVerticalEdge: PropTypes.bool, + // by default we align the popover to the center of the target. This + // causes the edges to be aligned + alignHorizontalEdge: PropTypes.bool, }; static defaultProps = { @@ -53,6 +63,8 @@ export default class Popover extends Component { hasArrow: true, verticalAttachments: ["top", "bottom"], horizontalAttachments: ["center", "left", "right"], + alignVerticalEdge: false, + alignHorizontalEdge: false, targetOffsetX: 24, targetOffsetY: 5, sizeToFit: false, @@ -259,7 +271,7 @@ export default class Popover extends Component { const popoverElement = this._getPopoverElement(); ReactDOM.unstable_renderSubtreeIntoContainer( this, - <ReactCSSTransitionGroup + <CSSTransitionGroup transitionName="Popover" transitionAppear transitionEnter @@ -269,12 +281,12 @@ export default class Popover extends Component { transitionLeaveTimeout={POPOVER_TRANSITION_LEAVE} > {isOpen ? this._popoverComponent() : null} - </ReactCSSTransitionGroup>, + </CSSTransitionGroup>, popoverElement, ); if (isOpen) { - var tetherOptions = { + let tetherOptions = { element: popoverElement, target: this._getTarget(), }; @@ -304,7 +316,9 @@ export default class Popover extends Component { (best, attachmentX) => ({ ...best, attachmentX: attachmentX, - targetAttachmentX: "center", + targetAttachmentX: this.props.alignHorizontalEdge + ? attachmentX + : "center", offsetX: { center: 0, left: -this.props.targetOffsetX, @@ -322,7 +336,11 @@ export default class Popover extends Component { (best, attachmentY) => ({ ...best, attachmentY: attachmentY, - targetAttachmentY: attachmentY === "top" ? "bottom" : "top", + targetAttachmentY: (this.props.alignVerticalEdge + ? attachmentY === "bottom" + : attachmentY === "top") + ? "bottom" + : "top", offsetY: { top: this.props.targetOffsetY, bottom: -this.props.targetOffsetY, diff --git a/frontend/src/metabase/components/ProgressBar.info.js b/frontend/src/metabase/components/ProgressBar.info.js new file mode 100644 index 0000000000000000000000000000000000000000..231eae0be7e188a47090c5e41fa7c0326311ce36 --- /dev/null +++ b/frontend/src/metabase/components/ProgressBar.info.js @@ -0,0 +1,12 @@ +import React from "react"; +import ProgressBar from "metabase/components/ProgressBar"; + +export const component = ProgressBar; + +export const description = ` +Progress bar. +`; +export const examples = { + Default: <ProgressBar percentage={0.75} />, + Animated: <ProgressBar percentage={0.35} animated />, +}; diff --git a/frontend/src/metabase/components/ProgressBar.jsx b/frontend/src/metabase/components/ProgressBar.jsx index a40db0bbf335fa85d090d9297c22b4cbe972eff0..613a93c5137424b0ceeafa01bca4bf3151594aa6 100644 --- a/frontend/src/metabase/components/ProgressBar.jsx +++ b/frontend/src/metabase/components/ProgressBar.jsx @@ -1,22 +1,61 @@ +/* @flow */ import React, { Component } from "react"; -import PropTypes from "prop-types"; +import cxs from "cxs"; + +import { normal } from "metabase/lib/colors"; + +type Props = { + percentage: number, + animated: boolean, + color: string, +}; export default class ProgressBar extends Component { - static propTypes = { - percentage: PropTypes.number.isRequired, - }; + props: Props; static defaultProps = { - className: "ProgressBar", + animated: false, + color: normal.blue, }; render() { + const { percentage, animated, color } = this.props; + + const width = percentage * 100; + + const wrapperStyles = cxs({ + position: "relative", + border: `1px solid ${color}`, + height: 10, + borderRadius: 99, + }); + + const progressStyles = cxs({ + overflow: "hidden", + backgroundColor: color, + position: "relative", + height: "100%", + top: 0, + left: 0, + borderRadius: "inherit", + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + width: `${width}%`, + ":before": { + display: animated ? "block" : "none", + position: "absolute", + content: '""', // need to wrap this in quotes so it actually outputs as valid CSS + left: 0, + width: `${width / 4}%`, + height: "100%", + backgroundColor: "rgba(0, 0, 0, 0.12)", + animation: animated ? "progress-bar 1.5s linear infinite" : "none", + }, + }); + return ( - <div className={this.props.className}> - <div - className="ProgressBar-progress" - style={{ width: this.props.percentage * 100 + "%" }} - /> + <div className={wrapperStyles}> + <div className={progressStyles} /> </div> ); } diff --git a/frontend/src/metabase/components/Quotes.jsx b/frontend/src/metabase/components/Quotes.jsx new file mode 100644 index 0000000000000000000000000000000000000000..34c9f90c511f8d7ede29ca3880f8accc9ace6c4b --- /dev/null +++ b/frontend/src/metabase/components/Quotes.jsx @@ -0,0 +1,42 @@ +/* @flow */ + +import React, { Component } from "react"; + +type Props = { + period: number, + quotes: string[], +}; +type State = { + count: number, +}; + +export default class Quotes extends Component { + props: Props; + state: State = { + count: 0, + }; + + _timer: ?number = null; + + static defaultProps = { + quotes: [], + period: 1000, + }; + + componentWillMount() { + this._timer = setInterval( + () => this.setState({ count: this.state.count + 1 }), + this.props.period, + ); + } + componentWillUnmount() { + if (this._timer != null) { + clearInterval(this._timer); + } + } + render() { + const { quotes } = this.props; + const { count } = this.state; + return <span>{quotes[count % quotes.length]}</span>; + } +} diff --git a/frontend/src/metabase/components/SaveStatus.jsx b/frontend/src/metabase/components/SaveStatus.jsx index a554185807c650ed79d55e4d009f72f3dbdac58b..0bafacdd4f1c25576f2fe39d1c1af46c59c0b719 100644 --- a/frontend/src/metabase/components/SaveStatus.jsx +++ b/frontend/src/metabase/components/SaveStatus.jsx @@ -25,7 +25,7 @@ export default class SaveStatus extends Component { setSaved() { clearTimeout(this.state.recentlySavedTimeout); - var recentlySavedTimeout = setTimeout( + let recentlySavedTimeout = setTimeout( () => this.setState({ recentlySavedTimeout: null }), 5000, ); diff --git a/frontend/src/metabase/components/SchedulePicker.jsx b/frontend/src/metabase/components/SchedulePicker.jsx index 19345ccb46a81533ace968abc70476f2211719a0..62e61392ee5458db1820c0433ecf4143b4820db7 100644 --- a/frontend/src/metabase/components/SchedulePicker.jsx +++ b/frontend/src/metabase/components/SchedulePicker.jsx @@ -20,7 +20,7 @@ export const AM_PM_OPTIONS = [ ]; export const DAY_OF_WEEK_OPTIONS = [ - { name: t`"Sunday`, value: "sun" }, + { name: t`Sunday`, value: "sun" }, { name: t`Monday`, value: "mon" }, { name: t`Tuesday`, value: "tue" }, { name: t`Wednesday`, value: "wed" }, diff --git a/frontend/src/metabase/components/Select.jsx b/frontend/src/metabase/components/Select.jsx index a2a138b068aa3e7f1a790f4433af2e269d7bb80e..bf8a4dfcbdcbb20887357bcb911dc418b9434886 100644 --- a/frontend/src/metabase/components/Select.jsx +++ b/frontend/src/metabase/components/Select.jsx @@ -132,7 +132,7 @@ class BrowserSelect extends Component { ) } triggerClasses={className} - verticalAttachments={["top"]} + verticalAttachments={["top", "bottom"]} isInitiallyOpen={isInitiallyOpen} {...extraProps} > @@ -304,11 +304,11 @@ class LegacySelect extends Component { disabled, } = this.props; - var selectedName = value + let selectedName = value ? optionNameFn(value) : options && options.length > 0 ? placeholder : emptyPlaceholder; - var triggerElement = ( + let triggerElement = ( <div className={cx( "flex align-center", @@ -331,9 +331,9 @@ class LegacySelect extends Component { </div> ); - var sections = {}; + let sections = {}; options.forEach(function(option) { - var sectionName = option.section || ""; + let sectionName = option.section || ""; sections[sectionName] = sections[sectionName] || { title: sectionName || undefined, items: [], @@ -342,7 +342,7 @@ class LegacySelect extends Component { }); sections = Object.keys(sections).map(sectionName => sections[sectionName]); - var columns = [ + let columns = [ { selectedItem: value, selectedItems: values, diff --git a/frontend/src/metabase/components/Sidebar.css b/frontend/src/metabase/components/Sidebar.css index 18dff1da157d4fb1c2378ac1df01878608b8cc74..1f0303641d7aa22cfc7058d155a27276897830fa 100644 --- a/frontend/src/metabase/components/Sidebar.css +++ b/frontend/src/metabase/components/Sidebar.css @@ -56,7 +56,6 @@ :local(.item.selected) :local(.icon), :local(.sectionTitle.selected), :local(.item):hover, -:local(.item):hover :local(.icon), :local(.sectionTitle):hover { background-color: #e3f0f9; color: #2d86d4; diff --git a/frontend/src/metabase/components/SortableItemList.jsx b/frontend/src/metabase/components/SortableItemList.jsx index d4cae406e412768179ead6afa3a4edb58d55ec42..955d877902504d32c332af125101e9e94eb9d692 100644 --- a/frontend/src/metabase/components/SortableItemList.jsx +++ b/frontend/src/metabase/components/SortableItemList.jsx @@ -27,7 +27,7 @@ export default class SortableItemList extends Component { } render() { - var items; + let items; if (this.state.sort === "Last Modified") { items = this.props.items .slice() @@ -44,7 +44,7 @@ export default class SortableItemList extends Component { <div className="SortableItemList"> <div className="flex align-center px2 pb3 border-bottom"> <h5 className="text-bold text-uppercase text-grey-3 ml2 mt1 mr2"> - Sort by + {t`Sort by`} </h5> <Radio value={this.state.sort} diff --git a/frontend/src/metabase/components/TokenField.jsx b/frontend/src/metabase/components/TokenField.jsx index d3559b67d2fa4f4bc09e1f538141a4afbc5c3b22..0d9ce8e0c48423efd0d02a828f9e92670785c764 100644 --- a/frontend/src/metabase/components/TokenField.jsx +++ b/frontend/src/metabase/components/TokenField.jsx @@ -24,7 +24,6 @@ import { isObscured } from "metabase/lib/dom"; const inputBoxClasses = cxs({ maxHeight: 130, - overflow: "scroll", }); type Value = any; @@ -536,18 +535,15 @@ export default class TokenField extends Component { const valuesList = ( <ul className={cx( - "m1 p0 pb1 bordered rounded flex flex-wrap bg-white scroll-x scroll-y", + "border-bottom p1 pb2 flex flex-wrap bg-white scroll-x scroll-y", inputBoxClasses, - { - [`border-grey-2`]: this.state.isFocused, - }, )} style={this.props.style} onMouseDownCapture={this.onMouseDownCapture} > {value.map((v, index) => ( <li - key={v} + key={index} className={cx( `mt1 ml1 py1 pl2 rounded bg-grey-05`, multi ? "pr1" : "pr2", @@ -590,13 +586,13 @@ export default class TokenField extends Component { const optionsList = filteredOptions.length === 0 ? null : ( <ul - className="ml1 scroll-y scroll-show" + className="pl1 py1 scroll-y scroll-show border-bottom" style={{ maxHeight: 300 }} onMouseEnter={() => this.setState({ listIsHovered: true })} onMouseLeave={() => this.setState({ listIsHovered: false })} > {filteredOptions.map(option => ( - <li key={this._value(option)}> + <li className="mr1" key={this._value(option)}> <div ref={ this._valueIsEqual(selectedOptionValue, this._value(option)) diff --git a/frontend/src/metabase/components/Unauthorized.jsx b/frontend/src/metabase/components/Unauthorized.jsx index d0917fc0e8c177b82973ae283986aea30f59a101..d19eff5383175b98ee0b719cfe6fc2760a6afc99 100644 --- a/frontend/src/metabase/components/Unauthorized.jsx +++ b/frontend/src/metabase/components/Unauthorized.jsx @@ -2,6 +2,8 @@ import React, { Component } from "react"; import { t } from "c-3po"; import Icon from "metabase/components/Icon.jsx"; +// TODO: port to ErrorMessage for more consistent style + export default class Unauthorized extends Component { render() { return ( diff --git a/frontend/src/metabase/components/UserAvatar.jsx b/frontend/src/metabase/components/UserAvatar.jsx index 5809d1a972d938a6e42961d88e8cdd72cc2e480b..89ee5da7f302e51c173b16e6723ced3b4f03fb20 100644 --- a/frontend/src/metabase/components/UserAvatar.jsx +++ b/frontend/src/metabase/components/UserAvatar.jsx @@ -50,7 +50,7 @@ export default class UserAvatar extends Component { return ( <div className={cx(classes)} - style={Object.assign(this.styles, this.props.style)} + style={{ ...this.styles, ...this.props.style }} > {this.userInitials()} </div> diff --git a/frontend/src/metabase/components/Value.jsx b/frontend/src/metabase/components/Value.jsx index f95822caeee9cfe2e7e6fdbd316ae634ec7433b9..ae0aee94a44856c273ffe79060beaa7bfe949e0a 100644 --- a/frontend/src/metabase/components/Value.jsx +++ b/frontend/src/metabase/components/Value.jsx @@ -14,6 +14,9 @@ type Props = { } & FormattingOptions; const Value = ({ value, ...options }: Props) => { + if (options.hide) { + return null; + } if (options.remap) { return <RemappedValue value={value} {...options} />; } diff --git a/frontend/src/metabase/components/__mocks__/ExplicitSize.jsx b/frontend/src/metabase/components/__mocks__/ExplicitSize.jsx new file mode 100644 index 0000000000000000000000000000000000000000..91e740969979b01f4c19ef9b0fc2f439cb76c1c3 --- /dev/null +++ b/frontend/src/metabase/components/__mocks__/ExplicitSize.jsx @@ -0,0 +1,8 @@ +/* eslint-disable react/display-name */ +import React from "react"; + +const ExplicitSize = ComposedComponent => props => ( + <ComposedComponent width={1000} height={1000} {...props} /> +); + +export default ExplicitSize; diff --git a/frontend/src/metabase/containers/AdHocQuestionLoader.jsx b/frontend/src/metabase/containers/AdHocQuestionLoader.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b891b2eebf8db30c7a8acf033e917b54383f616a --- /dev/null +++ b/frontend/src/metabase/containers/AdHocQuestionLoader.jsx @@ -0,0 +1,166 @@ +/* @flow */ + +import React from "react"; +import { connect } from "react-redux"; + +// things that will eventually load the quetsion +import { deserializeCardFromUrl } from "metabase/lib/card"; +import { loadMetadataForCard } from "metabase/query_builder/actions"; +import { getMetadata } from "metabase/selectors/metadata"; + +import Question from "metabase-lib/lib/Question"; + +// type annotations +import type Metadata from "metabase-lib/lib/metadata/Metadata"; +import type { Card } from "metabase/meta/types/Card"; + +type ChildProps = { + loading: boolean, + error: ?any, + question: ?Question, +}; + +type Props = { + questionHash?: string, + children?: (props: ChildProps) => React$Element<any>, + // provided by redux + loadMetadataForCard: (card: Card) => Promise<void>, + metadata: Metadata, +}; + +type State = { + // the question should be of type Question if it is set + question: ?Question, + card: ?Card, + loading: boolean, + error: ?any, +}; + +/* + * AdHocQuestionLoader + * + * Load a transient quetsion via its encoded URL and return it to the calling + * component + * + * @example + * + * Render prop style + * import AdHocQuestionLoader from 'metabase/containers/AdHocQuestionLoader' + * + * // assuming + * class ExampleAdHocQuestionFeature extends React.Component { + * render () { + * return ( + * <AdHocQuestionLoader questionId={this.props.params.questionId}> + * { ({ question, loading, error }) => { + * + * }} + * </SavedQuestion> + * ) + * } + * } + * + * @example + * + * The raw un-connected component is also exported so we can unit test it + * without the redux store. + */ +export class AdHocQuestionLoader extends React.Component { + props: Props; + + state: State = { + // this will store the loaded question + question: null, + // keep a reference to the card as well to help with re-creating question + // objects if the underlying metadata changes + card: null, + loading: false, + error: null, + }; + + componentWillMount() { + // load the specified question when the component mounts + this._loadQuestion(this.props.questionHash); + } + + componentWillReceiveProps(nextProps: Props) { + // if the questionHash changes (this will most likely be the result of a + // url change) then we need to load this new question + if (nextProps.questionHash !== this.props.questionHash) { + this._loadQuestion(nextProps.questionHash); + } + + // if the metadata changes for some reason we need to make sure we + // update the question with that metadata + if (nextProps.metadata !== this.props.metadata && this.state.card) { + this.setState({ + question: new Question(nextProps.metadata, this.state.card), + }); + } + } + + /* + * Load an AdHoc question and any required metadata + * + * 1. Decode the question via the URL + * 2. Load any required metadata into the redux store + * 3. Create a new Question object to return to metabase-lib methods can + * be used + * 4. Set the component state to the new Question + */ + async _loadQuestion(questionHash: ?string) { + if (!questionHash) { + this.setState({ + loading: false, + error: null, + question: null, + card: null, + }); + return; + } + try { + this.setState({ loading: true, error: null }); + // get the card definition from the URL, the "card" + const card = deserializeCardFromUrl(questionHash); + // pass the decoded card to load any necessary metadata + // (tables, source db, segments, etc) into + // the redux store, the resulting metadata will be avaliable as metadata on the + // component props once it's avaliable + await this.props.loadMetadataForCard(card); + + // instantiate a new question object using the metadata and saved question + // so we can use metabase-lib methods to retrieve information and modify + // the question + const question = new Question(this.props.metadata, card); + + // finally, set state to store the Question object so it can be passed + // to the component using the loader, keep a reference to the card + // as well + this.setState({ loading: false, question, card }); + } catch (error) { + this.setState({ loading: false, error }); + } + } + + render() { + const { children } = this.props; + const { question, loading, error } = this.state; + // call the child function with our loaded question + return children && children({ question, loading, error }); + } +} + +// redux stuff +function mapStateToProps(state) { + return { + metadata: getMetadata(state), + }; +} + +const mapDispatchToProps = { + loadMetadataForCard, +}; + +export default connect(mapStateToProps, mapDispatchToProps)( + AdHocQuestionLoader, +); diff --git a/frontend/src/metabase/containers/QuestionAndResultLoader.jsx b/frontend/src/metabase/containers/QuestionAndResultLoader.jsx new file mode 100644 index 0000000000000000000000000000000000000000..aac64d22466fe07f0c4e13dd2f05cdc23ca7a1b6 --- /dev/null +++ b/frontend/src/metabase/containers/QuestionAndResultLoader.jsx @@ -0,0 +1,59 @@ +/* @flow */ + +import React from "react"; + +import QuestionLoader from "metabase/containers/QuestionLoader"; +import QuestionResultLoader from "metabase/containers/QuestionResultLoader"; + +import type { ChildProps as QuestionLoaderChildProps } from "./QuestionLoader"; +import type { ChildProps as QuestionResultLoaderChildProps } from "./QuestionResultLoader"; + +type ChildProps = QuestionLoaderChildProps & QuestionResultLoaderChildProps; + +type Props = { + questionId?: ?number, + questionHash?: ?string, + children?: (props: ChildProps) => React$Element<any>, +}; + +/* + * QuestionAndResultLoader + * + * Load a question and also run the query to get the result. Useful when you want + * to load both a question and its visualization at the same time. + * + * @example + * + * import QuestionAndResultLoader from 'metabase/containers/QuestionAndResultLoader' + * + * const MyNewFeature = ({ params, location }) => + * <QuestionAndResultLoader question={question}> + * { ({ question, result, cancel, reload }) => + * <div> + * </div> + * </QuestionAndResultLoader> + * + */ +const QuestionAndResultLoader = ({ + questionId, + questionHash, + children, +}: Props) => ( + <QuestionLoader questionId={questionId} questionHash={questionHash}> + {({ loading: questionLoading, error: questionError, ...questionProps }) => ( + <QuestionResultLoader question={questionProps.question}> + {({ loading: resultLoading, error: resultError, ...resultProps }) => + children && + children({ + ...questionProps, + ...resultProps, + loading: resultLoading || questionLoading, + error: resultError || questionError, + }) + } + </QuestionResultLoader> + )} + </QuestionLoader> +); + +export default QuestionAndResultLoader; diff --git a/frontend/src/metabase/containers/QuestionLoader.jsx b/frontend/src/metabase/containers/QuestionLoader.jsx new file mode 100644 index 0000000000000000000000000000000000000000..50ec004f47fabaae3ebf84f8dcd987dd23df5aae --- /dev/null +++ b/frontend/src/metabase/containers/QuestionLoader.jsx @@ -0,0 +1,69 @@ +/* @flow */ + +import React from "react"; + +import AdHocQuestionLoader from "metabase/containers/AdHocQuestionLoader"; +import SavedQuestionLoader from "metabase/containers/SavedQuestionLoader"; + +import Question from "metabase-lib/lib/Question"; + +export type ChildProps = { + loading: boolean, + error: ?any, + question: ?Question, +}; + +type Props = { + questionId?: ?number, + questionHash?: ?string, + children?: (props: ChildProps) => React$Element<any>, +}; + +/* + * QuestionLoader + * + * Load either a saved or ad-hoc question depending on which is needed. Use + * this component if you need to moved between saved and ad-hoc questions + * as part of the same experience in the same part of the app. + * + * @example + * import QuestionLoader from 'metabase/containers/QuestionLoader + * + * const MyQuestionExplorer = ({ params, location }) => + * <QuestionLoader questionId={params.questionId} questionHash={ + * { ({ question, loading, error }) => + * <div> + * { // display info about the loaded question } + * <h1>{ question.displayName() }</h1> + * + * { // link to a new question created by adding a filter } + * <Link + * to={ + * question.query() + * .addFilter([ + * "SEGMENT", + * question.query().filterSegmentOptions()[0] + * ]) + * .question() + * .getUrl() + * } + * > + * View this ad-hoc exploration + * </Link> + * </div> + * } + * </QuestionLoader> + * + */ + +const QuestionLoader = ({ questionId, questionHash, children }: Props) => + // if there's a questionHash it means we're in ad-hoc land + questionHash ? ( + <AdHocQuestionLoader questionHash={questionHash} children={children} /> + ) : // otherwise if there's a non-null questionId it means we're in saved land + questionId != null ? ( + <SavedQuestionLoader questionId={questionId} children={children} /> + ) : // finally, if neither is present, just don't do anything + null; + +export default QuestionLoader; diff --git a/frontend/src/metabase/containers/QuestionResultLoader.jsx b/frontend/src/metabase/containers/QuestionResultLoader.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fb24e9f7ccf262375e644bdcc6c7c0d577b2aaa5 --- /dev/null +++ b/frontend/src/metabase/containers/QuestionResultLoader.jsx @@ -0,0 +1,151 @@ +/* @flow */ + +import React from "react"; +import { defer } from "metabase/lib/promise"; + +import type { Dataset } from "metabase/meta/types/Dataset"; +import type { RawSeries } from "metabase/meta/types/Visualization"; + +import Question from "metabase-lib/lib/Question"; + +export type ChildProps = { + loading: boolean, + error: ?any, + results: ?(Dataset[]), + result: ?Dataset, + rawSeries: ?RawSeries, + cancel: () => void, + reload: () => void, +}; + +type Props = { + question: ?Question, + children?: (props: ChildProps) => React$Element<any>, +}; + +type State = { + results: ?(Dataset[]), + loading: boolean, + error: ?any, +}; + +/* + * Question result loader + * + * Handle runninng, canceling, and reloading Question results + * + * @example + * <QuestionResultLoader question={question}> + * { ({ result, cancel, reload }) => + * <div> + * { result && (<Visualization ... />) } + * + * <a onClick={() => reload()}>Reload this please</a> + * <a onClick={() => cancel()}>Changed my mind</a> + * </div> + * } + * </QuestionResultLoader> + * + */ +export class QuestionResultLoader extends React.Component { + props: Props; + state: State = { + results: null, + loading: false, + error: null, + }; + + _cancelDeferred: ?() => void; + + componentWillMount() { + this._loadResult(this.props.question); + } + + componentWillReceiveProps(nextProps: Props) { + // if the question is different, we need to do a fresh load, check the + // difference by comparing the URL we'd generate for the question + if ( + (nextProps.question && nextProps.question.getUrl()) !== + (this.props.question && this.props.question.getUrl()) + ) { + this._loadResult(nextProps.question); + } + } + + /* + * load the result by calling question.apiGetResults + */ + async _loadResult(question: ?Question) { + // we need to have a question for anything to happen + if (question) { + try { + // set up a defer for cancelation + this._cancelDeferred = defer(); + + // begin the request, set cancel in state so the query can be canceled + this.setState({ loading: true, results: null, error: null }); + + // call apiGetResults and pass our cancel to allow for cancelation + const results: Dataset[] = await question.apiGetResults({ + cancelDeferred: this._cancelDeferred, + }); + + // setState with our result, remove our cancel since we've finished + this.setState({ loading: false, results }); + } catch (error) { + this.setState({ loading: false, error }); + } + } else { + // if there's not a question we can't do anything so go back to our initial + // state + this.setState({ loading: false, results: null, error: null }); + } + } + + /* + * a function to pass to the child to allow the component to call + * load again + */ + _reload = () => { + this._loadResult(this.props.question); + }; + + /* + * a function to pass to the child to allow the component to interrupt + * the query + */ + _cancel = () => { + // we only want to do things if cancel has been set + if (this.state.loading) { + // set loading false + this.setState({ loading: false }); + // call our _cancelDeferred to cancel the query + if (this._cancelDeferred) { + this._cancelDeferred(); + } + } + }; + + render() { + const { question, children } = this.props; + const { results, loading, error } = this.state; + return ( + children && + children({ + results, + result: results && results[0], + // convienence for <Visualization /> component. Only support single series for now + rawSeries: + question && results + ? [{ card: question.card(), data: results[0].data }] + : null, + loading, + error, + cancel: this._cancel, + reload: this._reload, + }) + ); + } +} + +export default QuestionResultLoader; diff --git a/frontend/src/metabase/containers/SaveQuestionModal.jsx b/frontend/src/metabase/containers/SaveQuestionModal.jsx index 4489a3c7dc2a76225578b77226e0ae15390cfd22..ea43c6c05999ce551613ba92265f9b6ea585d1e7 100644 --- a/frontend/src/metabase/containers/SaveQuestionModal.jsx +++ b/frontend/src/metabase/containers/SaveQuestionModal.jsx @@ -1,7 +1,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; -import ReactCSSTransitionGroup from "react-addons-css-transition-group"; +import { CSSTransitionGroup } from "react-transition-group"; import FormField from "metabase/components/FormField.jsx"; import ModalContent from "metabase/components/ModalContent.jsx"; @@ -135,9 +135,9 @@ export default class SaveQuestionModal extends Component { render() { let { error, details } = this.state; - var formError; + let formError; if (error) { - var errorMessage; + let errorMessage; if (error.status === 500) { errorMessage = t`Server error encountered`; } @@ -155,7 +155,7 @@ export default class SaveQuestionModal extends Component { } } - var saveOrUpdate = null; + let saveOrUpdate = null; if (!this.props.card.id && this.props.originalCard) { saveOrUpdate = ( <FormField @@ -201,7 +201,7 @@ export default class SaveQuestionModal extends Component { > <form className="Form-inputs" onSubmit={this.formSubmitted}> {saveOrUpdate} - <ReactCSSTransitionGroup + <CSSTransitionGroup transitionName="saveQuestionModalFields" transitionEnterTimeout={500} transitionLeaveTimeout={500} @@ -275,7 +275,7 @@ export default class SaveQuestionModal extends Component { </CollectionList> </div> )} - </ReactCSSTransitionGroup> + </CSSTransitionGroup> </form> </ModalContent> ); diff --git a/frontend/src/metabase/containers/SavedQuestionLoader.jsx b/frontend/src/metabase/containers/SavedQuestionLoader.jsx new file mode 100644 index 0000000000000000000000000000000000000000..76c543dd45e05debac247414d4fdb5d12ec5eef7 --- /dev/null +++ b/frontend/src/metabase/containers/SavedQuestionLoader.jsx @@ -0,0 +1,167 @@ +/* @flow */ + +import React from "react"; +import { connect } from "react-redux"; + +// things that will eventually load the quetsion +import { CardApi } from "metabase/services"; +import { loadMetadataForCard } from "metabase/query_builder/actions"; +import { getMetadata } from "metabase/selectors/metadata"; + +import Question from "metabase-lib/lib/Question"; + +// type annotations +import type Metadata from "metabase-lib/lib/metadata/Metadata"; +import type { Card } from "metabase/meta/types/Card"; + +type ChildProps = { + loading: boolean, + error: ?any, + question: ?Question, +}; + +type Props = { + questionId: ?number, + children?: (props: ChildProps) => React$Element<any>, + // provided by redux + loadMetadataForCard: (card: Card) => Promise<void>, + metadata: Metadata, +}; + +type State = { + // the question should be of type Question if it is set + question: ?Question, + // keep a reference to the card as well to help with re-creating question + // objects if the underlying metadata changes + card: ?Card, + loading: boolean, + error: ?any, +}; + +/* + * SavedQuestionLaoder + * + * Load a saved quetsion and return it to the calling component + * + * @example + * + * Render prop style + * import SavedQuestionLoader from 'metabase/containers/SavedQuestionLoader' + * + * // assuming + * class ExampleSavedQuestionFeature extends React.Component { + * render () { + * return ( + * <SavedQuestionLoader questionId={this.props.params.questionId}> + * { ({ question, loading, error }) => { + * + * }} + * </SavedQuestion> + * ) + * } + * } + * + * @example + * + * The raw un-connected component is also exported so we can unit test it + * without the redux store. + */ +export class SavedQuestionLoader extends React.Component { + props: Props; + + state: State = { + // this will store the loaded question + question: null, + card: null, + loading: false, + error: null, + }; + + componentWillMount() { + // load the specified question when the component mounts + this._loadQuestion(this.props.questionId); + } + + componentWillReceiveProps(nextProps: Props) { + // if the questionId changes (this will most likely be the result of a + // url change) then we need to load this new question + if (nextProps.questionId !== this.props.questionId) { + this._loadQuestion(nextProps.questionId); + } + + // if the metadata changes for some reason we need to make sure we + // update the question with that metadata + if (nextProps.metadata !== this.props.metadata && this.state.card) { + this.setState({ + question: new Question(nextProps.metadata, this.state.card), + }); + } + } + + /* + * Load a saved question and any required metadata + * + * 1. Get the card from the api + * 2. Load any required metadata into the redux store + * 3. Create a new Question object to return to metabase-lib methods can + * be used + * 4. Set the component state to the new Question + */ + async _loadQuestion(questionId: ?number) { + if (questionId == null) { + this.setState({ + loading: false, + error: null, + question: null, + card: null, + }); + return; + } + try { + this.setState({ loading: true, error: null }); + // get the saved question via the card API + const card = await CardApi.get({ cardId: questionId }); + + // pass the retrieved card to load any necessary metadata + // (tables, source db, segments, etc) into + // the redux store, the resulting metadata will be avaliable as metadata on the + // component props once it's avaliable + await this.props.loadMetadataForCard(card); + + // instantiate a new question object using the metadata and saved question + // so we can use metabase-lib methods to retrieve information and modify + // the question + // + const question = new Question(this.props.metadata, card); + + // finally, set state to store the Question object so it can be passed + // to the component using the loader, keep a reference to the card + // as well + this.setState({ loading: false, question, card }); + } catch (error) { + this.setState({ loading: false, error }); + } + } + + render() { + const { children } = this.props; + const { question, loading, error } = this.state; + // call the child function with our loaded question + return children && children({ question, loading, error }); + } +} + +// redux stuff +function mapStateToProps(state) { + return { + metadata: getMetadata(state), + }; +} + +const mapDispatchToProps = { + loadMetadataForCard, +}; + +export default connect(mapStateToProps, mapDispatchToProps)( + SavedQuestionLoader, +); diff --git a/frontend/src/metabase/containers/UndoListing.css b/frontend/src/metabase/containers/UndoListing.css index 7d5b2c21f225dce3e804b1737d96b8491b426673..6995e35cf25f9574fb9676377c50a298cd7a555a 100644 --- a/frontend/src/metabase/containers/UndoListing.css +++ b/frontend/src/metabase/containers/UndoListing.css @@ -7,10 +7,11 @@ :local(.undo) { composes: mt2 p2 from "style"; composes: bordered rounded shadowed from "style"; - composes: bg-white from "style"; composes: relative from "style"; composes: flex align-center from "style"; width: 320px; + background-color: #2e353b; + color: white; } :local(.actions) { @@ -30,14 +31,14 @@ color: var(--grey-3); } -.UndoListing-enter { -} -.UndoListing-enter.UndoListing-enter-active { -} -.UndoListing-leave { - opacity: 1; -} +/* enter and exit initial and final state */ +.UndoListing-enter, .UndoListing-leave.UndoListing-leave-active { opacity: 0.01; transition: opacity 300ms ease-in; } + +.UndoListing-leave, +.UndoListing-enter.UndoListing-enter-active { + opacity: 1; +} diff --git a/frontend/src/metabase/containers/UndoListing.jsx b/frontend/src/metabase/containers/UndoListing.jsx index b6b1f22748ee80506d0daf8f3e36c7dea50ca70d..dfab03d0bab4a30a84b9e857a29b84b095b749aa 100644 --- a/frontend/src/metabase/containers/UndoListing.jsx +++ b/frontend/src/metabase/containers/UndoListing.jsx @@ -11,7 +11,7 @@ import { t } from "c-3po"; import Icon from "metabase/components/Icon"; import BodyComponent from "metabase/components/BodyComponent"; -import ReactCSSTransitionGroup from "react-addons-css-transition-group"; +import { CSSTransitionGroup } from "react-transition-group"; const mapStateToProps = (state, props) => { return { @@ -37,7 +37,7 @@ export default class UndoListing extends Component { const { undos, performUndo, dismissUndo } = this.props; return ( <ul className={S.listing}> - <ReactCSSTransitionGroup + <CSSTransitionGroup transitionName="UndoListing" transitionEnterTimeout={300} transitionLeaveTimeout={300} @@ -65,7 +65,7 @@ export default class UndoListing extends Component { )} </li> ))} - </ReactCSSTransitionGroup> + </CSSTransitionGroup> </ul> ); } diff --git a/frontend/src/metabase/css/admin.css b/frontend/src/metabase/css/admin.css index 70eb69974f88e05bc9d64d42271bd80888a5494a..620263bf0089ecf1b428a7e6e0214957c85a49f9 100644 --- a/frontend/src/metabase/css/admin.css +++ b/frontend/src/metabase/css/admin.css @@ -180,14 +180,6 @@ font-size: smaller; } -.AdminList-item .ProgressBar { - opacity: 0.2; -} - -.AdminList-item.selected .ProgressBar { - opacity: 1; -} - .AdminInput { color: var(--default-font-color); padding: var(--padding-1); @@ -274,30 +266,6 @@ color: white !important; } -.ProgressBar { - position: relative; - border: 1px solid #6f7a8b; - - width: 55px; - height: 10px; - border-radius: 99px; -} -.ProgressBar--mini { - width: 17px; - height: 8px; - border-radius: 2px; -} -.ProgressBar-progress { - background-color: #6f7a8b; - position: absolute; - height: 100%; - top: 0px; - left: 0px; - border-radius: inherit; - border-top-left-radius: 0px; - border-bottom-left-radius: 0px; -} - .SaveStatus { line-height: 1; } diff --git a/frontend/src/metabase/css/components/buttons.css b/frontend/src/metabase/css/components/buttons.css index ae78a3fa7487554a0f37e657f19aff5c94de9d6f..ee3caac7ade617449a8643edf965f91e07831887 100644 --- a/frontend/src/metabase/css/components/buttons.css +++ b/frontend/src/metabase/css/components/buttons.css @@ -212,6 +212,8 @@ color: #fff; } .Button--success:hover { + background-color: var(--green-saturated-color); + border-color: var(--green-saturated-color); color: #fff; } diff --git a/frontend/src/metabase/css/core/animation.css b/frontend/src/metabase/css/core/animation.css new file mode 100644 index 0000000000000000000000000000000000000000..fdc89bf05a18fb7d851506e238802e140c9a4f31 --- /dev/null +++ b/frontend/src/metabase/css/core/animation.css @@ -0,0 +1,9 @@ +/* TODO - ideally this would be more reusable and not hardcode a value */ +@keyframes progress-bar { + from { + transform: translate3d(0, 0, 0, 0); + } + to { + transform: translate3d(1000px, 0, 0); + } +} diff --git a/frontend/src/metabase/css/core/colors.css b/frontend/src/metabase/css/core/colors.css index a8aa1c24dfed918a35952e29283de7eb6d4cee13..66e780d10c815b11d3d877985e3474764bf5d369 100644 --- a/frontend/src/metabase/css/core/colors.css +++ b/frontend/src/metabase/css/core/colors.css @@ -17,19 +17,19 @@ --success-color: #9cc177; --headsup-color: #f5a623; - --warning-color: #e35050; --gold-color: #f9d45c; --orange-color: #f9a354; --purple-color: #a989c5; --green-color: #9cc177; - --green-saturated-color: #84bb4c; + --green-saturated-color: #90bd64; --dark-color: #4c545b; - --error-color: #ef8c8c; --slate-color: #9ba5b1; --slate-light-color: #dfe8ea; --slate-almost-extra-light-color: #edf2f5; --slate-extra-light-color: #f9fbfc; + + --error-color: #e35050; } .text-default, @@ -41,10 +41,6 @@ color: var(--default-font-color); } -.text-danger { - color: #eea5a5; -} - /* brand */ .text-brand, :local(.text-brand), @@ -102,16 +98,6 @@ background-color: #fce8e8; } -/* warning */ - -.text-warning { - color: var(--warning-color) !important; -} - -.bg-warning { - background-color: var(--warning-color); -} - /* favorite */ .text-gold, .text-gold-hover:hover { @@ -160,6 +146,10 @@ .bg-green { background-color: var(--green-color); } +.bg-green-saturated, +.bg-green-saturated-hover:hover { + background-color: var(--green-saturated-color); +} /* alt */ .bg-alt, diff --git a/frontend/src/metabase/css/core/flex.css b/frontend/src/metabase/css/core/flex.css index 91ea33a7143d0c048fee1f5e7b41ffe5f75f233a..2a8085532748e14dae174551eb6e341d19787545 100644 --- a/frontend/src/metabase/css/core/flex.css +++ b/frontend/src/metabase/css/core/flex.css @@ -150,6 +150,12 @@ flex-direction: row; } +@media screen and (--breakpoint-min-sm) { + .sm-flex-row { + flex-direction: row; + } +} + .flex-wrap { flex-wrap: wrap; } diff --git a/frontend/src/metabase/css/core/index.css b/frontend/src/metabase/css/core/index.css index 72fef96f02b2dada5a3147212e82bcb6d5a2ffc3..0ebe2be7f8c51f13b48816d835c6fc5f5505888e 100644 --- a/frontend/src/metabase/css/core/index.css +++ b/frontend/src/metabase/css/core/index.css @@ -1,3 +1,4 @@ +@import "./animation.css"; @import "./arrow.css"; @import "./base.css"; @import "./bordered.css"; diff --git a/frontend/src/metabase/css/core/overflow.css b/frontend/src/metabase/css/core/overflow.css index 5bc078735264ad1cfdd5a352bbc29a10e080c8f4..727a2161873a9798046c5634c83062797b3b565e 100644 --- a/frontend/src/metabase/css/core/overflow.css +++ b/frontend/src/metabase/css/core/overflow.css @@ -5,3 +5,11 @@ .overflow-hidden { overflow: hidden; } + +.overflow-x-hidden { + overflow-x: hidden; +} + +.overflow-y-hidden { + overflow-y: hidden; +} diff --git a/frontend/src/metabase/css/core/spacing.css b/frontend/src/metabase/css/core/spacing.css index 83ee93691e9d133e0a3a229bb2bc97777ed3f651..983d66acb7deb7c2484f7a00629642e8d5092c6f 100644 --- a/frontend/src/metabase/css/core/spacing.css +++ b/frontend/src/metabase/css/core/spacing.css @@ -22,6 +22,10 @@ margin-top: auto; } +.mb-auto { + margin-bottom: auto; +} + /* padding */ /* 0 */ diff --git a/frontend/src/metabase/css/dashboard.css b/frontend/src/metabase/css/dashboard.css index 98d17531dcf69b8752bd0a8f1f44f8a691f1b92a..94657d12f12c1ac2dffdae60a53d5206242cd164 100644 --- a/frontend/src/metabase/css/dashboard.css +++ b/frontend/src/metabase/css/dashboard.css @@ -3,6 +3,7 @@ } .Dashboard { background-color: #f9fbfc; + min-height: 100vh; } .DashboardHeader { diff --git a/frontend/src/metabase/css/home.css b/frontend/src/metabase/css/home.css index a083c96cac42dac73b0cc5cb1e5835a8f6d231c7..5ad6267f9e34a5066022f5cc1bdcb17766fe3a65 100644 --- a/frontend/src/metabase/css/home.css +++ b/frontend/src/metabase/css/home.css @@ -6,6 +6,10 @@ background-color: rgba(0, 0, 0, 0.2); } +.NavItem { + justify-content: center; +} + .NavItem > .Icon { padding-left: 1em; padding-right: 1em; diff --git a/frontend/src/metabase/dashboard/components/AddSeriesModal.jsx b/frontend/src/metabase/dashboard/components/AddSeriesModal.jsx index b693b408b92e96f9d678e2958092f166ada81fca..e7de3a340cf2f6b8fc05a11a8758c6f6316d87b2 100644 --- a/frontend/src/metabase/dashboard/components/AddSeriesModal.jsx +++ b/frontend/src/metabase/dashboard/components/AddSeriesModal.jsx @@ -6,7 +6,7 @@ import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.j import Icon from "metabase/components/Icon.jsx"; import Tooltip from "metabase/components/Tooltip.jsx"; import CheckBox from "metabase/components/CheckBox.jsx"; - +import { t } from "c-3po"; import MetabaseAnalytics from "metabase/lib/analytics"; import Query from "metabase/lib/query"; @@ -266,11 +266,11 @@ export default class AddSeriesModal extends Component { > {this.state.state === "loading" ? ( <div className="h3 rounded bordered p3 bg-white shadowed"> - Applying Question + {t`Applying Question`} </div> ) : this.state.state === "incompatible" ? ( <div className="h3 rounded bordered p3 bg-error border-error text-white"> - That question isn't compatible + {t`That question isn't compatible`} </div> ) : null} </div> @@ -278,14 +278,14 @@ export default class AddSeriesModal extends Component { </div> <div className="flex-no-shrink pl4 pb4 pt1"> <button className="Button Button--primary" onClick={this.onDone}> - Done + {t`Done`} </button> <button data-metabase-event={"Dashboard;Edit Series Modal;cancel"} className="Button ml2" onClick={this.props.onClose} > - Cancel + {t`Cancel`} </button> </div> </div> @@ -306,7 +306,7 @@ export default class AddSeriesModal extends Component { className="h4 input full pl1" style={{ border: "none", backgroundColor: "transparent" }} type="search" - placeholder="Search for a question" + placeholder={t`Search for a question`} onFocus={this.onSearchFocus} onChange={this.onSearchChange} /> @@ -334,7 +334,9 @@ export default class AddSeriesModal extends Component { </span> <span className="px1">{card.name}</span> {card.dataset_query.type !== "query" && ( - <Tooltip tooltip="We're not sure if this question is compatible"> + <Tooltip + tooltip={t`We're not sure if this question is compatible`} + > <Icon className="px1 flex-align-right text-grey-2 text-grey-4-hover cursor-pointer flex-no-shrink" name="warning" diff --git a/frontend/src/metabase/dashboard/components/ArchiveDashboardModal.jsx b/frontend/src/metabase/dashboard/components/ArchiveDashboardModal.jsx index 978ce608bf69c1aeb8cd616a73ef75061310676a..14eba601aa9cc936fb31efb1981fcd6132658655 100644 --- a/frontend/src/metabase/dashboard/components/ArchiveDashboardModal.jsx +++ b/frontend/src/metabase/dashboard/components/ArchiveDashboardModal.jsx @@ -1,6 +1,6 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; - +import { t } from "c-3po"; import ModalContent from "metabase/components/ModalContent.jsx"; export default class ArchiveDashboardModal extends Component { @@ -28,9 +28,9 @@ export default class ArchiveDashboardModal extends Component { } render() { - var formError; + let formError; if (this.state.error) { - var errorMessage = "Server error encountered"; + let errorMessage = "Server error encountered"; if (this.state.error.data && this.state.error.data.message) { errorMessage = this.state.error.data.message; } else { @@ -42,9 +42,9 @@ export default class ArchiveDashboardModal extends Component { } return ( - <ModalContent title="Archive Dashboard" onClose={this.props.onClose}> + <ModalContent title={t`Archive Dashboard`} onClose={this.props.onClose}> <div className="Form-inputs mb4"> - <p>Are you sure you want to do this?</p> + <p>{t`Are you sure you want to do this?`}</p> </div> <div className="Form-actions"> diff --git a/frontend/src/metabase/dashboard/components/DashCard.jsx b/frontend/src/metabase/dashboard/components/DashCard.jsx index 992be8f22d2086e03684c4758ca93a33e30ce8c6..51f5e1e7d548c65959c72c46c5d94458ccca1456 100644 --- a/frontend/src/metabase/dashboard/components/DashCard.jsx +++ b/frontend/src/metabase/dashboard/components/DashCard.jsx @@ -1,7 +1,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import ReactDOM from "react-dom"; - +import { t } from "c-3po"; import visualizations, { getVisualizationRaw } from "metabase/visualizations"; import Visualization, { ERROR_MESSAGE_GENERIC, @@ -108,6 +108,10 @@ export default class DashCard extends Component { errorIcon = "warning"; } + const hideBackground = + !isEditing && + mainCard.visualization_settings["dashcard.background"] === false; + return ( <div className={cx( @@ -117,6 +121,11 @@ export default class DashCard extends Component { "Card--slow": isSlow === "usually-slow", }, )} + style={ + hideBackground + ? { border: 0, background: "transparent", boxShadow: "none" } + : null + } > <Visualization className="flex-full" @@ -244,7 +253,7 @@ const AddSeriesButton = ({ series, onAddSeries }) => ( <Icon name={getSeriesIconName(series)} size={HEADER_ICON_SIZE} /> </span> <span className="flex-no-shrink text-bold"> - {series.length > 1 ? "Edit" : "Add"} + {series.length > 1 ? t`Edit` : t`Add`} </span> </span> </a> diff --git a/frontend/src/metabase/dashboard/components/DashCardParameterMapper.jsx b/frontend/src/metabase/dashboard/components/DashCardParameterMapper.jsx index 0ddcf72b7e9a250843dbe65217b9b8b6a3c99d20..465df150065b380bdb11f71a7821542b45cc4d0f 100644 --- a/frontend/src/metabase/dashboard/components/DashCardParameterMapper.jsx +++ b/frontend/src/metabase/dashboard/components/DashCardParameterMapper.jsx @@ -1,5 +1,5 @@ import React from "react"; - +import { t } from "c-3po"; import DashCardCardParameterMapper from "../containers/DashCardCardParameterMapper.jsx"; const DashCardParameterMapper = ({ dashcard }) => ( @@ -14,8 +14,7 @@ const DashCardParameterMapper = ({ dashcard }) => ( marginTop: -10, }} > - Make sure to make a selection for each series, or the filter won't - work on this card. + {t`Make sure to make a selection for each series, or the filter won't work on this card.`} </div> )} <div className="flex mx4 z1" style={{ justifyContent: "space-around" }}> diff --git a/frontend/src/metabase/dashboard/components/Dashboard.jsx b/frontend/src/metabase/dashboard/components/Dashboard.jsx index 91a07bc3b747542a4fb118d4d1a69ac6a3a5494d..b23786c59a39726c682e5970a30de427608e117e 100644 --- a/frontend/src/metabase/dashboard/components/Dashboard.jsx +++ b/frontend/src/metabase/dashboard/components/Dashboard.jsx @@ -1,12 +1,14 @@ /* @flow */ +// TODO: merge with metabase/dashboard/containers/Dashboard.jsx + import React, { Component } from "react"; import PropTypes from "prop-types"; import DashboardHeader from "../components/DashboardHeader.jsx"; import DashboardGrid from "../components/DashboardGrid.jsx"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx"; - +import { t } from "c-3po"; import Parameters from "metabase/parameters/components/Parameters.jsx"; import DashboardControls from "../hoc/DashboardControls"; @@ -279,10 +281,10 @@ export default class Dashboard extends Component { <div className="absolute z1 top bottom left right flex flex-column layout-centered"> <span className="QuestionCircle">?</span> <div className="text-normal mt3 mb1"> - This dashboard is looking empty. + {t`This dashboard is looking empty.`} </div> <div className="text-normal text-grey-2"> - Add a question to start making it useful! + {t`Add a question to start making it useful!`} </div> </div> ) : ( diff --git a/frontend/src/metabase/dashboard/components/DashboardActions.jsx b/frontend/src/metabase/dashboard/components/DashboardActions.jsx index 68ab04ab611e6d9fd0dca6f0ac707792aa6e84ec..f66bda30526b91ff61875505b15df5aac3a1c55f 100644 --- a/frontend/src/metabase/dashboard/components/DashboardActions.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardActions.jsx @@ -1,5 +1,5 @@ import React from "react"; - +import { t } from "c-3po"; import Tooltip from "metabase/components/Tooltip"; import NightModeIcon from "metabase/components/icons/NightModeIcon"; import FullscreenIcon from "metabase/components/icons/FullscreenIcon"; @@ -33,7 +33,7 @@ export const getDashboardActions = ({ if (!isEditing && isFullscreen) { buttons.push( - <Tooltip tooltip={isNightMode ? "Daytime mode" : "Nighttime mode"}> + <Tooltip tooltip={isNightMode ? t`Daytime mode` : t`Nighttime mode`}> <span data-metabase-event={"Dashboard;Night Mode;" + !isNightMode}> <NightModeIcon className="text-brand-hover cursor-pointer" @@ -49,7 +49,9 @@ export const getDashboardActions = ({ if (!isEditing && !isEmpty) { // option click to enter fullscreen without making the browser go fullscreen buttons.push( - <Tooltip tooltip={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}> + <Tooltip + tooltip={isFullscreen ? t`Exit fullscreen` : t`Enter fullscreen`} + > <span data-metabase-event={"Dashboard;Fullscreen Mode;" + !isFullscreen} > diff --git a/frontend/src/metabase/dashboard/components/DashboardGrid.jsx b/frontend/src/metabase/dashboard/components/DashboardGrid.jsx index 2ecc0ddae2206fa990d0720c438c563585c4699f..4690afc6a166f1ea643e6811cf0561ff381bc6e2 100644 --- a/frontend/src/metabase/dashboard/components/DashboardGrid.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardGrid.jsx @@ -23,6 +23,7 @@ import _ from "underscore"; import cx from "classnames"; const MOBILE_ASPECT_RATIO = 3 / 2; +const MOBILE_TEXT_CARD_ROW_HEIGHT = 40; @ExplicitSize export default class DashboardGrid extends Component { @@ -72,11 +73,11 @@ export default class DashboardGrid extends Component { } onLayoutChange(layout) { - var changes = layout.filter( + let changes = layout.filter( newLayout => !_.isEqual(newLayout, this.getLayoutForDashCard(newLayout.dashcard)), ); - for (var change of changes) { + for (let change of changes) { this.props.setDashCardAttributes({ id: change.dashcard.id, attributes: { @@ -251,7 +252,11 @@ export default class DashboardGrid extends Component { width: width, marginTop: 10, marginBottom: 10, - height: width / MOBILE_ASPECT_RATIO, + height: + // "text" cards should get a height based on their dc sizeY + dc.card.display === "text" + ? MOBILE_TEXT_CARD_ROW_HEIGHT * dc.sizeY + : width / MOBILE_ASPECT_RATIO, }} > {this.renderDashCard(dc, true)} diff --git a/frontend/src/metabase/dashboard/components/DashboardHeader.jsx b/frontend/src/metabase/dashboard/components/DashboardHeader.jsx index 771eb0f99993904f0ad6027ec8279a333871e7f8..6b78e4fd6a4ca700295fbeb0ecb3f40939a06d7c 100644 --- a/frontend/src/metabase/dashboard/components/DashboardHeader.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardHeader.jsx @@ -2,7 +2,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; - +import { t } from "c-3po"; import ActionButton from "metabase/components/ActionButton.jsx"; import AddToDashSelectQuestionModal from "./AddToDashSelectQuestionModal.jsx"; import ArchiveDashboardModal from "./ArchiveDashboardModal.jsx"; @@ -156,7 +156,7 @@ export default class DashboardHeader extends Component { className="Button Button--small" onClick={() => this.onCancel()} > - Cancel + {t`Cancel`} </a>, <ModalWithTrigger key="archive" @@ -174,10 +174,10 @@ export default class DashboardHeader extends Component { key="save" actionFn={() => this.onSave()} className="Button Button--small Button--primary" - normalText="Save" - activeText="Saving…" - failedText="Save failed" - successText="Saved" + normalText={t`Save`} + activeText={t`Saving…`} + failedText={t`Save failed`} + successText={t`Saved`} />, ]; } @@ -211,10 +211,10 @@ export default class DashboardHeader extends Component { key="add" ref="addQuestionModal" triggerElement={ - <Tooltip tooltip="Add a question"> + <Tooltip tooltip={t`Add a question`}> <span data-metabase-event="Dashboard;Add Card Modal" - title="Add a question to this dashboard" + title={t`Add a question to this dashboard`} > <Icon className={cx("text-brand-hover cursor-pointer", { @@ -243,13 +243,13 @@ export default class DashboardHeader extends Component { // Parameters buttons.push( <span> - <Tooltip tooltip="Add a filter"> + <Tooltip tooltip={t`Add a filter`}> <a key="parameters" className={cx("text-brand-hover", { "text-brand": this.state.modal == "parameters", })} - title="Parameters" + title={t`Parameters`} onClick={() => this.setState({ modal: "parameters" })} > <Icon name="funneladd" size={16} /> @@ -270,11 +270,11 @@ export default class DashboardHeader extends Component { // Add text card button buttons.push( - <Tooltip tooltip="Add a text box"> + <Tooltip tooltip={t`Add a text box`}> <a data-metabase-event="Dashboard;Add Text Box" key="add-text" - title="Add a text box" + title={t`Add a text box`} className="text-brand-hover cursor-pointer" onClick={() => this.onAddTextBox()} > @@ -284,7 +284,7 @@ export default class DashboardHeader extends Component { ); buttons.push( - <Tooltip tooltip="Revision history"> + <Tooltip tooltip={t`Revision history`}> <Link to={location.pathname + "/history"} data-metabase-event={"Dashboard;Revisions"} @@ -297,11 +297,11 @@ export default class DashboardHeader extends Component { if (!isFullscreen && !isEditing && canEdit) { buttons.push( - <Tooltip tooltip="Edit dashboard"> + <Tooltip tooltip={t`Edit dashboard`}> <a data-metabase-event="Dashboard;Edit" key="edit" - title="Edit Dashboard Layout" + title={t`Edit Dashboard Layout`} className="text-brand-hover cursor-pointer" onClick={() => this.onEdit()} > @@ -325,7 +325,7 @@ export default class DashboardHeader extends Component { } render() { - var { dashboard } = this.props; + let { dashboard } = this.props; return ( <Header @@ -335,12 +335,12 @@ export default class DashboardHeader extends Component { isEditing={this.props.isEditing} isEditingInfo={this.props.isEditing} headerButtons={this.getHeaderButtons()} - editingTitle="You are editing a dashboard" + editingTitle={t`You are editing a dashboard`} editingButtons={this.getEditingButtons()} setItemAttributeFn={this.props.setDashboardAttribute} headerModalMessage={ this.props.isEditingParameter - ? "Select the field that should be filtered for each card" + ? t`Select the field that should be filtered for each card` : null } onHeaderModalDone={() => this.props.setEditingParameter(null)} diff --git a/frontend/src/metabase/dashboard/components/ParametersPopover.jsx b/frontend/src/metabase/dashboard/components/ParametersPopover.jsx index e02847b468df7282a03577f6f0c58df601636e9f..d6e6ac31a0289bb65828b75fa813d2496aa5921d 100644 --- a/frontend/src/metabase/dashboard/components/ParametersPopover.jsx +++ b/frontend/src/metabase/dashboard/components/ParametersPopover.jsx @@ -1,6 +1,6 @@ /* @flow */ import React, { Component } from "react"; - +import { t } from "c-3po"; import { PARAMETER_SECTIONS } from "metabase/meta/Dashboard"; import type { Parameter, ParameterOption } from "metabase/meta/types/Parameter"; @@ -79,7 +79,7 @@ export const ParameterOptionsSectionsPane = ({ onSelectSection: ParameterSection => any, }) => ( <div className="pb2"> - <h3 className="p2">What do you want to filter?</h3> + <h3 className="p2">{t`What do you want to filter?`}</h3> <ul> {sections.map(section => ( <ParameterOptionsSection @@ -112,7 +112,7 @@ export const ParameterOptionsPane = ({ onSelectOption: ParameterOption => any, }) => ( <div className="pb2"> - <h3 className="p2">What kind of filter?</h3> + <h3 className="p2">{t`What kind of filter?`}</h3> <ul> {options && options.map(option => ( diff --git a/frontend/src/metabase/dashboard/components/RefreshWidget.jsx b/frontend/src/metabase/dashboard/components/RefreshWidget.jsx index f235b936acfe61e536fbf92b8e8babfd55389e4c..cf7dd012151b19fde531b309c39668e555636cdf 100644 --- a/frontend/src/metabase/dashboard/components/RefreshWidget.jsx +++ b/frontend/src/metabase/dashboard/components/RefreshWidget.jsx @@ -6,17 +6,17 @@ import Tooltip from "metabase/components/Tooltip.jsx"; import Icon from "metabase/components/Icon.jsx"; import ClockIcon from "metabase/components/icons/ClockIcon.jsx"; import CountdownIcon from "metabase/components/icons/CountdownIcon.jsx"; - +import { t } from "c-3po"; import cx from "classnames"; const OPTIONS = [ - { name: "Off", period: null }, - { name: "1 minute", period: 1 * 60 }, - { name: "5 minutes", period: 5 * 60 }, - { name: "10 minutes", period: 10 * 60 }, - { name: "15 minutes", period: 15 * 60 }, - { name: "30 minutes", period: 30 * 60 }, - { name: "60 minutes", period: 60 * 60 }, + { name: t`Off`, period: null }, + { name: t`1 minute`, period: 1 * 60 }, + { name: t`5 minutes`, period: 5 * 60 }, + { name: t`10 minutes`, period: 10 * 60 }, + { name: t`15 minutes`, period: 15 * 60 }, + { name: t`30 minutes`, period: 30 * 60 }, + { name: t`60 minutes`, period: 60 * 60 }, ]; export default class RefreshWidget extends Component { @@ -28,13 +28,14 @@ export default class RefreshWidget extends Component { ref="popover" triggerElement={ elapsed == null ? ( - <Tooltip tooltip="Auto-refresh"> + <Tooltip tooltip={t`Auto-refresh`}> <ClockIcon width={18} height={18} className={className} /> </Tooltip> ) : ( <Tooltip tooltip={ - "Refreshing in " + + t`Refreshing in` + + " " + Math.floor(remaining / 60) + ":" + (remaining % 60 < 10 ? "0" : "") + diff --git a/frontend/src/metabase/dashboard/components/RemoveFromDashboardModal.jsx b/frontend/src/metabase/dashboard/components/RemoveFromDashboardModal.jsx index 256c1a94a186050c5f5c23af0eaf6cee7e2f4373..bde58db7fc5381252339abad539285d77ffd003f 100644 --- a/frontend/src/metabase/dashboard/components/RemoveFromDashboardModal.jsx +++ b/frontend/src/metabase/dashboard/components/RemoveFromDashboardModal.jsx @@ -1,6 +1,6 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; - +import { t } from "c-3po"; import MetabaseAnalytics from "metabase/lib/analytics"; import ModalContent from "metabase/components/ModalContent.jsx"; @@ -34,18 +34,18 @@ export default class RemoveFromDashboardModal extends Component { render() { return ( <ModalContent - title="Remove this question?" + title={t`Remove this question?`} onClose={() => this.props.onClose()} > <div className="Form-actions flex-align-right"> <button className="Button Button" onClick={this.props.onClose}> - Cancel + {t`Cancel`} </button> <button className="Button Button--danger ml2" onClick={() => this.onRemove()} > - Remove + {t`Remove`} </button> </div> </ModalContent> diff --git a/frontend/src/metabase/dashboard/containers/AutomaticDashboardApp.jsx b/frontend/src/metabase/dashboard/containers/AutomaticDashboardApp.jsx new file mode 100644 index 0000000000000000000000000000000000000000..7081651f5f18ee8d098c2fe50f6d6a7ed1b6e99c --- /dev/null +++ b/frontend/src/metabase/dashboard/containers/AutomaticDashboardApp.jsx @@ -0,0 +1,257 @@ +/* @flow weak */ + +import React from "react"; + +import { connect } from "react-redux"; +import { Link } from "react-router"; + +import { withBackground } from "metabase/hoc/Background"; +import title from "metabase/hoc/Title"; +import ActionButton from "metabase/components/ActionButton"; +import Button from "metabase/components/Button"; +import Icon from "metabase/components/Icon"; + +import cxs from "cxs"; +import { t } from "c-3po"; +import _ from "underscore"; + +import { Dashboard } from "metabase/dashboard/containers/Dashboard"; +import DashboardData from "metabase/dashboard/hoc/DashboardData"; +import Parameters from "metabase/parameters/components/Parameters"; + +import { addUndo, createUndo } from "metabase/redux/undo"; + +import { getMetadata } from "metabase/selectors/metadata"; +import { getUserIsAdmin } from "metabase/selectors/user"; + +import { DashboardApi } from "metabase/services"; +import * as Urls from "metabase/lib/urls"; +import MetabaseAnalytics from "metabase/lib/analytics"; + +import { getParameterIconName } from "metabase/meta/Parameter"; + +import { dissoc } from "icepick"; + +const getDashboardId = (state, { params: { splat }, location: { hash } }) => + `/auto/dashboard/${splat}${hash.replace(/^#?/, "?")}`; + +const mapStateToProps = (state, props) => ({ + isAdmin: getUserIsAdmin(state), + metadata: getMetadata(state), + dashboardId: getDashboardId(state, props), +}); + +@connect(mapStateToProps, { addUndo, createUndo }) +@DashboardData +@title(({ dashboard }) => dashboard && dashboard.name) +class AutomaticDashboardApp extends React.Component { + state = { + savedDashboardId: null, + }; + + componentDidUpdate(prevProps) { + // scroll to the top when the pathname changes + if (prevProps.location.pathname !== this.props.location.pathname) { + window.scrollTo(0, 0); + } + } + + save = async () => { + const { dashboard, addUndo, createUndo } = this.props; + // remove the transient id before trying to save + const newDashboard = await DashboardApi.save(dissoc(dashboard, "id")); + addUndo( + createUndo({ + type: "metabase/automatic-dashboards/link-to-created-object", + message: () => ( + <div className="flex align-center"> + <Icon name="dashboard" size={22} className="mr2" color="#93A1AB" /> + {t`Your dashboard was saved`} + <Link + className="link text-bold ml1" + to={Urls.dashboard(newDashboard.id)} + > + {t`See it`} + </Link> + </div> + ), + action: null, + }), + ); + this.setState({ savedDashboardId: newDashboard.id }); + MetabaseAnalytics.trackEvent("AutoDashboard", "Save"); + }; + + render() { + const { + dashboard, + parameters, + parameterValues, + setParameterValue, + location, + isAdmin, + } = this.props; + const { savedDashboardId } = this.state; + // pull out "more" related items for displaying as a button at the bottom of the dashboard + const more = dashboard && dashboard.related && dashboard.related["more"]; + const related = dashboard && _.omit(dashboard.related, "more"); + const hasSidebar = _.any(related || {}, list => list.length > 0); + + return ( + <div className="relative"> + <div className="" style={{ marginRight: hasSidebar ? 346 : undefined }}> + <div className="bg-white border-bottom py2"> + <div className="wrapper flex align-center"> + <Icon name="bolt" className="text-gold mr2" size={24} /> + <div> + <h2>{dashboard && <TransientTitle dashboard={dashboard} />}</h2> + {dashboard && + dashboard.transient_filters && + dashboard.transient_filters.length > 0 && ( + <TransientFilters filters={dashboard.transient_filters} /> + )} + </div> + {savedDashboardId != null ? ( + <Button className="ml-auto" disabled>{t`Saved`}</Button> + ) : isAdmin ? ( + <ActionButton + className="ml-auto" + success + borderless + actionFn={this.save} + > + {t`Save this`} + </ActionButton> + ) : null} + </div> + </div> + + <div className="wrapper pb4"> + {parameters && + parameters.length > 0 && ( + <div className="px1 pt1"> + <Parameters + parameters={parameters.map(p => ({ + ...p, + value: parameterValues && parameterValues[p.id], + }))} + query={location.query} + setParameterValue={setParameterValue} + syncQueryString + isQB + /> + </div> + )} + <Dashboard {...this.props} /> + </div> + {more && ( + <div className="flex justify-end px4 pb4"> + {more.map(item => ( + <Link + to={item.url} + className="ml2" + onClick={() => + MetabaseAnalytics.trackEvent("AutoDashboard", "ClickMore") + } + > + <Button iconRight="chevronright">{item.title}</Button> + </Link> + ))} + </div> + )} + </div> + {hasSidebar && ( + <div className="Layout-sidebar absolute top right bottom"> + <SuggestionsSidebar related={related} /> + </div> + )} + </div> + ); + } +} + +const TransientTitle = ({ dashboard }) => + dashboard.transient_name ? ( + <span>{dashboard.transient_name}</span> + ) : dashboard.name ? ( + <span>{dashboard.name}</span> + ) : null; + +const TransientFilters = ({ filters }) => ( + <div className="mt1 flex align-center text-grey-4 text-bold"> + {filters.map((filter, index) => ( + <TransientFilter key={index} filter={filter} /> + ))} + </div> +); + +const TransientFilter = ({ filter }) => ( + <div className="mr3"> + <Icon name={getParameterIconName(filter.type)} size={12} className="mr1" /> + {filter.field.map((str, index) => [ + <span key={"name" + index}>{str}</span>, + index !== filter.field.length - 1 ? ( + <Icon + key={"icon" + index} + size={10} + style={{ marginLeft: 3, marginRight: 3 }} + name="connections" + /> + ) : null, + ])} + <span> {filter.value}</span> + </div> +); + +const suggestionClasses = cxs({ + ":hover h3": { + color: "#509ee3", + }, + ":hover .Icon": { + color: "#F9D45C", + }, +}); + +const SuggestionsList = ({ suggestions, section }) => ( + <ol className="px2"> + {suggestions.map((s, i) => ( + <li key={i} className={suggestionClasses}> + <Link + to={s.url} + className="bordered rounded bg-white shadowed mb2 p2 flex no-decoration" + onClick={() => + MetabaseAnalytics.trackEvent( + "AutoDashboard", + "ClickRelated", + section, + ) + } + > + <div + className="bg-slate-extra-light rounded flex align-center justify-center text-slate mr1 flex-no-shrink" + style={{ width: 48, height: 48 }} + > + <Icon name="bolt" className="Icon text-grey-1" size={22} /> + </div> + <div> + <h3 className="m0 mb1 ml1">{s.title}</h3> + <p className="text-grey-4 ml1 mt0 mb0">{s.description}</p> + </div> + </Link> + </li> + ))} + </ol> +); + +const SuggestionsSidebar = ({ related }) => ( + <div className="flex flex-column bg-slate-almost-extra-light full-height"> + <div className="py2 text-centered my3"> + <h3 className="text-grey-3">More X-rays</h3> + </div> + {Object.entries(related).map(([section, suggestions]) => ( + <SuggestionsList section={section} suggestions={suggestions} /> + ))} + </div> +); + +export default withBackground("bg-slate-extra-light")(AutomaticDashboardApp); diff --git a/frontend/src/metabase/dashboard/containers/DashCardCardParameterMapper.css b/frontend/src/metabase/dashboard/containers/DashCardCardParameterMapper.css index 3356b83a489adc902727c0339c247689a3380f8b..bba6c6bc54dac14259edf53c1335bfade0f45110 100644 --- a/frontend/src/metabase/dashboard/containers/DashCardCardParameterMapper.css +++ b/frontend/src/metabase/dashboard/containers/DashCardCardParameterMapper.css @@ -15,8 +15,8 @@ } :local(.warn) { - border-color: var(--warning-color) !important; - color: var(--warning-color) !important; + border-color: var(--error-color) !important; + color: var(--error-color) !important; } :local(.disabled) { diff --git a/frontend/src/metabase/dashboard/containers/DashCardCardParameterMapper.jsx b/frontend/src/metabase/dashboard/containers/DashCardCardParameterMapper.jsx index 12189bdd02d72efa84e9a9396c8d6d37d4ae2a62..b2285e5807d4d4ad0c6501bc4b1014e74c9140f5 100644 --- a/frontend/src/metabase/dashboard/containers/DashCardCardParameterMapper.jsx +++ b/frontend/src/metabase/dashboard/containers/DashCardCardParameterMapper.jsx @@ -3,7 +3,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { connect } from "react-redux"; - +import { t } from "c-3po"; import S from "./DashCardCardParameterMapper.css"; import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx"; @@ -137,11 +137,9 @@ export default class DashCardCardParameterMapper extends Component { let tooltipText = null; if (disabled) { - tooltipText = - "This card doesn't have any fields or parameters that can be mapped to this parameter type."; + tooltipText = t`This card doesn't have any fields or parameters that can be mapped to this parameter type.`; } else if (noOverlap) { - tooltipText = - "The values in this field don't overlap with the values of any other fields you've chosen."; + tooltipText = t`The values in this field don't overlap with the values of any other fields you've chosen.`; } return ( @@ -185,8 +183,8 @@ export default class DashCardCardParameterMapper extends Component { > <span className="text-centered mr1"> {disabled - ? "No valid fields" - : selected ? selected.name : "Select…"} + ? t`No valid fields` + : selected ? selected.name : t`Select…`} </span> {selected ? ( <Icon diff --git a/frontend/src/metabase/dashboard/containers/Dashboard.jsx b/frontend/src/metabase/dashboard/containers/Dashboard.jsx new file mode 100644 index 0000000000000000000000000000000000000000..14608446e49042ba2d2bb70be87869a0faf8bb60 --- /dev/null +++ b/frontend/src/metabase/dashboard/containers/Dashboard.jsx @@ -0,0 +1,56 @@ +/* @flow */ + +// TODO: merge with metabase/dashboard/components/Dashboard.jsx + +import React, { Component } from "react"; +import cx from "classnames"; + +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; +import DashboardGrid from "metabase/dashboard/components/DashboardGrid"; +import DashboardData from "metabase/dashboard/hoc/DashboardData"; + +import type { Dashboard as _Dashboard } from "metabase/meta/types/Dashboard"; +import type { Parameter } from "metabase/meta/types/Parameter"; + +type Props = { + location?: { query: { [key: string]: string } }, + dashboardId: string, + + dashboard?: _Dashboard, + parameters: Parameter[], + parameterValues: { [key: string]: string }, + + initialize: () => void, + isFullscreen: boolean, + isNightMode: boolean, + fetchDashboard: ( + dashId: string, + query?: { [key: string]: string }, + ) => Promise<void>, + fetchDashboardCardData: (options: { + reload: boolean, + clear: boolean, + }) => Promise<void>, + setParameterValue: (id: string, value: string) => void, + setErrorPage: (error: { status: number }) => void, +}; + +export class Dashboard extends Component { + props: Props; + + render() { + const { dashboard } = this.props; + + return ( + <LoadingAndErrorWrapper + className={cx("Dashboard p1 flex-full")} + loading={!dashboard} + noBackground + > + {() => <DashboardGrid {...this.props} className={"spread"} />} + </LoadingAndErrorWrapper> + ); + } +} + +export default DashboardData(Dashboard); diff --git a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx index f7d6b7d66642368cb35145c005268c9859f41f5c..abbcd61efe743a67cd8125bd68c5e674cecbce9d 100644 --- a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx +++ b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx @@ -5,7 +5,7 @@ import { connect } from "react-redux"; import { push } from "react-router-redux"; import title from "metabase/hoc/Title"; -import Dashboard from "../components/Dashboard.jsx"; +import Dashboard from "metabase/dashboard/components/Dashboard.jsx"; import { fetchDatabaseMetadata } from "metabase/redux/metadata"; import { setErrorPage } from "metabase/redux/app"; @@ -32,7 +32,7 @@ import { parseHashOptions } from "metabase/lib/browser"; const mapStateToProps = (state, props) => { return { - dashboardId: props.params.dashboardId, + dashboardId: props.dashboardId || props.params.dashboardId, isAdmin: getUserIsAdmin(state, props), isEditing: getIsEditing(state, props), @@ -80,7 +80,7 @@ export default class DashboardApp extends Component { render() { return ( <div> - <Dashboard addCardOnLoad={this.state.addCardOnLoad} {...this.props} />; + <Dashboard addCardOnLoad={this.state.addCardOnLoad} {...this.props} /> {/* For rendering modal urls */} {this.props.children} </div> diff --git a/frontend/src/metabase/dashboard/dashboard.js b/frontend/src/metabase/dashboard/dashboard.js index 1aa7a42c607c8f7a6a6d0317cb5f406c5860d923..91f28f07fb32c3573a4d491660a3c0e168f609b1 100644 --- a/frontend/src/metabase/dashboard/dashboard.js +++ b/frontend/src/metabase/dashboard/dashboard.js @@ -33,7 +33,11 @@ import Utils from "metabase/lib/utils"; import { getPositionForNewDashCard } from "metabase/lib/dashboard_grid"; import { createCard } from "metabase/lib/card"; -import { addParamValues, fetchDatabaseMetadata } from "metabase/redux/metadata"; +import { + addParamValues, + addFields, + fetchDatabaseMetadata, +} from "metabase/redux/metadata"; import { push } from "react-router-redux"; import { @@ -42,6 +46,8 @@ import { RevisionApi, PublicApi, EmbedApi, + AutoApi, + MetabaseApi, } from "metabase/services"; import { getDashboard, getDashboardComplete } from "./selectors"; @@ -108,6 +114,8 @@ function getDashboardType(id) { return "public"; } else if (Utils.isJWT(id)) { return "embed"; + } else if (/\/auto\/dashboard/.test(id)) { + return "transient"; } else { return "normal"; } @@ -130,7 +138,7 @@ export const fetchCards = createThunkAction(FETCH_CARDS, function( ) { return async function(dispatch, getState) { let cards = await CardApi.list({ f: filterMode }); - for (var c of cards) { + for (let c of cards) { c.updated_at = moment(c.updated_at); } return normalize(cards, [card]); @@ -464,6 +472,8 @@ export const fetchCardData = createThunkAction(FETCH_CARD_DATA, function( ...getParametersBySlug(dashboard.parameters, parameterValues), }), ); + } else if (dashboardType === "transient") { + result = await fetchDataOrError(MetabaseApi.dataset(datasetQuery)); } else { result = await fetchDataOrError( CardApi.query({ cardId: card.id, parameters: datasetQuery.parameters }), @@ -513,6 +523,20 @@ export const fetchDashboard = createThunkAction(FETCH_DASHBOARD, function( dashboard_id: dashId, })), }; + } else if (dashboardType === "transient") { + const subPath = dashId + .split("/") + .slice(3) + .join("/"); + result = await AutoApi.dashboard({ subPath }); + result = { + ...result, + id: dashId, + ordered_cards: result.ordered_cards.map(dc => ({ + ...dc, + dashboard_id: dashId, + })), + }; } else { result = await DashboardApi.get({ dashId: dashId }); } @@ -528,7 +552,7 @@ export const fetchDashboard = createThunkAction(FETCH_DASHBOARD, function( } } - if (dashboardType === "normal") { + if (dashboardType === "normal" || dashboardType === "transient") { // fetch database metadata for every card _.chain(result.ordered_cards) .map(dc => [dc.card].concat(dc.series)) @@ -544,13 +568,19 @@ export const fetchDashboard = createThunkAction(FETCH_DASHBOARD, function( // copy over any virtual cards from the dashcard to the underlying card/question result.ordered_cards.forEach(card => { if (card.visualization_settings.virtual_card) { - _.extend(card.card, card.visualization_settings.virtual_card); + card.card = Object.assign( + card.card || {}, + card.visualization_settings.virtual_card, + ); } }); if (result.param_values) { dispatch(addParamValues(result.param_values)); } + if (result.param_fields) { + dispatch(addFields(result.param_fields)); + } return { ...normalize(result, dashboard), // includes `result` and `entities` diff --git a/frontend/src/metabase/dashboard/hoc/DashboardData.jsx b/frontend/src/metabase/dashboard/hoc/DashboardData.jsx new file mode 100644 index 0000000000000000000000000000000000000000..7e68eb19cf2b00aae1faffeab97be7956d1610e8 --- /dev/null +++ b/frontend/src/metabase/dashboard/hoc/DashboardData.jsx @@ -0,0 +1,108 @@ +/* @flow */ + +import React, { Component } from "react"; +import { connect } from "react-redux"; +import { push } from "react-router-redux"; + +import { fetchDatabaseMetadata } from "metabase/redux/metadata"; +import { setErrorPage } from "metabase/redux/app"; + +import { + getDashboardComplete, + getCardData, + getSlowCards, + getParameters, + getParameterValues, +} from "metabase/dashboard/selectors"; + +import * as dashboardActions from "metabase/dashboard/dashboard"; + +import type { Dashboard } from "metabase/meta/types/Dashboard"; +import type { Parameter } from "metabase/meta/types/Parameter"; + +import _ from "underscore"; + +const mapStateToProps = (state, props) => { + return { + dashboard: getDashboardComplete(state, props), + dashcardData: getCardData(state, props), + slowCards: getSlowCards(state, props), + parameters: getParameters(state, props), + parameterValues: getParameterValues(state, props), + }; +}; + +const mapDispatchToProps = { + ...dashboardActions, + fetchDatabaseMetadata, + setErrorPage, + onChangeLocation: push, +}; + +type Props = { + location?: { query: { [key: string]: string } }, + dashboardId: string, + + dashboard?: Dashboard, + parameters: Parameter[], + parameterValues: { [key: string]: string }, + + initialize: () => void, + isFullscreen: boolean, + isNightMode: boolean, + fetchDashboard: ( + dashId: string, + query?: { [key: string]: string }, + ) => Promise<void>, + fetchDashboardCardData: (options: { + reload: boolean, + clear: boolean, + }) => Promise<void>, + setParameterValue: (id: string, value: string) => void, + setErrorPage: (error: { status: number }) => void, +}; + +export default (ComposedComponent: ReactClass<any>) => + connect(mapStateToProps, mapDispatchToProps)( + class DashboardContainer extends Component { + props: Props; + + async load(props) { + const { + initialize, + fetchDashboard, + fetchDashboardCardData, + setErrorPage, + location, + dashboardId, + } = props; + + initialize(); + try { + await fetchDashboard(dashboardId, location && location.query); + await fetchDashboardCardData({ reload: false, clear: true }); + } catch (error) { + console.error(error); + setErrorPage(error); + } + } + + componentWillMount() { + this.load(this.props); + } + + componentWillReceiveProps(nextProps: Props) { + if (nextProps.dashboardId !== this.props.dashboardId) { + this.load(nextProps); + } else if ( + !_.isEqual(this.props.parameterValues, nextProps.parameterValues) + ) { + this.props.fetchDashboardCardData({ reload: false, clear: true }); + } + } + + render() { + return <ComposedComponent {...this.props} />; + } + }, + ); diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index 9e398cd85bfa8062e81a891e954eaa72d61a45e5..d6671c9640d68f42246cae5deb94c42484d15697 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -219,8 +219,7 @@ export class Dashboards extends Component { message={ <div className="mt4"> <h3 className="text-grey-5">{t`No results found`}</h3> - <p className="text-grey-4">{t`Try adjusting your filter to find what you’re - looking for.`}</p> + <p className="text-grey-4">{t`Try adjusting your filter to find what you’re looking for.`}</p> </div> } image="/app/img/empty_dashboard" diff --git a/frontend/src/metabase/home/actions.js b/frontend/src/metabase/home/actions.js index 1d40a0aa69d97de437f06e291d4ae7611a9feccd..9a810a2b027ec8866b2428e1b8536b018bf74c50 100644 --- a/frontend/src/metabase/home/actions.js +++ b/frontend/src/metabase/home/actions.js @@ -14,7 +14,7 @@ export const FETCH_RECENT_VIEWS = "FETCH_RECENT_VIEWS"; export const fetchActivity = createThunkAction(FETCH_ACTIVITY, function() { return async function(dispatch, getState) { let activity = await ActivityApi.list(); - for (var ai of activity) { + for (let ai of activity) { ai.timestamp = moment(ai.timestamp); ai.hasLinkableModel = function() { return _.contains(["card", "dashboard"], this.model); @@ -29,7 +29,7 @@ export const fetchRecentViews = createThunkAction( function() { return async function(dispatch, getState) { let recentViews = await ActivityApi.recent_views(); - for (var v of recentViews) { + for (let v of recentViews) { v.timestamp = moment(v.timestamp); } return recentViews; diff --git a/frontend/src/metabase/home/components/Activity.jsx b/frontend/src/metabase/home/components/Activity.jsx index 355c2f8704c8bece7d32b74c093b60abbe608d94..22ae894fea0d37f8df77506ce66d43da2eb605b5 100644 --- a/frontend/src/metabase/home/components/Activity.jsx +++ b/frontend/src/metabase/home/components/Activity.jsx @@ -48,11 +48,11 @@ export default class Activity extends Component { const maxColorUsed = _.isEmpty(userColors) ? 0 : _.max(_.values(userColors)); - var currColor = + let currColor = maxColorUsed && maxColorUsed < colors.length ? maxColorUsed : 0; if (user && activity) { - for (var item of activity) { + for (let item of activity) { if (!(item.user_id in userColors)) { // assign the user a color if (item.user_id === user.id) { @@ -89,7 +89,7 @@ export default class Activity extends Component { // this is a base to start with const description = { userName: this.userName(item.user, user), - summary: t`did some super awesome stuff thats hard to describe`, + summary: t`did some super awesome stuff that's hard to describe`, timeSince: item.timestamp.fromNow(), }; @@ -138,7 +138,7 @@ export default class Activity extends Component { } else { description.summary = ( <span> - {t`deleted an alert about- `} + {t`deleted an alert about - `} <span className="text-dark">{item.details.name}</span> </span> ); @@ -345,7 +345,7 @@ export default class Activity extends Component { if (item.model_exists) { description.summary = ( <span> - {t`added the filter `} + {t`added the filter`}{" "} <Link to={Urls.tableRowsQuery( item.database_id, @@ -386,7 +386,7 @@ export default class Activity extends Component { if (item.model_exists) { description.summary = ( <span> - {t`made changes to the filter `} + {t`made changes to the filter`}{" "} <Link to={Urls.tableRowsQuery( item.database_id, diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js index fc929f165a8b8d535931c00d229f842947e98522..69c49dc0082d59b0896199c78a339fad9bbba816 100644 --- a/frontend/src/metabase/icon_paths.js +++ b/frontend/src/metabase/icon_paths.js @@ -7,7 +7,7 @@ These paths represent the current canonical icon set for Metabase. */ -export var ICON_PATHS = { +export const ICON_PATHS = { add: "M19,13 L19,2 L14,2 L14,13 L2,13 L2,18 L14,18 L14,30 L19,30 L19,18 L30,18 L30,13 L19,13 Z", addtodash: { @@ -45,6 +45,7 @@ export var ICON_PATHS = { "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", + bolt: "M21.697 0L8 16.809l7.549 2.538L11.687 32l12.652-16.6-7.695-2.317z", 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: @@ -128,22 +129,6 @@ export var ICON_PATHS = { }, embed: "M12.734 9.333L6.099 16l6.635 6.667a2.547 2.547 0 0 1 0 3.59 2.518 2.518 0 0 1-3.573 0L.74 17.795a2.547 2.547 0 0 1 0-3.59L9.16 5.743a2.518 2.518 0 0 1 3.573 0 2.547 2.547 0 0 1 0 3.59zm6.527 13.339l6.64-6.71-6.63-6.623a2.547 2.547 0 0 1-.01-3.59 2.518 2.518 0 0 1 3.573-.01l8.42 8.412c.99.988.995 2.596.011 3.59l-8.42 8.51a2.518 2.518 0 0 1-3.574.01 2.547 2.547 0 0 1-.01-3.59z", - emojiactivity: - "M4.58360576,27.9163942 C7.44354038,30.7763289 17.5452495,30.2077351 24.1264923,23.6264923 C30.7077351,17.0452495 31.2763289,6.94354038 28.4163942,4.08360576 C25.5564596,1.22367115 15.4547505,1.79226488 8.87350769,8.37350769 C2.29226488,14.9547505 1.72367115,25.0564596 4.58360576,27.9163942 Z M18.0478143,6.51491123 C17.0327353,6.95492647 16.0168474,7.462507 15.0611336,8.03487206 C13.9504884,8.70002358 12.9907793,9.41185633 12.2241295,10.1785061 C11.6753609,10.7272747 11.1524326,11.3411471 10.6544469,12.0086598 C9.95174829,12.950575 9.33131183,13.9528185 8.79588947,14.9547475 C8.47309082,15.5587964 8.24755055,16.0346972 8.12501633,16.3206104 L10.6033735,17.3827634 C10.6970997,17.1640688 10.8900635,16.7569059 11.1739957,16.2255873 C11.6497914,15.335237 12.2007659,14.4452012 12.8156543,13.6209891 C13.2395954,13.0527276 13.6791325,12.5367492 14.1307526,12.0851292 C14.7228522,11.4930296 15.5113715,10.9081712 16.4465129,10.3481268 C17.2918299,9.84187694 18.205618,9.38530978 19.1202149,8.98885142 C19.6674377,8.75164195 20.0881481,8.58915552 20.3167167,8.50897949 L19.4242138,5.96460106 C19.1382021,6.06492664 18.6583971,6.25023653 18.0478143,6.51491123 Z", - emojifood: - "M14.9166667,8.71296296 L14.9166667,4.85648148 L14.9636504,4.84865086 L18.8123012,1 L21.149682,3.33738075 L18.2222222,6.26484052 L18.2222222,8.71296296 L27.037037,8.71296296 L27.037037,10.9166667 L24.6968811,10.9166667 L22.2407407,30.75 L10.3148148,30.75 L7.36744639,10.9166667 L5,10.9166667 L5,8.71296296 L14.9166667,8.71296296 Z", - emojiflags: - "M14.4,17.8888889 L7.9,17.8888889 L7.9,28.9485494 C7.9,30.0201693 7.0344636,30.8888889 5.95,30.8888889 C4.87304474,30.8888889 4,30.0243018 4,28.9485494 L4,2.99442095 C4,2.44521742 4.44737959,2 5.00434691,2 L7.25,2 L19.1921631,2 C20.8138216,2 22.135741,3.27793211 22.1977269,4.88888889 L29.0004187,4.88888889 C29.5524722,4.88888889 30,5.33043204 30,5.88281005 L30,17.7705198 C30,19.4313825 28.6564509,20.7777778 26.9921631,20.7777778 L14.4,20.7777778 L14.4,17.8888889 Z", - emojinature: - "M19.0364085,24.2897898 L29.4593537,24.2897898 C30.0142087,24.2897898 30.2190588,23.9075249 29.9310364,23.4359772 L17.0209996,2.29978531 C16.7300287,1.82341061 16.2660004,1.82823762 15.9779779,2.29978531 L3.06794114,23.4359772 C2.77697033,23.9123519 2.9910982,24.2897898 3.53962381,24.2897898 L13.962569,24.2897898 L13.962569,31.5582771 L16.5093674,31.5582771 L19.0364085,31.5582771 L19.0364085,24.2897898 Z", - mojiobjects: - "M10.4444444,25.2307692 L10.4444444,20.0205203 C7.76447613,18.1576095 6,14.9850955 6,11.3846154 C6,5.64935068 10.4771525,1 16,1 C21.5228475,1 26,5.64935068 26,11.3846154 C26,14.9850955 24.2355239,18.1576095 21.5555556,20.0205203 L21.5555556,25.2307692 C21.5555556,28.4170274 19.0682486,31 16,31 C12.9317514,31 10.4444444,28.4170274 10.4444444,25.2307692 Z", - emojipeople: - "M16,31 C24.2842712,31 31,24.2842712 31,16 C31,7.71572875 24.2842712,1 16,1 C7.71572875,1 1,7.71572875 1,16 C1,24.2842712 7.71572875,31 16,31 Z M22.9642857,19.2142857 C22.9642857,22.3818012 19.8462688,24.9495798 16,24.9495798 C12.1537312,24.9495798 9.03571429,22.3818012 9.03571429,19.2142857 L22.9642857,19.2142857 Z M19.75,14.9285714 C20.6376005,14.9285714 21.3571429,13.2496392 21.3571429,11.1785714 C21.3571429,9.10750362 20.6376005,7.42857143 19.75,7.42857143 C18.8623995,7.42857143 18.1428571,9.10750362 18.1428571,11.1785714 C18.1428571,13.2496392 18.8623995,14.9285714 19.75,14.9285714 Z M12.25,14.9285714 C13.1376005,14.9285714 13.8571429,13.2496392 13.8571429,11.1785714 C13.8571429,9.10750362 13.1376005,7.42857143 12.25,7.42857143 C11.3623995,7.42857143 10.6428571,9.10750362 10.6428571,11.1785714 C10.6428571,13.2496392 11.3623995,14.9285714 12.25,14.9285714 Z", - emojisymbols: - "M15.915,6.8426232 L13.8540602,4.78168347 C10.8078936,1.73551684 5.86858639,1.73495294 2.82246208,4.78107725 C-0.217463984,7.82100332 -0.223390824,12.7662163 2.82306829,15.8126754 L15.6919527,28.6815598 L15.915,28.4585125 L16.1380473,28.6815598 L29.0069316,15.8126754 C32.0533907,12.7662163 32.0474639,7.82100332 29.0075378,4.78107725 C25.9614135,1.73495294 21.0221063,1.73551684 17.9759397,4.78168347 L15.915,6.8426232 Z", - emojitravel: - "M21.5273864,25.0150018 L21.5273864,1.91741484 C21.5273864,0.0857778237 20.049926,-1.38499821 18.2273864,-1.38499821 C16.4085552,-1.38499821 14.9273864,0.0935424793 14.9273864,1.91741484 L14.9273864,25.0150018 L11.6273864,28.3150018 L24.8273864,28.3150018 L21.5273864,25.0150018 Z M1.72738636,18.4150018 L14.9273864,5.21500179 L14.9273864,15.7750018 L1.72738636,23.6950018 L1.72738636,18.4150018 Z M34.7273864,18.4150018 L21.5273864,5.21500179 L21.5273864,15.7750018 L34.7273864,23.6950018 L34.7273864,18.4150018 Z", empty: " ", enterorreturn: "M6.81 16.784l6.14-4.694a1.789 1.789 0 0 0 .341-2.49 1.748 1.748 0 0 0-2.464-.344L.697 17a1.788 1.788 0 0 0-.01 2.826l10.058 7.806c.77.598 1.875.452 2.467-.326a1.79 1.79 0 0 0-.323-2.492l-5.766-4.475h23.118c.971 0 1.759-.796 1.759-1.777V6.777C32 5.796 31.212 5 30.24 5c-.971 0-1.759.796-1.759 1.777v10.007H6.811z", @@ -343,11 +328,6 @@ export var ICON_PATHS = { attrs: { fillRule: "evenodd" }, }, warning: { - svg: - '<g fill="currentcolor" fill-rule="evenodd"><path d="M10.9665007,4.7224988 C11.5372866,3.77118898 12.455761,3.75960159 13.0334993,4.7224988 L22.9665007,21.2775012 C23.5372866,22.228811 23.1029738,23 21.9950534,23 L2.00494659,23 C0.897645164,23 0.455760956,22.2403984 1.03349928,21.2775012 L10.9665007,4.7224988 Z M13.0184348,11.258 L13.0184348,14.69 C13.0184348,15.0580018 12.996435,15.4229982 12.9524348,15.785 C12.9084346,16.1470018 12.8504351,16.5159981 12.7784348,16.892 L11.5184348,16.892 C11.4464344,16.5159981 11.388435,16.1470018 11.3444348,15.785 C11.3004346,15.4229982 11.2784348,15.0580018 11.2784348,14.69 L11.2784348,11.258 L13.0184348,11.258 Z M11.0744348,19.058 C11.0744348,18.9139993 11.1014345,18.7800006 11.1554348,18.656 C11.2094351,18.5319994 11.2834343,18.4240005 11.3774348,18.332 C11.4714353,18.2399995 11.5824341,18.1670003 11.7104348,18.113 C11.8384354,18.0589997 11.978434,18.032 12.1304348,18.032 C12.2784355,18.032 12.4164341,18.0589997 12.5444348,18.113 C12.6724354,18.1670003 12.7844343,18.2399995 12.8804348,18.332 C12.9764353,18.4240005 13.0514345,18.5319994 13.1054348,18.656 C13.1594351,18.7800006 13.1864348,18.9139993 13.1864348,19.058 C13.1864348,19.2020007 13.1594351,19.3369994 13.1054348,19.463 C13.0514345,19.5890006 12.9764353,19.6979995 12.8804348,19.79 C12.7844343,19.8820005 12.6724354,19.9539997 12.5444348,20.006 C12.4164341,20.0580003 12.2784355,20.084 12.1304348,20.084 C11.978434,20.084 11.8384354,20.0580003 11.7104348,20.006 C11.5824341,19.9539997 11.4714353,19.8820005 11.3774348,19.79 C11.2834343,19.6979995 11.2094351,19.5890006 11.1554348,19.463 C11.1014345,19.3369994 11.0744348,19.2020007 11.0744348,19.058 Z"></path></g>', - attrs: { viewBox: "0 0 23 23" }, - }, - warning2: { path: "M12.3069589,4.52260192 C14.3462632,1.2440969 17.653446,1.24541073 19.691933,4.52260192 L31.2249413,23.0637415 C33.2642456,26.3422466 31.7889628,29 27.9115531,29 L4.08733885,29 C0.218100769,29 -1.26453645,26.3409327 0.77395061,23.0637415 L12.3069589,4.52260192 Z M18.0499318,23.0163223 C18.0499772,23.0222378 18.05,23.0281606 18.05,23.0340907 C18.05,23.3266209 17.9947172,23.6030345 17.8840476,23.8612637 C17.7737568,24.1186089 17.6195847,24.3426723 17.4224081,24.5316332 C17.2266259,24.7192578 16.998292,24.8660439 16.7389806,24.9713892 C16.4782454,25.0773129 16.1979962,25.1301134 15.9,25.1301134 C15.5950083,25.1301134 15.3111795,25.0774024 15.0502239,24.9713892 C14.7901813,24.8657469 14.5629613,24.7183609 14.3703047,24.5298034 C14.177545,24.3411449 14.0258626,24.1177208 13.9159524,23.8612637 C13.8052827,23.6030345 13.75,23.3266209 13.75,23.0340907 C13.75,22.7411889 13.8054281,22.4661013 13.9165299,22.2109786 C14.0264627,21.9585404 14.1779817,21.7374046 14.3703047,21.5491736 C14.5621821,21.3613786 14.7883231,21.2126553 15.047143,21.1034656 C15.3089445,20.9930181 15.593871,20.938068 15.9,20.938068 C16.1991423,20.938068 16.4804862,20.9931136 16.7420615,21.1034656 C17.0001525,21.2123478 17.2274115,21.360472 17.4224081,21.5473437 C17.6191428,21.7358811 17.7731504,21.957652 17.88347,22.2109786 C17.9124619,22.2775526 17.9376628,22.3454862 17.9590769,22.414741 C18.0181943,22.5998533 18.05,22.7963729 18.05,23 C18.05,23.0054459 18.0499772,23.0108867 18.0499318,23.0163223 L18.0499318,23.0163223 Z M17.7477272,14.1749999 L17.7477272,8.75 L14.1170454,8.75 L14.1170454,14.1749999 C14.1170454,14.8471841 14.1572355,15.5139742 14.2376219,16.1753351 C14.3174838,16.8323805 14.4227217,17.5019113 14.5533248,18.1839498 L14.5921937,18.3869317 L17.272579,18.3869317 L17.3114479,18.1839498 C17.442051,17.5019113 17.5472889,16.8323805 17.6271507,16.1753351 C17.7075371,15.5139742 17.7477272,14.8471841 17.7477272,14.1749999 Z", attrs: { fillRule: "evenodd" }, @@ -393,7 +373,7 @@ export function parseViewBox(viewBox: string): Array<number> { } export function loadIcon(name: string) { - var def = ICON_PATHS[name]; + let def = ICON_PATHS[name]; if (!def) { console.warn('Icon "' + name + '" does not exist.'); return; @@ -403,7 +383,7 @@ export function loadIcon(name: string) { return { ...def, attrs: { ...def.attrs, className: "Icon Icon-" + name } }; } - var icon = { + let icon = { attrs: { className: "Icon Icon-" + name, viewBox: "0 0 32 32", @@ -418,8 +398,8 @@ export function loadIcon(name: string) { if (typeof def === "string") { icon.path = def; } else if (def != null) { - var { svg, path, attrs } = def; - for (var attr in attrs) { + let { svg, path, attrs } = def; + for (let attr in attrs) { icon.attrs[attr] = attrs[attr]; } diff --git a/frontend/src/metabase/internal/components/ColorsApp.jsx b/frontend/src/metabase/internal/components/ColorsApp.jsx index aa1e941aa12f187a3e6ef51930a42acd83ac84f0..ce4584fdc19aa6beb99a88682b6f9f7f462d5cbd 100644 --- a/frontend/src/metabase/internal/components/ColorsApp.jsx +++ b/frontend/src/metabase/internal/components/ColorsApp.jsx @@ -1,24 +1,97 @@ +/* @flow */ import React from "react"; - import cx from "classnames"; +import { connect } from "react-redux"; +import CopyToClipboard from "react-copy-to-clipboard"; + +import { addUndo, createUndo } from "metabase/redux/undo"; +import { normal, saturated, harmony } from "metabase/lib/colors"; + +const SWATCH_SIZE = 150; + +const mapDispatchToProps = { + addUndo, + createUndo, +}; + +@connect(() => ({}), mapDispatchToProps) +class ColorSwatch extends React.Component { + _onCopy(colorValue) { + const { addUndo, createUndo } = this.props; + addUndo( + createUndo({ + type: "copy-color", + message: <div>Copied {colorValue} to clipboard</div>, + }), + ); + } + render() { + const { color, name } = this.props; + return ( + <CopyToClipboard value={color} onCopy={() => this._onCopy(color)}> + <div + style={{ + backgroundColor: color, + height: SWATCH_SIZE, + borderRadius: 12, + width: SWATCH_SIZE, + color: "white", + }} + className="p3 mr2 mb2 cursor-pointer" + > + {name} + <h2>{color}</h2> + </div> + </CopyToClipboard> + ); + } +} // eslint-disable-next-line import/no-commonjs let colorStyles = require("!style-loader!css-loader?modules!postcss-loader!metabase/css/core/colors.css"); const ColorsApp = () => ( - <div className="p2"> - {Object.entries(colorStyles).map(([name, className]) => ( - <div - className={cx(className, "rounded px1")} - style={{ - paddingTop: "0.25em", - paddingBottom: "0.25em", - marginBottom: "0.25em", - }} - > - {name} + <div className="wrapper"> + <div className="my2"> + <h2 className="my3">Normal</h2> + <div className="flex flex-wrap"> + {Object.entries(normal).map(([name, value]) => ( + <ColorSwatch color={value} name={name} key={`noraml-${name}`} /> + ))} + </div> + </div> + <div className="my2"> + <h2 className="my3">Saturated</h2> + <div className="flex flex-wrap"> + {Object.entries(saturated).map(([name, value]) => ( + <ColorSwatch color={value} name={name} key={`saturated-${name}`} /> + ))} + </div> + </div> + <div className="my2"> + <h2 className="my3">Chart colors</h2> + <div className="flex flex-wrap"> + {harmony.map((color, index) => ( + <ColorSwatch color={color} name={`Series ${index + 1}`} key={index} /> + ))} </div> - ))} + </div> + <div className="my2"> + <h2 className="my3">CSS colors</h2> + {Object.entries(colorStyles).map(([name, className]) => ( + <div + className={cx(className, "rounded px1")} + key={className} + style={{ + paddingTop: "0.25em", + paddingBottom: "0.25em", + marginBottom: "0.25em", + }} + > + {name} + </div> + ))} + </div> </div> ); diff --git a/frontend/src/metabase/internal/components/ComponentsApp.jsx b/frontend/src/metabase/internal/components/ComponentsApp.jsx index a503a0f25831e62822912208d79d1c429bc1542c..9b5d5455a5a5a71fd0f6287ce4505c182b1aaf69 100644 --- a/frontend/src/metabase/internal/components/ComponentsApp.jsx +++ b/frontend/src/metabase/internal/components/ComponentsApp.jsx @@ -1,10 +1,18 @@ +/* @flow */ + import React, { Component } from "react"; -import { Link } from "react-router"; +import { Link, Route } from "react-router"; import { slugify } from "metabase/lib/formatting"; + +// $FlowFixMe: react-virtualized ignored import reactElementToJSXString from "react-element-to-jsx-string"; + import COMPONENTS from "../lib/components-webpack"; +import AceEditor from "metabase/components/TextEditor"; +import CopyButton from "metabase/components/CopyButton"; + const Section = ({ title, children }) => ( <div className="mb2"> <h3 className="my2">{title}</h3> @@ -13,78 +21,125 @@ const Section = ({ title, children }) => ( ); export default class ComponentsApp extends Component { + static routes: ?[React$Element<Route>]; render() { const componentName = slugify(this.props.params.componentName); const exampleName = slugify(this.props.params.exampleName); return ( - <div className="wrapper p4"> - {COMPONENTS.filter( - ({ component, description, examples }) => - !componentName || componentName === slugify(component.name), - ).map(({ component, description, examples }) => ( - <div> - <h2> - <Link - to={`_internal/components/${slugify(component.name)}`} - className="no-decoration" - > - {component.name} - </Link> - </h2> - {description && <p className="my2">{description}</p>} - {component.propTypes && ( - <Section title="Props"> - <div className="border-left border-right border-bottom text-code"> - {Object.keys(component.propTypes).map(prop => ( - <div> - {prop}{" "} - {component.defaultProps && - component.defaultProps[prop] !== undefined - ? "(default: " + - JSON.stringify(component.defaultProps[prop]) + - ")" - : ""} - </div> - ))} - </div> - </Section> - )} - {examples && ( - <Section title="Examples"> - {Object.entries(examples) - .filter( - ([name, element]) => - !exampleName || exampleName === slugify(name), - ) - .map(([name, element]) => ( - <div className="my2"> - <h4 className="my1"> - <Link - to={`_internal/components/${slugify( - component.name, - )}/${slugify(name)}`} - className="no-decoration" - > - {name} - </Link> - </h4> - <div className="flex flex-column"> - <div className="p2 bordered flex align-center flex-full"> - <div className="full">{element}</div> + <div className="flex full"> + <nav + className="full-height border-right p2 pl4" + style={{ flex: "0 0 33.33%" }} + > + <h2 className="my2">Components</h2> + <ul className="py2"> + {COMPONENTS.filter( + ({ component, description, examples }) => + !componentName || componentName === slugify(component.name), + ).map(({ component, description, examples }) => ( + <li> + <a + className="py1 block link h3 text-bold" + href={`/_internal/components#${component.name}`} + > + {component.name} + </a> + </li> + ))} + </ul> + </nav> + <div + className="bg-slate-extra-light flex-full" + style={{ flex: "66.66%" }} + > + <div className="p4"> + {COMPONENTS.filter( + ({ component, description, examples }) => + !componentName || componentName === slugify(component.name), + ).map(({ component, description, examples }) => ( + <div id={component.name}> + <h2> + <Link + to={`_internal/components/${slugify(component.name)}`} + className="no-decoration" + > + {component.name} + </Link> + </h2> + {description && <p className="my2">{description}</p>} + {component.propTypes && ( + <Section title="Props"> + <div className="border-left border-right border-bottom text-code"> + {Object.keys(component.propTypes).map(prop => ( + <div> + {prop}{" "} + {component.defaultProps && + component.defaultProps[prop] !== undefined + ? "(default: " + + JSON.stringify(component.defaultProps[prop]) + + ")" + : ""} </div> - <div className="border-left border-right border-bottom text-code"> - <div className="p1"> - {reactElementToJSXString(element)} + ))} + </div> + </Section> + )} + {examples && ( + <Section title="Examples"> + {Object.entries(examples) + .filter( + ([name, element]) => + !exampleName || exampleName === slugify(name), + ) + .map(([name, element]) => ( + <div className="my2"> + <h4 className="my1"> + <Link + to={`_internal/components/${slugify( + component.name, + )}/${slugify(name)}`} + className="no-decoration" + > + {name} + </Link> + </h4> + <div className="flex flex-column"> + <div className="p2 bordered flex align-center flex-full"> + <div className="full">{element}</div> + </div> + <div className="relative"> + <AceEditor + value={reactElementToJSXString(element)} + mode="ace/mode/jsx" + theme="ace/theme/metabase" + readOnly + /> + <div className="absolute top right text-brand-hover cursor-pointer z2"> + <CopyButton + className="p1" + value={reactElementToJSXString(element)} + /> + </div> + </div> </div> </div> - </div> - </div> - ))} - </Section> - )} + ))} + </Section> + )} + </div> + ))} </div> - ))} + </div> </div> ); } } + +ComponentsApp.routes = [ + <Route path="components" component={ComponentsApp} />, + <Route path="components/:componentName" component={ComponentsApp} />, + <Route + path="components/:componentName/:exampleName" + component={ComponentsApp} + />, +]; diff --git a/frontend/src/metabase/internal/components/IconsApp.jsx b/frontend/src/metabase/internal/components/IconsApp.jsx index 1e688eccfc15db8793c29d26a13f311668bfc64c..9634fdb832f5613bce21d2423643ca06f1048e81 100644 --- a/frontend/src/metabase/internal/components/IconsApp.jsx +++ b/frontend/src/metabase/internal/components/IconsApp.jsx @@ -1,16 +1,21 @@ +/* @flow */ + import React, { Component } from "react"; import Icon from "metabase/components/Icon.jsx"; const SIZES = [12, 16]; +type Props = {}; +type State = { + size: number, +}; + export default class IconsApp extends Component { - constructor(props) { - super(props); - this.state = { - size: 32, - }; - } + props: Props; + state: State = { + size: 32, + }; render() { let sizes = SIZES.concat(this.state.size); return ( diff --git a/frontend/src/metabase/internal/components/QuestionApp.jsx b/frontend/src/metabase/internal/components/QuestionApp.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d27666c80c6c69f4c28c2acd3d9e46b88dc220cd --- /dev/null +++ b/frontend/src/metabase/internal/components/QuestionApp.jsx @@ -0,0 +1,51 @@ +/* @flow */ + +import React from "react"; +import { Route } from "react-router"; + +import QuestionAndResultLoader from "metabase/containers/QuestionAndResultLoader"; +import Visualization from "metabase/visualizations/components/Visualization"; + +type Props = { + location: { + hash: ?string, + }, + params: { + questionId?: string, + }, +}; + +export default class QuestionApp extends React.Component { + props: Props; + + static routes: ?[React$Element<Route>]; + + render() { + const { location, params } = this.props; + if (!location.hash && !params.questionId) { + return ( + <div className="p4 text-centered flex-full"> + Visit <strong>/_internal/question/:id</strong> or{" "} + <strong>/_internal/question#:hash</strong>. + </div> + ); + } + return ( + <QuestionAndResultLoader + questionHash={location.hash} + questionId={params.questionId ? parseInt(params.questionId) : null} + > + {({ rawSeries }) => + rawSeries && ( + <Visualization className="flex-full" rawSeries={rawSeries} /> + ) + } + </QuestionAndResultLoader> + ); + } +} + +QuestionApp.routes = [ + <Route path="question" component={QuestionApp} />, + <Route path="question/:questionId" component={QuestionApp} />, +]; diff --git a/frontend/src/metabase/internal/lib/components-node.js b/frontend/src/metabase/internal/lib/components-node.js index fe1af8974bda4d8af497659217fc346b1e36278e..507008b81c835af0f49f8449640bd67205c5ae8c 100644 --- a/frontend/src/metabase/internal/lib/components-node.js +++ b/frontend/src/metabase/internal/lib/components-node.js @@ -3,7 +3,7 @@ import path from "path"; import fs from "fs"; -var normalizedPath = path.join(__dirname, "..", "..", "components"); +let normalizedPath = path.join(__dirname, "..", "..", "components"); export default fs .readdirSync(normalizedPath) diff --git a/frontend/src/metabase/internal/routes.js b/frontend/src/metabase/internal/routes.js index c0b6be00dbe4ee4b38336cfa8a01a6044cd2be9a..eb2d40d213c11c08c693220e03d23f33e21a8b7b 100644 --- a/frontend/src/metabase/internal/routes.js +++ b/frontend/src/metabase/internal/routes.js @@ -1,36 +1,65 @@ +/* @flow */ + import React from "react"; import { Route, IndexRoute } from "react-router"; -import IconsApp from "metabase/internal/components/IconsApp"; -import ColorsApp from "metabase/internal/components/ColorsApp"; -import ComponentsApp from "metabase/internal/components/ComponentsApp"; +// $FlowFixMe: doesn't know about require.context +const req = require.context( + "metabase/internal/components", + true, + /(\w+)App.jsx$/, +); + +const PAGES = {}; +for (const key of req.keys()) { + const name = key.match(/(\w+)App.jsx$/)[1]; + PAGES[name] = req(key).default; +} -const PAGES = { - Icons: IconsApp, - Colors: ColorsApp, - Components: ComponentsApp, +const WelcomeApp = () => { + return ( + <div className="wrapper flex flex-column justify-center"> + <div className="my4"> + <h1>Metabase Style Guide</h1> + <p className="text-paragraph"> + Reference and samples for how to make things the Metabase way. + </p> + </div> + </div> + ); }; -const ListApp = () => ( - <ul> - {Object.keys(PAGES).map(name => ( - <li> - <a href={"/_internal/" + name.toLowerCase()}>{name}</a> - </li> - ))} - </ul> -); +const InternalLayout = ({ children }) => { + return ( + <div className="flex flex-column full-height"> + <nav className="wrapper flex align-center py3 border-bottom"> + <a className="text-brand-hover" href="/_internal"> + <h4>Style Guide</h4> + </a> + <ul className="flex ml-auto"> + {Object.keys(PAGES).map(name => ( + <li key={name}> + <a className="link mx2" href={"/_internal/" + name.toLowerCase()}> + {name} + </a> + </li> + ))} + </ul> + </nav> + <div className="flex flex-full">{children}</div> + </div> + ); +}; export default ( - <Route> - <IndexRoute component={ListApp} /> - {Object.entries(PAGES).map(([name, Component]) => ( - <Route path={name.toLowerCase()} component={Component} /> - ))} - <Route path="components/:componentName" component={ComponentsApp} /> - <Route - path="components/:componentName/:exampleName" - component={ComponentsApp} - /> + <Route component={InternalLayout}> + <IndexRoute component={WelcomeApp} /> + {Object.entries(PAGES).map( + ([name, Component]) => + Component && + (Component.routes || ( + <Route path={name.toLowerCase()} component={Component} /> + )), + )} </Route> ); diff --git a/frontend/src/metabase/lib/ace/sql_behaviour.js b/frontend/src/metabase/lib/ace/sql_behaviour.js index b5297c3731bd9943bab24c7e2c6ac35ef6e808bb..8435b1bb846b1b13d836f289365a842770c3c52c 100644 --- a/frontend/src/metabase/lib/ace/sql_behaviour.js +++ b/frontend/src/metabase/lib/ace/sql_behaviour.js @@ -37,22 +37,22 @@ ace.require( ["ace/lib/oop", "ace/mode/behaviour", "ace/token_iterator", "ace/lib/lang"], function(oop, { Behaviour }, { TokenIterator }, lang) { - var SAFE_INSERT_IN_TOKENS = [ + let SAFE_INSERT_IN_TOKENS = [ "text", "paren.rparen", "punctuation.operator", ]; - var SAFE_INSERT_BEFORE_TOKENS = [ + let SAFE_INSERT_BEFORE_TOKENS = [ "text", "paren.rparen", "punctuation.operator", "comment", ]; - var context; - var contextCache = {}; - var initContext = function(editor) { - var id = -1; + let context; + let contextCache = {}; + let initContext = function(editor) { + let id = -1; if (editor.multiSelect) { id = editor.selection.index; if (contextCache.rangeCount != editor.multiSelect.rangeCount) @@ -70,8 +70,8 @@ ace.require( }; }; - var getWrapped = function(selection, selected, opening, closing) { - var rowDiff = selection.end.row - selection.start.row; + let getWrapped = function(selection, selected, opening, closing) { + let rowDiff = selection.end.row - selection.start.row; return { text: opening + selected + closing, selection: [ @@ -83,7 +83,7 @@ ace.require( }; }; - var SQLBehaviour = function() { + const SQLBehaviour = function() { function createInsertDeletePair(name, opening, closing) { this.add(name, "insertion", function( state, @@ -94,8 +94,8 @@ ace.require( ) { if (text == opening) { initContext(editor); - var selection = editor.getSelectionRange(); - var selected = session.doc.getTextRange(selection); + let selection = editor.getSelectionRange(); + let selected = session.doc.getTextRange(selection); if (selected !== "" && editor.getWrapBehavioursEnabled()) { return getWrapped(selection, selected, opening, closing); } else if (SQLBehaviour.isSaneInsertion(editor, session)) { @@ -107,11 +107,11 @@ ace.require( } } else if (text == closing) { initContext(editor); - var cursor = editor.getCursorPosition(); - var line = session.doc.getLine(cursor.row); - var rightChar = line.substring(cursor.column, cursor.column + 1); + let cursor = editor.getCursorPosition(); + let line = session.doc.getLine(cursor.row); + let rightChar = line.substring(cursor.column, cursor.column + 1); if (rightChar == closing) { - var matching = session.$findOpeningBracket(closing, { + let matching = session.$findOpeningBracket(closing, { column: cursor.column + 1, row: cursor.row, }); @@ -136,11 +136,11 @@ ace.require( session, range, ) { - var selected = session.doc.getTextRange(range); + let selected = session.doc.getTextRange(range); if (!range.isMultiLine() && selected == opening) { initContext(editor); - var line = session.doc.getLine(range.start.row); - var rightChar = line.substring( + let line = session.doc.getLine(range.start.row); + let rightChar = line.substring( range.start.column + 1, range.start.column + 2, ); @@ -170,9 +170,9 @@ ace.require( ) return; initContext(editor); - var quote = text; - var selection = editor.getSelectionRange(); - var selected = session.doc.getTextRange(selection); + let quote = text; + let selection = editor.getSelectionRange(); + let selected = session.doc.getTextRange(selection); if ( selected !== "" && selected !== "'" && @@ -181,33 +181,33 @@ ace.require( ) { return getWrapped(selection, selected, quote, quote); } else if (!selected) { - var cursor = editor.getCursorPosition(); - var line = session.doc.getLine(cursor.row); - var leftChar = line.substring(cursor.column - 1, cursor.column); - var rightChar = line.substring(cursor.column, cursor.column + 1); + let cursor = editor.getCursorPosition(); + let line = session.doc.getLine(cursor.row); + let leftChar = line.substring(cursor.column - 1, cursor.column); + let rightChar = line.substring(cursor.column, cursor.column + 1); - var token = session.getTokenAt(cursor.row, cursor.column); - var rightToken = session.getTokenAt(cursor.row, cursor.column + 1); + let token = session.getTokenAt(cursor.row, cursor.column); + let rightToken = session.getTokenAt(cursor.row, cursor.column + 1); // We're escaped. if (leftChar == "\\" && token && /escape/.test(token.type)) return null; - var stringBefore = token && /string|escape/.test(token.type); - var stringAfter = + let stringBefore = token && /string|escape/.test(token.type); + let stringAfter = !rightToken || /string|escape/.test(rightToken.type); - var pair; + let pair; if (rightChar == quote) { pair = stringBefore !== stringAfter; if (pair && /string\.end/.test(rightToken.type)) pair = false; } else { if (stringBefore && !stringAfter) return null; // wrap string with different quote if (stringBefore && stringAfter) return null; // do not pair quotes inside strings - var wordRe = session.$mode.tokenRe; + let wordRe = session.$mode.tokenRe; wordRe.lastIndex = 0; - var isWordBefore = wordRe.test(leftChar); + let isWordBefore = wordRe.test(leftChar); wordRe.lastIndex = 0; - var isWordAfter = wordRe.test(leftChar); + let isWordAfter = wordRe.test(leftChar); if (isWordBefore || isWordAfter) return null; // before or after alphanumeric if (rightChar && !/[\s;,.})\]\\]/.test(rightChar)) return null; // there is rightChar and it isn't closing pair = true; @@ -227,11 +227,11 @@ ace.require( session, range, ) { - var selected = session.doc.getTextRange(range); + let selected = session.doc.getTextRange(range); if (!range.isMultiLine() && (selected == '"' || selected == "'")) { initContext(editor); - var line = session.doc.getLine(range.start.row); - var rightChar = line.substring( + let line = session.doc.getLine(range.start.row); + let rightChar = line.substring( range.start.column + 1, range.start.column + 2, ); @@ -244,8 +244,8 @@ ace.require( }; SQLBehaviour.isSaneInsertion = function(editor, session) { - var cursor = editor.getCursorPosition(); - var iterator = new TokenIterator(session, cursor.row, cursor.column); + let cursor = editor.getCursorPosition(); + let iterator = new TokenIterator(session, cursor.row, cursor.column); // Don't insert in the middle of a keyword/identifier/lexical if ( @@ -255,7 +255,7 @@ ace.require( ) ) { // Look ahead in case we're at the end of a token - var iterator2 = new TokenIterator( + let iterator2 = new TokenIterator( session, cursor.row, cursor.column + 1, @@ -285,8 +285,8 @@ ace.require( }; SQLBehaviour.recordAutoInsert = function(editor, session, bracket) { - var cursor = editor.getCursorPosition(); - var line = session.doc.getLine(cursor.row); + let cursor = editor.getCursorPosition(); + let line = session.doc.getLine(cursor.row); // Reset previous state if text or context changed too much if ( !this.isAutoInsertedClosing( @@ -302,8 +302,8 @@ ace.require( }; SQLBehaviour.recordMaybeInsert = function(editor, session, bracket) { - var cursor = editor.getCursorPosition(); - var line = session.doc.getLine(cursor.row); + let cursor = editor.getCursorPosition(); + let line = session.doc.getLine(cursor.row); if (!this.isMaybeInsertedClosing(cursor, line)) context.maybeInsertedBrackets = 0; context.maybeInsertedRow = cursor.row; diff --git a/frontend/src/metabase/lib/ace/theme-metabase.js b/frontend/src/metabase/lib/ace/theme-metabase.js index 1738e4a5e43ebb1db3491ae23fbbef49bfd2fda4..64b52410d885cb0476c1e7d5223312965c66f95b 100644 --- a/frontend/src/metabase/lib/ace/theme-metabase.js +++ b/frontend/src/metabase/lib/ace/theme-metabase.js @@ -105,7 +105,7 @@ background: #e8e8e8;\ background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAE0lEQVQImWP4////f4bLly//BwAmVgd1/w11/gAAAABJRU5ErkJggg==") right repeat-y;\ }'; - var dom = require("../lib/dom"); + let dom = require("../lib/dom"); dom.importCssString(exports.cssText, exports.cssClass); }, ); diff --git a/frontend/src/metabase/lib/analytics.js b/frontend/src/metabase/lib/analytics.js index 0378b683ae0addbdd55cf248a7b66e66264c6605..b22e245fd4bcf510a1ce8118b679c7b1a3b82aff 100644 --- a/frontend/src/metabase/lib/analytics.js +++ b/frontend/src/metabase/lib/analytics.js @@ -25,9 +25,9 @@ const MetabaseAnalytics = { // track an event trackEvent: function( category: string, - action?: string, - label?: string | number | boolean, - value?: number, + action?: ?string, + label?: ?(string | number | boolean), + value?: ?number, ) { const { tag } = MetabaseSettings.get("version"); @@ -50,7 +50,7 @@ export function registerAnalyticsClickListener() { document.body.addEventListener( "click", function(e) { - var node = e.target; + let node = e.target; // check the target and all parent elements while (node) { diff --git a/frontend/src/metabase/lib/api.js b/frontend/src/metabase/lib/api.js index 78c4a0dd53a8d0cbf7ba7f8f410760a13fcfde1b..1bcda89500067916b63cc49d5364c7ad552f9d16 100644 --- a/frontend/src/metabase/lib/api.js +++ b/frontend/src/metabase/lib/api.js @@ -6,18 +6,20 @@ import EventEmitter from "events"; type TransformFn = (o: any) => any; -type Options = { +export type Options = { noEvent?: boolean, transformResponse?: TransformFn, cancelled?: Promise<any>, + raw?: { [key: string]: boolean }, }; -type Data = { +export type Data = { [key: string]: any, }; const DEFAULT_OPTIONS: Options = { noEvent: false, transformResponse: o => o, + raw: {}, }; class Api extends EventEmitter { @@ -62,13 +64,17 @@ class Api extends EventEmitter { let url = urlTemplate; data = { ...data }; for (let tag of url.match(/:\w+/g) || []) { - let value = data[tag.slice(1)]; + const paramName = tag.slice(1); + let value = data[paramName]; + delete data[paramName]; if (value === undefined) { console.warn("Warning: calling", method, "without", tag); value = ""; } - url = url.replace(tag, encodeURIComponent(data[tag.slice(1)])); - delete data[tag.slice(1)]; + if (!options.raw || !options.raw[paramName]) { + value = encodeURIComponent(value); + } + url = url.replace(tag, value); } let headers: { [key: string]: string } = { diff --git a/frontend/src/metabase/lib/browser.js b/frontend/src/metabase/lib/browser.js index 6a3cfb7c94fd632a516329b4ec05c6fa604f490f..df089beb79c6e8c32cc028b7bbabda74053fb709 100644 --- a/frontend/src/metabase/lib/browser.js +++ b/frontend/src/metabase/lib/browser.js @@ -2,7 +2,7 @@ import querystring from "querystring"; export function parseHashOptions(hash) { let options = querystring.parse(hash.replace(/^#/, "")); - for (var name in options) { + for (let name in options) { if (options[name] === "") { options[name] = true; } else if (/^(true|false|-?\d+(\.\d+)?)$/.test(options[name])) { diff --git a/frontend/src/metabase/lib/card.js b/frontend/src/metabase/lib/card.js index 4f668ba089be7131b1405bd3deb1a96219e9bd10..a01aebd6e0ac35ca72e140f76d443076758d94df 100644 --- a/frontend/src/metabase/lib/card.js +++ b/frontend/src/metabase/lib/card.js @@ -84,12 +84,12 @@ export function isCardRunnable(card, tableMetadata) { // TODO Atte Keinänen 5/31/17 Deprecated, we should move tests to Questions.spec.js export function serializeCardForUrl(card) { - var dataset_query = Utils.copy(card.dataset_query); + let dataset_query = Utils.copy(card.dataset_query); if (dataset_query.query) { dataset_query.query = Query.cleanQuery(dataset_query.query); } - var cardCopy = { + let cardCopy = { name: card.name, description: card.description, dataset_query: dataset_query, @@ -134,8 +134,8 @@ export function urlForCardState(state, dirty) { } export function cleanCopyCard(card) { - var cardCopy = {}; - for (var name in card) { + let cardCopy = {}; + for (let name in card) { if (name.charAt(0) !== "$") { cardCopy[name] = card[name]; } diff --git a/frontend/src/metabase/lib/colors.js b/frontend/src/metabase/lib/colors.js index 0f5bfc26c48c789e90c7f5116bc7ca8be9cfb89d..126cb086e53a0d346621b3f7c868e74488d75067 100644 --- a/frontend/src/metabase/lib/colors.js +++ b/frontend/src/metabase/lib/colors.js @@ -14,6 +14,10 @@ export const normal = { teal: "#A6E7F3", indigo: "#7172AD", gray: "#7B8797", + grey1: "#DCE1E4", + grey2: "#93A1AB", + grey3: "#2E353B", + text: "#2E353B", }; export const saturated = { diff --git a/frontend/src/metabase/lib/cookies.js b/frontend/src/metabase/lib/cookies.js index 33a78587665ba777ceb983a105df197935faef4a..860ccb863ce328798de6aa92460213184e7cf83f 100644 --- a/frontend/src/metabase/lib/cookies.js +++ b/frontend/src/metabase/lib/cookies.js @@ -6,7 +6,7 @@ export const METABASE_SESSION_COOKIE = "metabase.SESSION_ID"; export const METABASE_SEEN_ALERT_SPLASH_COOKIE = "metabase.SEEN_ALERT_SPLASH"; // Handles management of Metabase cookie work -var MetabaseCookies = { +let MetabaseCookies = { // set the session cookie. if sessionId is null, clears the cookie setSessionCookie: function(sessionId) { const options = { diff --git a/frontend/src/metabase/lib/core.js b/frontend/src/metabase/lib/core.js index c17d7e32f3d632447e1e66fe2c333a88084a2745..3c894aa1f70693ef27066e8f77241f77b50d8efd 100644 --- a/frontend/src/metabase/lib/core.js +++ b/frontend/src/metabase/lib/core.js @@ -5,105 +5,205 @@ export const field_special_types = [ { id: TYPE.PK, name: t`Entity Key`, - section: "Overall Row", + section: t`Overall Row`, description: t`The primary key for this table.`, }, { id: TYPE.Name, name: t`Entity Name`, - section: "Overall Row", + section: t`Overall Row`, description: t`The "name" of each record. Usually a column called "name", "title", etc.`, }, { id: TYPE.FK, name: t`Foreign Key`, - section: "Overall Row", + section: t`Overall Row`, description: t`Points to another table to make a connection.`, }, { id: TYPE.AvatarURL, name: t`Avatar Image URL`, - section: "Common", + section: t`Common`, }, { id: TYPE.Category, name: t`Category`, - section: "Common", + section: t`Common`, }, { id: TYPE.City, name: t`City`, - section: "Common", + section: t`Common`, }, { id: TYPE.Country, name: t`Country`, - section: "Common", + section: t`Common`, }, { id: TYPE.Description, name: t`Description`, - section: "Common", + section: t`Common`, }, { id: TYPE.Email, name: t`Email`, - section: "Common", + section: t`Common`, }, { id: TYPE.Enum, name: t`Enum`, - section: "Common", + section: t`Common`, }, { id: TYPE.ImageURL, name: t`Image URL`, - section: "Common", + section: t`Common`, }, { id: TYPE.SerializedJSON, name: t`Field containing JSON`, - section: "Common", + section: t`Common`, }, { id: TYPE.Latitude, name: t`Latitude`, - section: "Common", + section: t`Common`, }, { id: TYPE.Longitude, name: t`Longitude`, - section: "Common", + section: t`Common`, }, { id: TYPE.Number, name: t`Number`, - section: "Common", + section: t`Common`, }, { id: TYPE.State, name: t`State`, - section: "Common", + section: t`Common`, }, { id: TYPE.UNIXTimestampSeconds, name: t`UNIX Timestamp (Seconds)`, - section: "Common", + section: t`Common`, }, { id: TYPE.UNIXTimestampMilliseconds, name: t`UNIX Timestamp (Milliseconds)`, - section: "Common", + section: t`Common`, }, { id: TYPE.URL, name: t`URL`, - section: "Common", + section: t`Common`, }, { id: TYPE.ZipCode, name: t`Zip Code`, - section: "Common", + section: t`Common`, + }, + { + id: TYPE.Quantity, + name: t`Quantity`, + section: t`Common`, + }, + { + id: TYPE.Income, + name: t`Income`, + section: t`Common`, + }, + { + id: TYPE.Discount, + name: t`Discount`, + section: t`Common`, + }, + { + id: TYPE.CreationTimestamp, + name: t`Creation timestamp`, + section: t`Common`, + }, + { + id: TYPE.Product, + name: t`Product`, + section: t`Common`, + }, + { + id: TYPE.User, + name: t`User`, + section: t`Common`, + }, + { + id: TYPE.Source, + name: t`Source`, + section: t`Common`, + }, + { + id: TYPE.Price, + name: t`Price`, + section: t`Common`, + }, + { + id: TYPE.JoinTimestamp, + name: t`Join timestamp`, + section: t`Common`, + }, + { + id: TYPE.Share, + name: t`Share`, + section: t`Common`, + }, + { + id: TYPE.Owner, + name: t`Owner`, + section: t`Common`, + }, + { + id: TYPE.Company, + name: t`Company`, + section: t`Common`, + }, + { + id: TYPE.Subscription, + name: t`Subscription`, + section: t`Common`, + }, + { + id: TYPE.Score, + name: t`Score`, + section: t`Common`, + }, + { + id: TYPE.Description, + name: t`Description`, + section: t`Common`, + }, + { + id: TYPE.Title, + name: t`Title`, + section: t`Common`, + }, + { + id: TYPE.Comment, + name: t`Comment`, + section: t`Common`, + }, + { + id: TYPE.Cost, + name: t`Cost`, + section: t`Common`, + }, + { + id: TYPE.GrossMargin, + name: t`Gross margin`, + section: t`Common`, + }, + { + id: TYPE.Birthdate, + name: t`Birthday`, + section: t`Common`, }, ]; diff --git a/frontend/src/metabase/lib/data_grid.js b/frontend/src/metabase/lib/data_grid.js index dbb9f541fd6d775549ac63b61ced58e709d4252b..46f900291c9ef94997f48a2b38fd0f3bbd67f165 100644 --- a/frontend/src/metabase/lib/data_grid.js +++ b/frontend/src/metabase/lib/data_grid.js @@ -10,7 +10,7 @@ function compareNumbers(a, b) { export function pivot(data) { // find the lowest cardinality dimension and make it our "pivoted" column // TODO: we assume dimensions are in the first 2 columns, which is less than ideal - var pivotCol = 0, + let pivotCol = 0, normalCol = 1, cellCol = 2, pivotColValues = distinctValues(data, pivotCol), @@ -19,7 +19,7 @@ export function pivot(data) { pivotCol = 1; normalCol = 0; - var tmp = pivotColValues; + let tmp = pivotColValues; pivotColValues = normalColValues; normalColValues = tmp; } @@ -52,9 +52,9 @@ export function pivot(data) { }); // fill it up with the data - for (var j = 0; j < data.rows.length; j++) { - var normalColIdx = normalColValues.lastIndexOf(data.rows[j][normalCol]); - var pivotColIdx = pivotColValues.lastIndexOf(data.rows[j][pivotCol]); + for (let j = 0; j < data.rows.length; j++) { + let normalColIdx = normalColValues.lastIndexOf(data.rows[j][normalCol]); + let pivotColIdx = pivotColValues.lastIndexOf(data.rows[j][pivotCol]); pivotedRows[normalColIdx][0] = data.rows[j][normalCol]; // NOTE: we are hard coding the expectation that the metric is in the 3rd column @@ -68,7 +68,7 @@ export function pivot(data) { return data.cols[normalCol]; } - var colDef = _.clone(data.cols[cellCol]); + let colDef = _.clone(data.cols[cellCol]); colDef.name = colDef.display_name = formatValue(value, { column: data.cols[pivotCol] }) || ""; // for onVisualizationClick: @@ -88,7 +88,7 @@ export function pivot(data) { } export function distinctValues(data, colIdx) { - var vals = data.rows.map(function(r) { + let vals = data.rows.map(function(r) { return r[colIdx]; }); diff --git a/frontend/src/metabase/lib/dom.js b/frontend/src/metabase/lib/dom.js index e93b2c238a332ec64517e45d45a58cc3478b9b88..50a90430879a6c197af7c722c52d23176a5e691e 100644 --- a/frontend/src/metabase/lib/dom.js +++ b/frontend/src/metabase/lib/dom.js @@ -45,8 +45,8 @@ export function isObscured(element, offset) { // get the position of an element on the page export function findPosition(element, excludeScroll = false) { - var offset = { top: 0, left: 0 }; - var scroll = { top: 0, left: 0 }; + let offset = { top: 0, left: 0 }; + let scroll = { top: 0, left: 0 }; let offsetParent = element; while (offsetParent) { // we need to check every element for scrollTop/scrollLeft @@ -173,7 +173,7 @@ function getTextNodeAtPosition(root, index) { return NodeFilter.FILTER_ACCEPT; }, ); - var c = treeWalker.nextNode(); + let c = treeWalker.nextNode(); return { node: c ? c : root, position: c ? index : 0, @@ -181,9 +181,9 @@ function getTextNodeAtPosition(root, index) { } // https://davidwalsh.name/add-rules-stylesheets -var STYLE_SHEET = (function() { +let STYLE_SHEET = (function() { // Create the <style> tag - var style = document.createElement("style"); + let style = document.createElement("style"); // WebKit hack :( style.appendChild(document.createTextNode("/* dynamic stylesheet */")); @@ -203,6 +203,9 @@ export function addCSSRule(selector, rules, index = 0) { } export function constrainToScreen(element, direction, padding) { + if (!element) { + return false; + } if (direction === "bottom") { let screenBottom = window.innerHeight + getScrollY(); let overflowY = element.getBoundingClientRect().bottom - screenBottom; diff --git a/frontend/src/metabase/lib/emoji.js b/frontend/src/metabase/lib/emoji.js deleted file mode 100644 index 454e09b09c9d88189c1c5c3739dc327975100eca..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/lib/emoji.js +++ /dev/null @@ -1,13 +0,0 @@ -import EMOJI from "./emoji.json"; - -export const emoji = {}; -export const categories = EMOJI.categories; - -for (let shortcode in EMOJI.emoji) { - let e = EMOJI.emoji[shortcode]; - emoji[shortcode] = { - codepoint: e, - str: String.fromCodePoint(e), - react: String.fromCodePoint(e), - }; -} diff --git a/frontend/src/metabase/lib/emoji.json b/frontend/src/metabase/lib/emoji.json deleted file mode 100644 index 270fdb00f949f3828e72ad09d680425286d88f6f..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/lib/emoji.json +++ /dev/null @@ -1 +0,0 @@ -{"emoji":{":grinning:":128512,":grimacing:":128556,":grin:":128513,":joy:":128514,":smiley:":128515,":smile:":128516,":sweat_smile:":128517,":laughing:":128518,":innocent:":128519,":wink:":128521,":blush:":128522,":slight_smile:":128578,":upside_down:":128579,":relaxed:":9786,":yum:":128523,":relieved:":128524,":heart_eyes:":128525,":kissing_heart:":128536,":kissing:":128535,":kissing_smiling_eyes:":128537,":kissing_closed_eyes:":128538,":stuck_out_tongue_winking_eye:":128540,":stuck_out_tongue_closed_eyes:":128541,":stuck_out_tongue:":128539,":money_mouth:":129297,":nerd:":129299,":sunglasses:":128526,":hugging:":129303,":smirk:":128527,":no_mouth:":128566,":neutral_face:":128528,":expressionless:":128529,":unamused:":128530,":rolling_eyes:":128580,":thinking:":129300,":flushed:":128563,":disappointed:":128542,":worried:":128543,":angry:":128544,":rage:":128545,":pensive:":128532,":confused:":128533,":slight_frown:":128577,":frowning2:":9785,":persevere:":128547,":confounded:":128534,":tired_face:":128555,":weary:":128553,":triumph:":128548,":open_mouth:":128558,":scream:":128561,":fearful:":128552,":cold_sweat:":128560,":hushed:":128559,":frowning:":128550,":anguished:":128551,":cry:":128546,":disappointed_relieved:":128549,":sleepy:":128554,":sweat:":128531,":sob:":128557,":dizzy_face:":128565,":astonished:":128562,":zipper_mouth:":129296,":mask:":128567,":thermometer_face:":129298,":head_bandage:":129301,":sleeping:":128564,":zzz:":128164,":poop:":128169,":smiling_imp:":128520,":imp:":128127,":japanese_ogre:":128121,":japanese_goblin:":128122,":skull:":128128,":ghost:":128123,":alien:":128125,":robot:":129302,":smiley_cat:":128570,":smile_cat:":128568,":joy_cat:":128569,":heart_eyes_cat:":128571,":smirk_cat:":128572,":kissing_cat:":128573,":scream_cat:":128576,":crying_cat_face:":128575,":pouting_cat:":128574,":raised_hands:":128588,":clap:":128079,":wave:":128075,":thumbsup:":128077,":thumbsdown:":128078,":punch:":128074,":fist:":9994,":v:":9996,":ok_hand:":128076,":raised_hand:":9995,":open_hands:":128080,":muscle:":128170,":pray:":128591,":point_up:":9757,":point_up_2:":128070,":point_down:":128071,":point_left:":128072,":point_right:":128073,":middle_finger:":128405,":hand_splayed:":128400,":metal:":129304,":vulcan:":128406,":writing_hand:":9997,":nail_care:":128133,":lips:":128068,":tongue:":128069,":ear:":128066,":nose:":128067,":eye:":128065,":eyes:":128064,":bust_in_silhouette:":128100,":busts_in_silhouette:":128101,":speaking_head:":128483,":baby:":128118,":boy:":128102,":girl:":128103,":man:":128104,":woman:":128105,":person_with_blond_hair:":128113,":older_man:":128116,":older_woman:":128117,":man_with_gua_pi_mao:":128114,":man_with_turban:":128115,":cop:":128110,":construction_worker:":128119,":guardsman:":128130,":spy:":128373,":santa:":127877,":angel:":128124,":princess:":128120,":bride_with_veil:":128112,":walking:":128694,":runner:":127939,":dancer:":128131,":dancers:":128111,":couple:":128107,":two_men_holding_hands:":128108,":two_women_holding_hands:":128109,":bow:":128583,":information_desk_person:":128129,":no_good:":128581,":ok_woman:":128582,":raising_hand:":128587,":person_with_pouting_face:":128590,":person_frowning:":128589,":haircut:":128135,":massage:":128134,":couple_with_heart:":128145,":couple_ww:":128105,":couple_mm:":128104,":couplekiss:":128143,":kiss_ww:":128105,":kiss_mm:":128104,":family:":128106,":family_mwg:":128104,":family_mwgb:":128104,":family_mwbb:":128104,":family_mwgg:":128104,":family_wwb:":128105,":family_wwg:":128105,":family_wwgb:":128105,":family_wwbb:":128105,":family_wwgg:":128105,":family_mmb:":128104,":family_mmg:":128104,":family_mmgb:":128104,":family_mmbb:":128104,":family_mmgg:":128104,":womans_clothes:":128090,":shirt:":128085,":jeans:":128086,":necktie:":128084,":dress:":128087,":bikini:":128089,":kimono:":128088,":lipstick:":128132,":kiss:":128139,":footprints:":128099,":high_heel:":128096,":sandal:":128097,":boot:":128098,":mans_shoe:":128094,":athletic_shoe:":128095,":womans_hat:":128082,":tophat:":127913,":helmet_with_cross:":9937,":mortar_board:":127891,":crown:":128081,":school_satchel:":127890,":pouch:":128093,":purse:":128091,":handbag:":128092,":briefcase:":128188,":eyeglasses:":128083,":dark_sunglasses:":128374,":ring:":128141,":closed_umbrella:":127746,":dog:":128054,":cat:":128049,":mouse:":128045,":hamster:":128057,":rabbit:":128048,":bear:":128059,":panda_face:":128060,":koala:":128040,":tiger:":128047,":lion_face:":129409,":cow:":128046,":pig:":128055,":pig_nose:":128061,":frog:":128056,":octopus:":128025,":monkey_face:":128053,":see_no_evil:":128584,":hear_no_evil:":128585,":speak_no_evil:":128586,":monkey:":128018,":chicken:":128020,":penguin:":128039,":bird:":128038,":baby_chick:":128036,":hatching_chick:":128035,":hatched_chick:":128037,":wolf:":128058,":boar:":128023,":horse:":128052,":unicorn:":129412,":bee:":128029,":bug:":128027,":snail:":128012,":beetle:":128030,":ant:":128028,":spider:":128375,":scorpion:":129410,":crab:":129408,":snake:":128013,":turtle:":128034,":tropical_fish:":128032,":fish:":128031,":blowfish:":128033,":dolphin:":128044,":whale:":128051,":whale2:":128011,":crocodile:":128010,":leopard:":128006,":tiger2:":128005,":water_buffalo:":128003,":ox:":128002,":cow2:":128004,":dromedary_camel:":128042,":camel:":128043,":elephant:":128024,":goat:":128016,":ram:":128015,":sheep:":128017,":racehorse:":128014,":pig2:":128022,":rat:":128000,":mouse2:":128001,":rooster:":128019,":turkey:":129411,":dove:":128330,":dog2:":128021,":poodle:":128041,":cat2:":128008,":rabbit2:":128007,":chipmunk:":128063,":feet:":128062,":dragon:":128009,":dragon_face:":128050,":cactus:":127797,":christmas_tree:":127876,":evergreen_tree:":127794,":deciduous_tree:":127795,":palm_tree:":127796,":seedling:":127793,":herb:":127807,":shamrock:":9752,":four_leaf_clover:":127808,":bamboo:":127885,":tanabata_tree:":127883,":leaves:":127811,":fallen_leaf:":127810,":maple_leaf:":127809,":ear_of_rice:":127806,":hibiscus:":127802,":sunflower:":127803,":rose:":127801,":tulip:":127799,":blossom:":127804,":cherry_blossom:":127800,":bouquet:":128144,":mushroom:":127812,":chestnut:":127792,":jack_o_lantern:":127875,":shell:":128026,":spider_web:":128376,":earth_americas:":127758,":earth_africa:":127757,":earth_asia:":127759,":full_moon:":127765,":waning_gibbous_moon:":127766,":last_quarter_moon:":127767,":waning_crescent_moon:":127768,":new_moon:":127761,":waxing_crescent_moon:":127762,":first_quarter_moon:":127763,":waxing_gibbous_moon:":127764,":new_moon_with_face:":127770,":full_moon_with_face:":127773,":first_quarter_moon_with_face:":127771,":last_quarter_moon_with_face:":127772,":sun_with_face:":127774,":crescent_moon:":127769,":star:":11088,":star2:":127775,":dizzy:":128171,":sparkles:":10024,":comet:":9732,":sunny:":9728,":white_sun_small_cloud:":127780,":partly_sunny:":9925,":white_sun_cloud:":127781,":white_sun_rain_cloud:":127782,":cloud:":9729,":cloud_rain:":127783,":thunder_cloud_rain:":9928,":cloud_lightning:":127785,":zap:":9889,":fire:":128293,":boom:":128165,":snowflake:":10052,":cloud_snow:":127784,":snowman2:":9731,":snowman:":9924,":wind_blowing_face:":127788,":dash:":128168,":cloud_tornado:":127786,":fog:":127787,":umbrella2:":9730,":umbrella:":9748,":droplet:":128167,":sweat_drops:":128166,":ocean:":127754,":green_apple:":127823,":apple:":127822,":pear:":127824,":tangerine:":127818,":lemon:":127819,":banana:":127820,":watermelon:":127817,":grapes:":127815,":strawberry:":127827,":melon:":127816,":cherries:":127826,":peach:":127825,":pineapple:":127821,":tomato:":127813,":eggplant:":127814,":hot_pepper:":127798,":corn:":127805,":sweet_potato:":127840,":honey_pot:":127855,":bread:":127838,":cheese:":129472,":poultry_leg:":127831,":meat_on_bone:":127830,":fried_shrimp:":127844,":egg:":127859,":hamburger:":127828,":fries:":127839,":hotdog:":127789,":pizza:":127829,":spaghetti:":127837,":taco:":127790,":burrito:":127791,":ramen:":127836,":stew:":127858,":fish_cake:":127845,":sushi:":127843,":bento:":127857,":curry:":127835,":rice_ball:":127833,":rice:":127834,":rice_cracker:":127832,":oden:":127842,":dango:":127841,":shaved_ice:":127847,":ice_cream:":127848,":icecream:":127846,":cake:":127856,":birthday:":127874,":custard:":127854,":candy:":127852,":lollipop:":127853,":chocolate_bar:":127851,":popcorn:":127871,":doughnut:":127849,":cookie:":127850,":beer:":127866,":beers:":127867,":wine_glass:":127863,":cocktail:":127864,":tropical_drink:":127865,":champagne:":127870,":sake:":127862,":tea:":127861,":coffee:":9749,":baby_bottle:":127868,":fork_and_knife:":127860,":fork_knife_plate:":127869,":soccer:":9917,":basketball:":127936,":football:":127944,":baseball:":9918,":tennis:":127934,":volleyball:":127952,":rugby_football:":127945,":8ball:":127921,":golf:":9971,":golfer:":127948,":ping_pong:":127955,":badminton:":127992,":hockey:":127954,":field_hockey:":127953,":cricket:":127951,":ski:":127935,":skier:":9975,":snowboarder:":127938,":ice_skate:":9976,":bow_and_arrow:":127993,":fishing_pole_and_fish:":127907,":rowboat:":128675,":swimmer:":127946,":surfer:":127940,":bath:":128704,":basketball_player:":9977,":lifter:":127947,":bicyclist:":128692,":mountain_bicyclist:":128693,":horse_racing:":127943,":levitate:":128372,":trophy:":127942,":running_shirt_with_sash:":127933,":medal:":127941,":military_medal:":127894,":reminder_ribbon:":127895,":rosette:":127989,":ticket:":127915,":tickets:":127903,":performing_arts:":127917,":art:":127912,":circus_tent:":127914,":microphone:":127908,":headphones:":127911,":musical_score:":127932,":musical_keyboard:":127929,":saxophone:":127927,":trumpet:":127930,":guitar:":127928,":violin:":127931,":clapper:":127916,":video_game:":127918,":space_invader:":128126,":dart:":127919,":game_die:":127922,":slot_machine:":127920,":bowling:":127923,":red_car:":128663,":taxi:":128661,":blue_car:":128665,":bus:":128652,":trolleybus:":128654,":race_car:":127950,":police_car:":128659,":ambulance:":128657,":fire_engine:":128658,":minibus:":128656,":truck:":128666,":articulated_lorry:":128667,":tractor:":128668,":motorcycle:":127949,":bike:":128690,":rotating_light:":128680,":oncoming_police_car:":128660,":oncoming_bus:":128653,":oncoming_automobile:":128664,":oncoming_taxi:":128662,":aerial_tramway:":128673,":mountain_cableway:":128672,":suspension_railway:":128671,":railway_car:":128643,":train:":128651,":monorail:":128669,":bullettrain_side:":128644,":bullettrain_front:":128645,":light_rail:":128648,":mountain_railway:":128670,":steam_locomotive:":128642,":train2:":128646,":metro:":128647,":tram:":128650,":station:":128649,":helicopter:":128641,":airplane_small:":128745,":airplane:":9992,":airplane_departure:":128747,":airplane_arriving:":128748,":sailboat:":9973,":motorboat:":128741,":speedboat:":128676,":ferry:":9972,":cruise_ship:":128755,":rocket:":128640,":satellite_orbital:":128752,":seat:":128186,":anchor:":9875,":construction:":128679,":fuelpump:":9981,":busstop:":128655,":vertical_traffic_light:":128678,":traffic_light:":128677,":checkered_flag:":127937,":ship:":128674,":ferris_wheel:":127905,":roller_coaster:":127906,":carousel_horse:":127904,":construction_site:":127959,":foggy:":127745,":tokyo_tower:":128508,":factory:":127981,":fountain:":9970,":rice_scene:":127889,":mountain:":9968,":mountain_snow:":127956,":mount_fuji:":128507,":volcano:":127755,":japan:":128510,":camping:":127957,":tent:":9978,":park:":127966,":motorway:":128739,":railway_track:":128740,":sunrise:":127749,":sunrise_over_mountains:":127748,":desert:":127964,":beach:":127958,":island:":127965,":city_sunset:":127751,":city_dusk:":127750,":cityscape:":127961,":night_with_stars:":127747,":bridge_at_night:":127753,":milky_way:":127756,":stars:":127776,":sparkler:":127879,":fireworks:":127878,":rainbow:":127752,":homes:":127960,":european_castle:":127984,":japanese_castle:":127983,":stadium:":127967,":statue_of_liberty:":128509,":house:":127968,":house_with_garden:":127969,":house_abandoned:":127962,":office:":127970,":department_store:":127980,":post_office:":127971,":european_post_office:":127972,":hospital:":127973,":bank:":127974,":hotel:":127976,":convenience_store:":127978,":school:":127979,":love_hotel:":127977,":wedding:":128146,":classical_building:":127963,":church:":9962,":mosque:":128332,":synagogue:":128333,":kaaba:":128331,":shinto_shrine:":9961,":watch:":8986,":iphone:":128241,":calling:":128242,":computer:":128187,":keyboard:":9000,":desktop:":128421,":printer:":128424,":mouse_three_button:":128433,":trackball:":128434,":joystick:":128377,":compression:":128476,":minidisc:":128189,":floppy_disk:":128190,":cd:":128191,":dvd:":128192,":vhs:":128252,":camera:":128247,":camera_with_flash:":128248,":video_camera:":128249,":movie_camera:":127909,":projector:":128253,":film_frames:":127902,":telephone_receiver:":128222,":telephone:":9742,":pager:":128223,":fax:":128224,":tv:":128250,":radio:":128251,":microphone2:":127897,":level_slider:":127898,":control_knobs:":127899,":stopwatch:":9201,":timer:":9202,":alarm_clock:":9200,":clock:":128368,":hourglass_flowing_sand:":9203,":hourglass:":8987,":satellite:":128225,":battery:":128267,":electric_plug:":128268,":bulb:":128161,":flashlight:":128294,":candle:":128367,":wastebasket:":128465,":oil:":128738,":money_with_wings:":128184,":dollar:":128181,":yen:":128180,":euro:":128182,":pound:":128183,":moneybag:":128176,":credit_card:":128179,":gem:":128142,":scales:":9878,":wrench:":128295,":hammer:":128296,":hammer_pick:":9874,":tools:":128736,":pick:":9935,":nut_and_bolt:":128297,":gear:":9881,":chains:":9939,":gun:":128299,":bomb:":128163,":knife:":128298,":dagger:":128481,":crossed_swords:":9876,":shield:":128737,":smoking:":128684,":skull_crossbones:":9760,":coffin:":9904,":urn:":9905,":amphora:":127994,":crystal_ball:":128302,":prayer_beads:":128255,":barber:":128136,":alembic:":9879,":telescope:":128301,":microscope:":128300,":hole:":128371,":pill:":128138,":syringe:":128137,":thermometer:":127777,":label:":127991,":bookmark:":128278,":toilet:":128701,":shower:":128703,":bathtub:":128705,":key:":128273,":key2:":128477,":couch:":128715,":sleeping_accommodation:":128716,":bed:":128719,":door:":128682,":bellhop:":128718,":frame_photo:":128444,":map:":128506,":beach_umbrella:":9969,":moyai:":128511,":shopping_bags:":128717,":balloon:":127880,":flags:":127887,":ribbon:":127872,":gift:":127873,":confetti_ball:":127882,":tada:":127881,":dolls:":127886,":wind_chime:":127888,":crossed_flags:":127884,":izakaya_lantern:":127982,":envelope:":9993,":envelope_with_arrow:":128233,":incoming_envelope:":128232,":e-mail:":128231,":love_letter:":128140,":postbox:":128238,":mailbox_closed:":128234,":mailbox:":128235,":mailbox_with_mail:":128236,":mailbox_with_no_mail:":128237,":package:":128230,":postal_horn:":128239,":inbox_tray:":128229,":outbox_tray:":128228,":scroll:":128220,":page_with_curl:":128195,":bookmark_tabs:":128209,":bar_chart:":128202,":chart_with_upwards_trend:":128200,":chart_with_downwards_trend:":128201,":page_facing_up:":128196,":date:":128197,":calendar:":128198,":calendar_spiral:":128467,":card_index:":128199,":card_box:":128451,":ballot_box:":128499,":file_cabinet:":128452,":clipboard:":128203,":notepad_spiral:":128466,":file_folder:":128193,":open_file_folder:":128194,":dividers:":128450,":newspaper2:":128478,":newspaper:":128240,":notebook:":128211,":closed_book:":128213,":green_book:":128215,":blue_book:":128216,":orange_book:":128217,":notebook_with_decorative_cover:":128212,":ledger:":128210,":books:":128218,":book:":128214,":link:":128279,":paperclip:":128206,":paperclips:":128391,":scissors:":9986,":triangular_ruler:":128208,":straight_ruler:":128207,":pushpin:":128204,":round_pushpin:":128205,":triangular_flag_on_post:":128681,":flag_white:":127987,":flag_black:":127988,":closed_lock_with_key:":128272,":lock:":128274,":unlock:":128275,":lock_with_ink_pen:":128271,":pen_ballpoint:":128394,":pen_fountain:":128395,":black_nib:":10002,":pencil:":128221,":pencil2:":9999,":crayon:":128397,":paintbrush:":128396,":mag:":128269,":mag_right:":128270,":heart:":10084,":yellow_heart:":128155,":green_heart:":128154,":blue_heart:":128153,":purple_heart:":128156,":broken_heart:":128148,":heart_exclamation:":10083,":two_hearts:":128149,":revolving_hearts:":128158,":heartbeat:":128147,":heartpulse:":128151,":sparkling_heart:":128150,":cupid:":128152,":gift_heart:":128157,":heart_decoration:":128159,":peace:":9774,":cross:":10013,":star_and_crescent:":9770,":om_symbol:":128329,":wheel_of_dharma:":9784,":star_of_david:":10017,":six_pointed_star:":128303,":menorah:":128334,":yin_yang:":9775,":orthodox_cross:":9766,":place_of_worship:":128720,":ophiuchus:":9934,":aries:":9800,":taurus:":9801,":gemini:":9802,":cancer:":9803,":leo:":9804,":virgo:":9805,":libra:":9806,":scorpius:":9807,":sagittarius:":9808,":capricorn:":9809,":aquarius:":9810,":pisces:":9811,":id:":127380,":atom:":9883,":u7a7a:":127539,":u5272:":127545,":radioactive:":9762,":biohazard:":9763,":mobile_phone_off:":128244,":vibration_mode:":128243,":u6709:":127542,":u7121:":127514,":u7533:":127544,":u55b6:":127546,":u6708:":127543,":eight_pointed_black_star:":10036,":vs:":127386,":accept:":127569,":white_flower:":128174,":ideograph_advantage:":127568,":secret:":12953,":congratulations:":12951,":u5408:":127540,":u6e80:":127541,":u7981:":127538,":a:":127344,":b:":127345,":ab:":127374,":cl:":127377,":o2:":127358,":sos:":127384,":no_entry:":9940,":name_badge:":128219,":no_entry_sign:":128683,":x:":10060,":o:":11093,":anger:":128162,":hotsprings:":9832,":no_pedestrians:":128695,":do_not_litter:":128687,":no_bicycles:":128691,":non-potable_water:":128689,":underage:":128286,":no_mobile_phones:":128245,":exclamation:":10071,":grey_exclamation:":10069,":question:":10067,":grey_question:":10068,":bangbang:":8252,":interrobang:":8265,":100:":128175,":low_brightness:":128261,":high_brightness:":128262,":trident:":128305,":fleur-de-lis:":9884,":part_alternation_mark:":12349,":warning:":9888,":children_crossing:":128696,":beginner:":128304,":recycle:":9851,":u6307:":127535,":chart:":128185,":sparkle:":10055,":eight_spoked_asterisk:":10035,":negative_squared_cross_mark:":10062,":white_check_mark:":9989,":diamond_shape_with_a_dot_inside:":128160,":cyclone:":127744,":loop:":10175,":globe_with_meridians:":127760,":m:":9410,":atm:":127975,":sa:":127490,":passport_control:":128706,":customs:":128707,":baggage_claim:":128708,":left_luggage:":128709,":wheelchair:":9855,":no_smoking:":128685,":wc:":128702,":parking:":127359,":potable_water:":128688,":mens:":128697,":womens:":128698,":baby_symbol:":128700,":restroom:":128699,":put_litter_in_its_place:":128686,":cinema:":127910,":signal_strength:":128246,":koko:":127489,":ng:":127382,":ok:":127383,":up:":127385,":cool:":127378,":new:":127381,":free:":127379,":zero:":48,":one:":49,":two:":50,":three:":51,":four:":52,":five:":53,":six:":54,":seven:":55,":eight:":56,":nine:":57,":ten:":128287,":1234:":128290,":arrow_forward:":9654,":pause_button:":9208,":play_pause:":9199,":stop_button:":9209,":record_button:":9210,":track_next:":9197,":track_previous:":9198,":fast_forward:":9193,":rewind:":9194,":twisted_rightwards_arrows:":128256,":repeat:":128257,":repeat_one:":128258,":arrow_backward:":9664,":arrow_up_small:":128316,":arrow_down_small:":128317,":arrow_double_up:":9195,":arrow_double_down:":9196,":arrow_right:":10145,":arrow_left:":11013,":arrow_up:":11014,":arrow_down:":11015,":arrow_upper_right:":8599,":arrow_lower_right:":8600,":arrow_lower_left:":8601,":arrow_upper_left:":8598,":arrow_up_down:":8597,":left_right_arrow:":8596,":arrows_counterclockwise:":128260,":arrow_right_hook:":8618,":leftwards_arrow_with_hook:":8617,":arrow_heading_up:":10548,":arrow_heading_down:":10549,":hash:":35,":asterisk:":42,":information_source:":8505,":abc:":128292,":abcd:":128289,":capital_abcd:":128288,":symbols:":128291,":musical_note:":127925,":notes:":127926,":wavy_dash:":12336,":curly_loop:":10160,":heavy_check_mark:":10004,":arrows_clockwise:":128259,":heavy_plus_sign:":10133,":heavy_minus_sign:":10134,":heavy_division_sign:":10135,":heavy_multiplication_x:":10006,":heavy_dollar_sign:":128178,":currency_exchange:":128177,":copyright:":169,":registered:":174,":tm:":8482,":end:":128282,":back:":128281,":on:":128283,":top:":128285,":soon:":128284,":ballot_box_with_check:":9745,":radio_button:":128280,":white_circle:":9898,":black_circle:":9899,":red_circle:":128308,":large_blue_circle:":128309,":small_orange_diamond:":128312,":small_blue_diamond:":128313,":large_orange_diamond:":128310,":large_blue_diamond:":128311,":small_red_triangle:":128314,":black_small_square:":9642,":white_small_square:":9643,":black_large_square:":11035,":white_large_square:":11036,":small_red_triangle_down:":128315,":black_medium_square:":9724,":white_medium_square:":9723,":black_medium_small_square:":9726,":white_medium_small_square:":9725,":black_square_button:":128306,":white_square_button:":128307,":speaker:":128264,":sound:":128265,":loud_sound:":128266,":mute:":128263,":mega:":128227,":loudspeaker:":128226,":bell:":128276,":no_bell:":128277,":black_joker:":127183,":mahjong:":126980,":spades:":9824,":clubs:":9827,":hearts:":9829,":diamonds:":9830,":flower_playing_cards:":127924,":thought_balloon:":128173,":anger_right:":128495,":speech_balloon:":128172,":clock1:":128336,":clock2:":128337,":clock3:":128338,":clock4:":128339,":clock5:":128340,":clock6:":128341,":clock7:":128342,":clock8:":128343,":clock9:":128344,":clock10:":128345,":clock11:":128346,":clock12:":128347,":clock130:":128348,":clock230:":128349,":clock330:":128350,":clock430:":128351,":clock530:":128352,":clock630:":128353,":clock730:":128354,":clock830:":128355,":clock930:":128356,":clock1030:":128357,":clock1130:":128358,":clock1230:":128359,":eye_in_speech_bubble:":128065,":flag_ac:":127462,":flag_af:":127462,":flag_al:":127462,":flag_dz:":127465,":flag_ad:":127462,":flag_ao:":127462,":flag_ai:":127462,":flag_ag:":127462,":flag_ar:":127462,":flag_am:":127462,":flag_aw:":127462,":flag_au:":127462,":flag_at:":127462,":flag_az:":127462,":flag_bs:":127463,":flag_bh:":127463,":flag_bd:":127463,":flag_bb:":127463,":flag_by:":127463,":flag_be:":127463,":flag_bz:":127463,":flag_bj:":127463,":flag_bm:":127463,":flag_bt:":127463,":flag_bo:":127463,":flag_ba:":127463,":flag_bw:":127463,":flag_br:":127463,":flag_bn:":127463,":flag_bg:":127463,":flag_bf:":127463,":flag_bi:":127463,":flag_cv:":127464,":flag_kh:":127472,":flag_cm:":127464,":flag_ca:":127464,":flag_ky:":127472,":flag_cf:":127464,":flag_td:":127481,":flag_cl:":127464,":flag_cn:":127464,":flag_co:":127464,":flag_km:":127472,":flag_cg:":127464,":flag_cd:":127464,":flag_cr:":127464,":flag_hr:":127469,":flag_cu:":127464,":flag_cy:":127464,":flag_cz:":127464,":flag_dk:":127465,":flag_dj:":127465,":flag_dm:":127465,":flag_do:":127465,":flag_ec:":127466,":flag_eg:":127466,":flag_sv:":127480,":flag_gq:":127468,":flag_er:":127466,":flag_ee:":127466,":flag_et:":127466,":flag_fk:":127467,":flag_fo:":127467,":flag_fj:":127467,":flag_fi:":127467,":flag_fr:":127467,":flag_pf:":127477,":flag_ga:":127468,":flag_gm:":127468,":flag_ge:":127468,":flag_de:":127465,":flag_gh:":127468,":flag_gi:":127468,":flag_gr:":127468,":flag_gl:":127468,":flag_gd:":127468,":flag_gu:":127468,":flag_gt:":127468,":flag_gn:":127468,":flag_gw:":127468,":flag_gy:":127468,":flag_ht:":127469,":flag_hn:":127469,":flag_hk:":127469,":flag_hu:":127469,":flag_is:":127470,":flag_in:":127470,":flag_id:":127470,":flag_ir:":127470,":flag_iq:":127470,":flag_ie:":127470,":flag_il:":127470,":flag_it:":127470,":flag_ci:":127464,":flag_jm:":127471,":flag_jp:":127471,":flag_je:":127471,":flag_jo:":127471,":flag_kz:":127472,":flag_ke:":127472,":flag_ki:":127472,":flag_xk:":127485,":flag_kw:":127472,":flag_kg:":127472,":flag_la:":127473,":flag_lv:":127473,":flag_lb:":127473,":flag_ls:":127473,":flag_lr:":127473,":flag_ly:":127473,":flag_li:":127473,":flag_lt:":127473,":flag_lu:":127473,":flag_mo:":127474,":flag_mk:":127474,":flag_mg:":127474,":flag_mw:":127474,":flag_my:":127474,":flag_mv:":127474,":flag_ml:":127474,":flag_mt:":127474,":flag_mh:":127474,":flag_mr:":127474,":flag_mu:":127474,":flag_mx:":127474,":flag_fm:":127467,":flag_md:":127474,":flag_mc:":127474,":flag_mn:":127474,":flag_me:":127474,":flag_ms:":127474,":flag_ma:":127474,":flag_mz:":127474,":flag_mm:":127474,":flag_na:":127475,":flag_nr:":127475,":flag_np:":127475,":flag_nl:":127475,":flag_nc:":127475,":flag_nz:":127475,":flag_ni:":127475,":flag_ne:":127475,":flag_ng:":127475,":flag_nu:":127475,":flag_kp:":127472,":flag_no:":127475,":flag_om:":127476,":flag_pk:":127477,":flag_pw:":127477,":flag_ps:":127477,":flag_pa:":127477,":flag_pg:":127477,":flag_py:":127477,":flag_pe:":127477,":flag_ph:":127477,":flag_pl:":127477,":flag_pt:":127477,":flag_pr:":127477,":flag_qa:":127478,":flag_ro:":127479,":flag_ru:":127479,":flag_rw:":127479,":flag_sh:":127480,":flag_kn:":127472,":flag_lc:":127473,":flag_vc:":127483,":flag_ws:":127484,":flag_sm:":127480,":flag_st:":127480,":flag_sa:":127480,":flag_sn:":127480,":flag_rs:":127479,":flag_sc:":127480,":flag_sl:":127480,":flag_sg:":127480,":flag_sk:":127480,":flag_si:":127480,":flag_sb:":127480,":flag_so:":127480,":flag_za:":127487,":flag_kr:":127472,":flag_es:":127466,":flag_lk:":127473,":flag_sd:":127480,":flag_sr:":127480,":flag_sz:":127480,":flag_se:":127480,":flag_ch:":127464,":flag_sy:":127480,":flag_tw:":127481,":flag_tj:":127481,":flag_tz:":127481,":flag_th:":127481,":flag_tl:":127481,":flag_tg:":127481,":flag_to:":127481,":flag_tt:":127481,":flag_tn:":127481,":flag_tr:":127481,":flag_tm:":127481,":flag_tv:":127481,":flag_ug:":127482,":flag_ua:":127482,":flag_ae:":127462,":flag_gb:":127468,":flag_us:":127482,":flag_vi:":127483,":flag_uy:":127482,":flag_uz:":127482,":flag_vu:":127483,":flag_va:":127483,":flag_ve:":127483,":flag_vn:":127483,":flag_wf:":127484,":flag_eh:":127466,":flag_ye:":127486,":flag_zm:":127487,":flag_zw:":127487,":flag_re:":127479,":flag_ax:":127462,":flag_ta:":127481,":flag_io:":127470,":flag_bq:":127463,":flag_cx:":127464,":flag_cc:":127464,":flag_gg:":127468,":flag_im:":127470,":flag_yt:":127486,":flag_nf:":127475,":flag_pn:":127477,":flag_bl:":127463,":flag_pm:":127477,":flag_gs:":127468,":flag_tk:":127481,":flag_bv:":127463,":flag_hm:":127469,":flag_sj:":127480,":flag_um:":127482,":flag_ic:":127470,":flag_ea:":127466,":flag_cp:":127464,":flag_dg:":127465,":flag_as:":127462,":flag_aq:":127462,":flag_vg:":127483,":flag_ck:":127464,":flag_cw:":127464,":flag_eu:":127466,":flag_gf:":127468,":flag_tf:":127481,":flag_gp:":127468,":flag_mq:":127474,":flag_mp:":127474,":flag_sx:":127480,":flag_ss:":127480,":flag_tc:":127481,":flag_mf:":127474,":raised_hands_tone1:":128588,":raised_hands_tone2:":128588,":raised_hands_tone3:":128588,":raised_hands_tone4:":128588,":raised_hands_tone5:":128588,":clap_tone1:":128079,":clap_tone2:":128079,":clap_tone3:":128079,":clap_tone4:":128079,":clap_tone5:":128079,":wave_tone1:":128075,":wave_tone2:":128075,":wave_tone3:":128075,":wave_tone4:":128075,":wave_tone5:":128075,":thumbsup_tone1:":128077,":thumbsup_tone2:":128077,":thumbsup_tone3:":128077,":thumbsup_tone4:":128077,":thumbsup_tone5:":128077,":thumbsdown_tone1:":128078,":thumbsdown_tone2:":128078,":thumbsdown_tone3:":128078,":thumbsdown_tone4:":128078,":thumbsdown_tone5:":128078,":punch_tone1:":128074,":punch_tone2:":128074,":punch_tone3:":128074,":punch_tone4:":128074,":punch_tone5:":128074,":fist_tone1:":9994,":fist_tone2:":9994,":fist_tone3:":9994,":fist_tone4:":9994,":fist_tone5:":9994,":v_tone1:":9996,":v_tone2:":9996,":v_tone3:":9996,":v_tone4:":9996,":v_tone5:":9996,":ok_hand_tone1:":128076,":ok_hand_tone2:":128076,":ok_hand_tone3:":128076,":ok_hand_tone4:":128076,":ok_hand_tone5:":128076,":raised_hand_tone1:":9995,":raised_hand_tone2:":9995,":raised_hand_tone3:":9995,":raised_hand_tone4:":9995,":raised_hand_tone5:":9995,":open_hands_tone1:":128080,":open_hands_tone2:":128080,":open_hands_tone3:":128080,":open_hands_tone4:":128080,":open_hands_tone5:":128080,":muscle_tone1:":128170,":muscle_tone2:":128170,":muscle_tone3:":128170,":muscle_tone4:":128170,":muscle_tone5:":128170,":pray_tone1:":128591,":pray_tone2:":128591,":pray_tone3:":128591,":pray_tone4:":128591,":pray_tone5:":128591,":point_up_tone1:":9757,":point_up_tone2:":9757,":point_up_tone3:":9757,":point_up_tone4:":9757,":point_up_tone5:":9757,":point_up_2_tone1:":128070,":point_up_2_tone2:":128070,":point_up_2_tone3:":128070,":point_up_2_tone4:":128070,":point_up_2_tone5:":128070,":point_down_tone1:":128071,":point_down_tone2:":128071,":point_down_tone3:":128071,":point_down_tone4:":128071,":point_down_tone5:":128071,":point_left_tone1:":128072,":point_left_tone2:":128072,":point_left_tone3:":128072,":point_left_tone4:":128072,":point_left_tone5:":128072,":point_right_tone1:":128073,":point_right_tone2:":128073,":point_right_tone3:":128073,":point_right_tone4:":128073,":point_right_tone5:":128073,":middle_finger_tone1:":128405,":middle_finger_tone2:":128405,":middle_finger_tone3:":128405,":middle_finger_tone4:":128405,":middle_finger_tone5:":128405,":hand_splayed_tone1:":128400,":hand_splayed_tone2:":128400,":hand_splayed_tone3:":128400,":hand_splayed_tone4:":128400,":hand_splayed_tone5:":128400,":metal_tone1:":129304,":metal_tone2:":129304,":metal_tone3:":129304,":metal_tone4:":129304,":metal_tone5:":129304,":vulcan_tone1:":128406,":vulcan_tone2:":128406,":vulcan_tone3:":128406,":vulcan_tone4:":128406,":vulcan_tone5:":128406,":writing_hand_tone1:":9997,":writing_hand_tone2:":9997,":writing_hand_tone3:":9997,":writing_hand_tone4:":9997,":writing_hand_tone5:":9997,":nail_care_tone1:":128133,":nail_care_tone2:":128133,":nail_care_tone3:":128133,":nail_care_tone4:":128133,":nail_care_tone5:":128133,":ear_tone1:":128066,":ear_tone2:":128066,":ear_tone3:":128066,":ear_tone4:":128066,":ear_tone5:":128066,":nose_tone1:":128067,":nose_tone2:":128067,":nose_tone3:":128067,":nose_tone4:":128067,":nose_tone5:":128067,":baby_tone1:":128118,":baby_tone2:":128118,":baby_tone3:":128118,":baby_tone4:":128118,":baby_tone5:":128118,":boy_tone1:":128102,":boy_tone2:":128102,":boy_tone3:":128102,":boy_tone4:":128102,":boy_tone5:":128102,":girl_tone1:":128103,":girl_tone2:":128103,":girl_tone3:":128103,":girl_tone4:":128103,":girl_tone5:":128103,":man_tone1:":128104,":man_tone2:":128104,":man_tone3:":128104,":man_tone4:":128104,":man_tone5:":128104,":woman_tone1:":128105,":woman_tone2:":128105,":woman_tone3:":128105,":woman_tone4:":128105,":woman_tone5:":128105,":person_with_blond_hair_tone1:":128113,":person_with_blond_hair_tone2:":128113,":person_with_blond_hair_tone3:":128113,":person_with_blond_hair_tone4:":128113,":person_with_blond_hair_tone5:":128113,":older_man_tone1:":128116,":older_man_tone2:":128116,":older_man_tone3:":128116,":older_man_tone4:":128116,":older_man_tone5:":128116,":older_woman_tone1:":128117,":older_woman_tone2:":128117,":older_woman_tone3:":128117,":older_woman_tone4:":128117,":older_woman_tone5:":128117,":man_with_gua_pi_mao_tone1:":128114,":man_with_gua_pi_mao_tone2:":128114,":man_with_gua_pi_mao_tone3:":128114,":man_with_gua_pi_mao_tone4:":128114,":man_with_gua_pi_mao_tone5:":128114,":man_with_turban_tone1:":128115,":man_with_turban_tone2:":128115,":man_with_turban_tone3:":128115,":man_with_turban_tone4:":128115,":man_with_turban_tone5:":128115,":cop_tone1:":128110,":cop_tone2:":128110,":cop_tone3:":128110,":cop_tone4:":128110,":cop_tone5:":128110,":construction_worker_tone1:":128119,":construction_worker_tone2:":128119,":construction_worker_tone3:":128119,":construction_worker_tone4:":128119,":construction_worker_tone5:":128119,":guardsman_tone1:":128130,":guardsman_tone2:":128130,":guardsman_tone3:":128130,":guardsman_tone4:":128130,":guardsman_tone5:":128130,":santa_tone1:":127877,":santa_tone2:":127877,":santa_tone3:":127877,":santa_tone4:":127877,":santa_tone5:":127877,":angel_tone1:":128124,":angel_tone2:":128124,":angel_tone3:":128124,":angel_tone4:":128124,":angel_tone5:":128124,":princess_tone1:":128120,":princess_tone2:":128120,":princess_tone3:":128120,":princess_tone4:":128120,":princess_tone5:":128120,":bride_with_veil_tone1:":128112,":bride_with_veil_tone2:":128112,":bride_with_veil_tone3:":128112,":bride_with_veil_tone4:":128112,":bride_with_veil_tone5:":128112,":walking_tone1:":128694,":walking_tone2:":128694,":walking_tone3:":128694,":walking_tone4:":128694,":walking_tone5:":128694,":runner_tone1:":127939,":runner_tone2:":127939,":runner_tone3:":127939,":runner_tone4:":127939,":runner_tone5:":127939,":dancer_tone1:":128131,":dancer_tone2:":128131,":dancer_tone3:":128131,":dancer_tone4:":128131,":dancer_tone5:":128131,":bow_tone1:":128583,":bow_tone2:":128583,":bow_tone3:":128583,":bow_tone4:":128583,":bow_tone5:":128583,":information_desk_person_tone1:":128129,":information_desk_person_tone2:":128129,":information_desk_person_tone3:":128129,":information_desk_person_tone4:":128129,":information_desk_person_tone5:":128129,":no_good_tone1:":128581,":no_good_tone2:":128581,":no_good_tone3:":128581,":no_good_tone4:":128581,":no_good_tone5:":128581,":ok_woman_tone1:":128582,":ok_woman_tone2:":128582,":ok_woman_tone3:":128582,":ok_woman_tone4:":128582,":ok_woman_tone5:":128582,":raising_hand_tone1:":128587,":raising_hand_tone2:":128587,":raising_hand_tone3:":128587,":raising_hand_tone4:":128587,":raising_hand_tone5:":128587,":person_with_pouting_face_tone1:":128590,":person_with_pouting_face_tone2:":128590,":person_with_pouting_face_tone3:":128590,":person_with_pouting_face_tone4:":128590,":person_with_pouting_face_tone5:":128590,":person_frowning_tone1:":128589,":person_frowning_tone2:":128589,":person_frowning_tone3:":128589,":person_frowning_tone4:":128589,":person_frowning_tone5:":128589,":haircut_tone1:":128135,":haircut_tone2:":128135,":haircut_tone3:":128135,":haircut_tone4:":128135,":haircut_tone5:":128135,":massage_tone1:":128134,":massage_tone2:":128134,":massage_tone3:":128134,":massage_tone4:":128134,":massage_tone5:":128134,":rowboat_tone1:":128675,":rowboat_tone2:":128675,":rowboat_tone3:":128675,":rowboat_tone4:":128675,":rowboat_tone5:":128675,":swimmer_tone1:":127946,":swimmer_tone2:":127946,":swimmer_tone3:":127946,":swimmer_tone4:":127946,":swimmer_tone5:":127946,":surfer_tone1:":127940,":surfer_tone2:":127940,":surfer_tone3:":127940,":surfer_tone4:":127940,":surfer_tone5:":127940,":bath_tone1:":128704,":bath_tone2:":128704,":bath_tone3:":128704,":bath_tone4:":128704,":bath_tone5:":128704,":basketball_player_tone1:":9977,":basketball_player_tone2:":9977,":basketball_player_tone3:":9977,":basketball_player_tone4:":9977,":basketball_player_tone5:":9977,":lifter_tone1:":127947,":lifter_tone2:":127947,":lifter_tone3:":127947,":lifter_tone4:":127947,":lifter_tone5:":127947,":bicyclist_tone1:":128692,":bicyclist_tone2:":128692,":bicyclist_tone3:":128692,":bicyclist_tone4:":128692,":bicyclist_tone5:":128692,":mountain_bicyclist_tone1:":128693,":mountain_bicyclist_tone2:":128693,":mountain_bicyclist_tone3:":128693,":mountain_bicyclist_tone4:":128693,":mountain_bicyclist_tone5:":128693,":horse_racing_tone1:":127943,":horse_racing_tone2:":127943,":horse_racing_tone3:":127943,":horse_racing_tone4:":127943,":horse_racing_tone5:":127943,":spy_tone1:":128373,":spy_tone2:":128373,":spy_tone3:":128373,":spy_tone4:":128373,":spy_tone5:":128373},"categories":[{"id":"people","name":"Smilies & People","emoji":[":grinning:",":grimacing:",":grin:",":joy:",":smiley:",":smile:",":sweat_smile:",":laughing:",":innocent:",":wink:",":blush:",":slight_smile:",":upside_down:",":relaxed:",":yum:",":relieved:",":heart_eyes:",":kissing_heart:",":kissing:",":kissing_smiling_eyes:",":kissing_closed_eyes:",":stuck_out_tongue_winking_eye:",":stuck_out_tongue_closed_eyes:",":stuck_out_tongue:",":money_mouth:",":nerd:",":sunglasses:",":hugging:",":smirk:",":no_mouth:",":neutral_face:",":expressionless:",":unamused:",":rolling_eyes:",":thinking:",":flushed:",":disappointed:",":worried:",":angry:",":rage:",":pensive:",":confused:",":slight_frown:",":frowning2:",":persevere:",":confounded:",":tired_face:",":weary:",":triumph:",":open_mouth:",":scream:",":fearful:",":cold_sweat:",":hushed:",":frowning:",":anguished:",":cry:",":disappointed_relieved:",":sleepy:",":sweat:",":sob:",":dizzy_face:",":astonished:",":zipper_mouth:",":mask:",":thermometer_face:",":head_bandage:",":sleeping:",":zzz:",":poop:",":smiling_imp:",":imp:",":japanese_ogre:",":japanese_goblin:",":skull:",":ghost:",":alien:",":robot:",":smiley_cat:",":smile_cat:",":joy_cat:",":heart_eyes_cat:",":smirk_cat:",":kissing_cat:",":scream_cat:",":crying_cat_face:",":pouting_cat:",":raised_hands:",":clap:",":wave:",":thumbsup:",":thumbsdown:",":punch:",":fist:",":v:",":ok_hand:",":raised_hand:",":open_hands:",":muscle:",":pray:",":point_up:",":point_up_2:",":point_down:",":point_left:",":point_right:",":middle_finger:",":hand_splayed:",":metal:",":vulcan:",":writing_hand:",":nail_care:",":lips:",":tongue:",":ear:",":nose:",":eye:",":eyes:",":bust_in_silhouette:",":busts_in_silhouette:",":speaking_head:",":baby:",":boy:",":girl:",":man:",":woman:",":person_with_blond_hair:",":older_man:",":older_woman:",":man_with_gua_pi_mao:",":man_with_turban:",":cop:",":construction_worker:",":guardsman:",":spy:",":santa:",":angel:",":princess:",":bride_with_veil:",":walking:",":runner:",":dancer:",":dancers:",":couple:",":two_men_holding_hands:",":two_women_holding_hands:",":bow:",":information_desk_person:",":no_good:",":ok_woman:",":raising_hand:",":person_with_pouting_face:",":person_frowning:",":haircut:",":massage:",":couple_with_heart:",":couple_ww:",":couple_mm:",":couplekiss:",":kiss_ww:",":kiss_mm:",":family:",":family_mwg:",":family_mwgb:",":family_mwbb:",":family_mwgg:",":family_wwb:",":family_wwg:",":family_wwgb:",":family_wwbb:",":family_wwgg:",":family_mmb:",":family_mmg:",":family_mmgb:",":family_mmbb:",":family_mmgg:",":womans_clothes:",":shirt:",":jeans:",":necktie:",":dress:",":bikini:",":kimono:",":lipstick:",":kiss:",":footprints:",":high_heel:",":sandal:",":boot:",":mans_shoe:",":athletic_shoe:",":womans_hat:",":tophat:",":helmet_with_cross:",":mortar_board:",":crown:",":school_satchel:",":pouch:",":purse:",":handbag:",":briefcase:",":eyeglasses:",":dark_sunglasses:",":ring:",":closed_umbrella:",":raised_hands_tone1:",":raised_hands_tone2:",":raised_hands_tone3:",":raised_hands_tone4:",":raised_hands_tone5:",":clap_tone1:",":clap_tone2:",":clap_tone3:",":clap_tone4:",":clap_tone5:",":wave_tone1:",":wave_tone2:",":wave_tone3:",":wave_tone4:",":wave_tone5:",":thumbsup_tone1:",":thumbsup_tone2:",":thumbsup_tone3:",":thumbsup_tone4:",":thumbsup_tone5:",":thumbsdown_tone1:",":thumbsdown_tone2:",":thumbsdown_tone3:",":thumbsdown_tone4:",":thumbsdown_tone5:",":punch_tone1:",":punch_tone2:",":punch_tone3:",":punch_tone4:",":punch_tone5:",":fist_tone1:",":fist_tone2:",":fist_tone3:",":fist_tone4:",":fist_tone5:",":v_tone1:",":v_tone2:",":v_tone3:",":v_tone4:",":v_tone5:",":ok_hand_tone1:",":ok_hand_tone2:",":ok_hand_tone3:",":ok_hand_tone4:",":ok_hand_tone5:",":raised_hand_tone1:",":raised_hand_tone2:",":raised_hand_tone3:",":raised_hand_tone4:",":raised_hand_tone5:",":open_hands_tone1:",":open_hands_tone2:",":open_hands_tone3:",":open_hands_tone4:",":open_hands_tone5:",":muscle_tone1:",":muscle_tone2:",":muscle_tone3:",":muscle_tone4:",":muscle_tone5:",":pray_tone1:",":pray_tone2:",":pray_tone3:",":pray_tone4:",":pray_tone5:",":point_up_tone1:",":point_up_tone2:",":point_up_tone3:",":point_up_tone4:",":point_up_tone5:",":point_up_2_tone1:",":point_up_2_tone2:",":point_up_2_tone3:",":point_up_2_tone4:",":point_up_2_tone5:",":point_down_tone1:",":point_down_tone2:",":point_down_tone3:",":point_down_tone4:",":point_down_tone5:",":point_left_tone1:",":point_left_tone2:",":point_left_tone3:",":point_left_tone4:",":point_left_tone5:",":point_right_tone1:",":point_right_tone2:",":point_right_tone3:",":point_right_tone4:",":point_right_tone5:",":middle_finger_tone1:",":middle_finger_tone2:",":middle_finger_tone3:",":middle_finger_tone4:",":middle_finger_tone5:",":hand_splayed_tone1:",":hand_splayed_tone2:",":hand_splayed_tone3:",":hand_splayed_tone4:",":hand_splayed_tone5:",":metal_tone1:",":metal_tone2:",":metal_tone3:",":metal_tone4:",":metal_tone5:",":vulcan_tone1:",":vulcan_tone2:",":vulcan_tone3:",":vulcan_tone4:",":vulcan_tone5:",":writing_hand_tone1:",":writing_hand_tone2:",":writing_hand_tone3:",":writing_hand_tone4:",":writing_hand_tone5:",":nail_care_tone1:",":nail_care_tone2:",":nail_care_tone3:",":nail_care_tone4:",":nail_care_tone5:",":ear_tone1:",":ear_tone2:",":ear_tone3:",":ear_tone4:",":ear_tone5:",":nose_tone1:",":nose_tone2:",":nose_tone3:",":nose_tone4:",":nose_tone5:",":baby_tone1:",":baby_tone2:",":baby_tone3:",":baby_tone4:",":baby_tone5:",":boy_tone1:",":boy_tone2:",":boy_tone3:",":boy_tone4:",":boy_tone5:",":girl_tone1:",":girl_tone2:",":girl_tone3:",":girl_tone4:",":girl_tone5:",":man_tone1:",":man_tone2:",":man_tone3:",":man_tone4:",":man_tone5:",":woman_tone1:",":woman_tone2:",":woman_tone3:",":woman_tone4:",":woman_tone5:",":person_with_blond_hair_tone1:",":person_with_blond_hair_tone2:",":person_with_blond_hair_tone3:",":person_with_blond_hair_tone4:",":person_with_blond_hair_tone5:",":older_man_tone1:",":older_man_tone2:",":older_man_tone3:",":older_man_tone4:",":older_man_tone5:",":older_woman_tone1:",":older_woman_tone2:",":older_woman_tone3:",":older_woman_tone4:",":older_woman_tone5:",":man_with_gua_pi_mao_tone1:",":man_with_gua_pi_mao_tone2:",":man_with_gua_pi_mao_tone3:",":man_with_gua_pi_mao_tone4:",":man_with_gua_pi_mao_tone5:",":man_with_turban_tone1:",":man_with_turban_tone2:",":man_with_turban_tone3:",":man_with_turban_tone4:",":man_with_turban_tone5:",":cop_tone1:",":cop_tone2:",":cop_tone3:",":cop_tone4:",":cop_tone5:",":construction_worker_tone1:",":construction_worker_tone2:",":construction_worker_tone3:",":construction_worker_tone4:",":construction_worker_tone5:",":guardsman_tone1:",":guardsman_tone2:",":guardsman_tone3:",":guardsman_tone4:",":guardsman_tone5:",":santa_tone1:",":santa_tone2:",":santa_tone3:",":santa_tone4:",":santa_tone5:",":angel_tone1:",":angel_tone2:",":angel_tone3:",":angel_tone4:",":angel_tone5:",":princess_tone1:",":princess_tone2:",":princess_tone3:",":princess_tone4:",":princess_tone5:",":bride_with_veil_tone1:",":bride_with_veil_tone2:",":bride_with_veil_tone3:",":bride_with_veil_tone4:",":bride_with_veil_tone5:",":walking_tone1:",":walking_tone2:",":walking_tone3:",":walking_tone4:",":walking_tone5:",":runner_tone1:",":runner_tone2:",":runner_tone3:",":runner_tone4:",":runner_tone5:",":dancer_tone1:",":dancer_tone2:",":dancer_tone3:",":dancer_tone4:",":dancer_tone5:",":bow_tone1:",":bow_tone2:",":bow_tone3:",":bow_tone4:",":bow_tone5:",":information_desk_person_tone1:",":information_desk_person_tone2:",":information_desk_person_tone3:",":information_desk_person_tone4:",":information_desk_person_tone5:",":no_good_tone1:",":no_good_tone2:",":no_good_tone3:",":no_good_tone4:",":no_good_tone5:",":ok_woman_tone1:",":ok_woman_tone2:",":ok_woman_tone3:",":ok_woman_tone4:",":ok_woman_tone5:",":raising_hand_tone1:",":raising_hand_tone2:",":raising_hand_tone3:",":raising_hand_tone4:",":raising_hand_tone5:",":person_with_pouting_face_tone1:",":person_with_pouting_face_tone2:",":person_with_pouting_face_tone3:",":person_with_pouting_face_tone4:",":person_with_pouting_face_tone5:",":person_frowning_tone1:",":person_frowning_tone2:",":person_frowning_tone3:",":person_frowning_tone4:",":person_frowning_tone5:",":haircut_tone1:",":haircut_tone2:",":haircut_tone3:",":haircut_tone4:",":haircut_tone5:",":massage_tone1:",":massage_tone2:",":massage_tone3:",":massage_tone4:",":massage_tone5:",":spy_tone1:",":spy_tone2:",":spy_tone3:",":spy_tone4:",":spy_tone5:"]},{"id":"nature","name":"Animals & Nature","emoji":[":dog:",":cat:",":mouse:",":hamster:",":rabbit:",":bear:",":panda_face:",":koala:",":tiger:",":lion_face:",":cow:",":pig:",":pig_nose:",":frog:",":octopus:",":monkey_face:",":see_no_evil:",":hear_no_evil:",":speak_no_evil:",":monkey:",":chicken:",":penguin:",":bird:",":baby_chick:",":hatching_chick:",":hatched_chick:",":wolf:",":boar:",":horse:",":unicorn:",":bee:",":bug:",":snail:",":beetle:",":ant:",":spider:",":scorpion:",":crab:",":snake:",":turtle:",":tropical_fish:",":fish:",":blowfish:",":dolphin:",":whale:",":whale2:",":crocodile:",":leopard:",":tiger2:",":water_buffalo:",":ox:",":cow2:",":dromedary_camel:",":camel:",":elephant:",":goat:",":ram:",":sheep:",":racehorse:",":pig2:",":rat:",":mouse2:",":rooster:",":turkey:",":dove:",":dog2:",":poodle:",":cat2:",":rabbit2:",":chipmunk:",":feet:",":dragon:",":dragon_face:",":cactus:",":christmas_tree:",":evergreen_tree:",":deciduous_tree:",":palm_tree:",":seedling:",":herb:",":shamrock:",":four_leaf_clover:",":bamboo:",":tanabata_tree:",":leaves:",":fallen_leaf:",":maple_leaf:",":ear_of_rice:",":hibiscus:",":sunflower:",":rose:",":tulip:",":blossom:",":cherry_blossom:",":bouquet:",":mushroom:",":chestnut:",":jack_o_lantern:",":shell:",":spider_web:",":earth_americas:",":earth_africa:",":earth_asia:",":full_moon:",":waning_gibbous_moon:",":last_quarter_moon:",":waning_crescent_moon:",":new_moon:",":waxing_crescent_moon:",":first_quarter_moon:",":waxing_gibbous_moon:",":new_moon_with_face:",":full_moon_with_face:",":first_quarter_moon_with_face:",":last_quarter_moon_with_face:",":sun_with_face:",":crescent_moon:",":star:",":star2:",":dizzy:",":sparkles:",":comet:",":sunny:",":white_sun_small_cloud:",":partly_sunny:",":white_sun_cloud:",":white_sun_rain_cloud:",":cloud:",":cloud_rain:",":thunder_cloud_rain:",":cloud_lightning:",":zap:",":fire:",":boom:",":snowflake:",":cloud_snow:",":snowman2:",":snowman:",":wind_blowing_face:",":dash:",":cloud_tornado:",":fog:",":umbrella2:",":umbrella:",":droplet:",":sweat_drops:",":ocean:"]},{"id":"food","name":"Food & Drink","emoji":[":green_apple:",":apple:",":pear:",":tangerine:",":lemon:",":banana:",":watermelon:",":grapes:",":strawberry:",":melon:",":cherries:",":peach:",":pineapple:",":tomato:",":eggplant:",":hot_pepper:",":corn:",":sweet_potato:",":honey_pot:",":bread:",":cheese:",":poultry_leg:",":meat_on_bone:",":fried_shrimp:",":egg:",":hamburger:",":fries:",":hotdog:",":pizza:",":spaghetti:",":taco:",":burrito:",":ramen:",":stew:",":fish_cake:",":sushi:",":bento:",":curry:",":rice_ball:",":rice:",":rice_cracker:",":oden:",":dango:",":shaved_ice:",":ice_cream:",":icecream:",":cake:",":birthday:",":custard:",":candy:",":lollipop:",":chocolate_bar:",":popcorn:",":doughnut:",":cookie:",":beer:",":beers:",":wine_glass:",":cocktail:",":tropical_drink:",":champagne:",":sake:",":tea:",":coffee:",":baby_bottle:",":fork_and_knife:",":fork_knife_plate:"]},{"id":"activity","name":"Activity","emoji":[":soccer:",":basketball:",":football:",":baseball:",":tennis:",":volleyball:",":rugby_football:",":8ball:",":golf:",":golfer:",":ping_pong:",":badminton:",":hockey:",":field_hockey:",":cricket:",":ski:",":skier:",":snowboarder:",":ice_skate:",":bow_and_arrow:",":fishing_pole_and_fish:",":rowboat:",":swimmer:",":surfer:",":bath:",":basketball_player:",":lifter:",":bicyclist:",":mountain_bicyclist:",":horse_racing:",":levitate:",":trophy:",":running_shirt_with_sash:",":medal:",":military_medal:",":reminder_ribbon:",":rosette:",":ticket:",":tickets:",":performing_arts:",":art:",":circus_tent:",":microphone:",":headphones:",":musical_score:",":musical_keyboard:",":saxophone:",":trumpet:",":guitar:",":violin:",":clapper:",":video_game:",":space_invader:",":dart:",":game_die:",":slot_machine:",":bowling:",":rowboat_tone1:",":rowboat_tone2:",":rowboat_tone3:",":rowboat_tone4:",":rowboat_tone5:",":swimmer_tone1:",":swimmer_tone2:",":swimmer_tone3:",":swimmer_tone4:",":swimmer_tone5:",":surfer_tone1:",":surfer_tone2:",":surfer_tone3:",":surfer_tone4:",":surfer_tone5:",":bath_tone1:",":bath_tone2:",":bath_tone3:",":bath_tone4:",":bath_tone5:",":basketball_player_tone1:",":basketball_player_tone2:",":basketball_player_tone3:",":basketball_player_tone4:",":basketball_player_tone5:",":lifter_tone1:",":lifter_tone2:",":lifter_tone3:",":lifter_tone4:",":lifter_tone5:",":bicyclist_tone1:",":bicyclist_tone2:",":bicyclist_tone3:",":bicyclist_tone4:",":bicyclist_tone5:",":mountain_bicyclist_tone1:",":mountain_bicyclist_tone2:",":mountain_bicyclist_tone3:",":mountain_bicyclist_tone4:",":mountain_bicyclist_tone5:",":horse_racing_tone1:",":horse_racing_tone2:",":horse_racing_tone3:",":horse_racing_tone4:",":horse_racing_tone5:"]},{"id":"travel","name":"Travel & Places","emoji":[":red_car:",":taxi:",":blue_car:",":bus:",":trolleybus:",":race_car:",":police_car:",":ambulance:",":fire_engine:",":minibus:",":truck:",":articulated_lorry:",":tractor:",":motorcycle:",":bike:",":rotating_light:",":oncoming_police_car:",":oncoming_bus:",":oncoming_automobile:",":oncoming_taxi:",":aerial_tramway:",":mountain_cableway:",":suspension_railway:",":railway_car:",":train:",":monorail:",":bullettrain_side:",":bullettrain_front:",":light_rail:",":mountain_railway:",":steam_locomotive:",":train2:",":metro:",":tram:",":station:",":helicopter:",":airplane_small:",":airplane:",":airplane_departure:",":airplane_arriving:",":sailboat:",":motorboat:",":speedboat:",":ferry:",":cruise_ship:",":rocket:",":satellite_orbital:",":seat:",":anchor:",":construction:",":fuelpump:",":busstop:",":vertical_traffic_light:",":traffic_light:",":checkered_flag:",":ship:",":ferris_wheel:",":roller_coaster:",":carousel_horse:",":construction_site:",":foggy:",":tokyo_tower:",":factory:",":fountain:",":rice_scene:",":mountain:",":mountain_snow:",":mount_fuji:",":volcano:",":japan:",":camping:",":tent:",":park:",":motorway:",":railway_track:",":sunrise:",":sunrise_over_mountains:",":desert:",":beach:",":island:",":city_sunset:",":city_dusk:",":cityscape:",":night_with_stars:",":bridge_at_night:",":milky_way:",":stars:",":sparkler:",":fireworks:",":rainbow:",":homes:",":european_castle:",":japanese_castle:",":stadium:",":statue_of_liberty:",":house:",":house_with_garden:",":house_abandoned:",":office:",":department_store:",":post_office:",":european_post_office:",":hospital:",":bank:",":hotel:",":convenience_store:",":school:",":love_hotel:",":wedding:",":classical_building:",":church:",":mosque:",":synagogue:",":kaaba:",":shinto_shrine:"]},{"id":"objects","name":"Objects","emoji":[":watch:",":iphone:",":calling:",":computer:",":keyboard:",":desktop:",":printer:",":mouse_three_button:",":trackball:",":joystick:",":compression:",":minidisc:",":floppy_disk:",":cd:",":dvd:",":vhs:",":camera:",":camera_with_flash:",":video_camera:",":movie_camera:",":projector:",":film_frames:",":telephone_receiver:",":telephone:",":pager:",":fax:",":tv:",":radio:",":microphone2:",":level_slider:",":control_knobs:",":stopwatch:",":timer:",":alarm_clock:",":clock:",":hourglass_flowing_sand:",":hourglass:",":satellite:",":battery:",":electric_plug:",":bulb:",":flashlight:",":candle:",":wastebasket:",":oil:",":money_with_wings:",":dollar:",":yen:",":euro:",":pound:",":moneybag:",":credit_card:",":gem:",":scales:",":wrench:",":hammer:",":hammer_pick:",":tools:",":pick:",":nut_and_bolt:",":gear:",":chains:",":gun:",":bomb:",":knife:",":dagger:",":crossed_swords:",":shield:",":smoking:",":skull_crossbones:",":coffin:",":urn:",":amphora:",":crystal_ball:",":prayer_beads:",":barber:",":alembic:",":telescope:",":microscope:",":hole:",":pill:",":syringe:",":thermometer:",":label:",":bookmark:",":toilet:",":shower:",":bathtub:",":key:",":key2:",":couch:",":sleeping_accommodation:",":bed:",":door:",":bellhop:",":frame_photo:",":map:",":beach_umbrella:",":moyai:",":shopping_bags:",":balloon:",":flags:",":ribbon:",":gift:",":confetti_ball:",":tada:",":dolls:",":wind_chime:",":crossed_flags:",":izakaya_lantern:",":envelope:",":envelope_with_arrow:",":incoming_envelope:",":e-mail:",":love_letter:",":postbox:",":mailbox_closed:",":mailbox:",":mailbox_with_mail:",":mailbox_with_no_mail:",":package:",":postal_horn:",":inbox_tray:",":outbox_tray:",":scroll:",":page_with_curl:",":bookmark_tabs:",":bar_chart:",":chart_with_upwards_trend:",":chart_with_downwards_trend:",":page_facing_up:",":date:",":calendar:",":calendar_spiral:",":card_index:",":card_box:",":ballot_box:",":file_cabinet:",":clipboard:",":notepad_spiral:",":file_folder:",":open_file_folder:",":dividers:",":newspaper2:",":newspaper:",":notebook:",":closed_book:",":green_book:",":blue_book:",":orange_book:",":notebook_with_decorative_cover:",":ledger:",":books:",":book:",":link:",":paperclip:",":paperclips:",":scissors:",":triangular_ruler:",":straight_ruler:",":pushpin:",":round_pushpin:",":triangular_flag_on_post:",":flag_white:",":flag_black:",":closed_lock_with_key:",":lock:",":unlock:",":lock_with_ink_pen:",":pen_ballpoint:",":pen_fountain:",":black_nib:",":pencil:",":pencil2:",":crayon:",":paintbrush:",":mag:",":mag_right:"]},{"id":"symbols","name":"Symbols","emoji":[":heart:",":yellow_heart:",":green_heart:",":blue_heart:",":purple_heart:",":broken_heart:",":heart_exclamation:",":two_hearts:",":revolving_hearts:",":heartbeat:",":heartpulse:",":sparkling_heart:",":cupid:",":gift_heart:",":heart_decoration:",":peace:",":cross:",":star_and_crescent:",":om_symbol:",":wheel_of_dharma:",":star_of_david:",":six_pointed_star:",":menorah:",":yin_yang:",":orthodox_cross:",":place_of_worship:",":ophiuchus:",":aries:",":taurus:",":gemini:",":cancer:",":leo:",":virgo:",":libra:",":scorpius:",":sagittarius:",":capricorn:",":aquarius:",":pisces:",":id:",":atom:",":u7a7a:",":u5272:",":radioactive:",":biohazard:",":mobile_phone_off:",":vibration_mode:",":u6709:",":u7121:",":u7533:",":u55b6:",":u6708:",":eight_pointed_black_star:",":vs:",":accept:",":white_flower:",":ideograph_advantage:",":secret:",":congratulations:",":u5408:",":u6e80:",":u7981:",":a:",":b:",":ab:",":cl:",":o2:",":sos:",":no_entry:",":name_badge:",":no_entry_sign:",":x:",":o:",":anger:",":hotsprings:",":no_pedestrians:",":do_not_litter:",":no_bicycles:",":non-potable_water:",":underage:",":no_mobile_phones:",":exclamation:",":grey_exclamation:",":question:",":grey_question:",":bangbang:",":interrobang:",":100:",":low_brightness:",":high_brightness:",":trident:",":fleur-de-lis:",":part_alternation_mark:",":warning:",":children_crossing:",":beginner:",":recycle:",":u6307:",":chart:",":sparkle:",":eight_spoked_asterisk:",":negative_squared_cross_mark:",":white_check_mark:",":diamond_shape_with_a_dot_inside:",":cyclone:",":loop:",":globe_with_meridians:",":m:",":atm:",":sa:",":passport_control:",":customs:",":baggage_claim:",":left_luggage:",":wheelchair:",":no_smoking:",":wc:",":parking:",":potable_water:",":mens:",":womens:",":baby_symbol:",":restroom:",":put_litter_in_its_place:",":cinema:",":signal_strength:",":koko:",":ng:",":ok:",":up:",":cool:",":new:",":free:",":zero:",":one:",":two:",":three:",":four:",":five:",":six:",":seven:",":eight:",":nine:",":ten:",":1234:",":arrow_forward:",":pause_button:",":play_pause:",":stop_button:",":record_button:",":track_next:",":track_previous:",":fast_forward:",":rewind:",":twisted_rightwards_arrows:",":repeat:",":repeat_one:",":arrow_backward:",":arrow_up_small:",":arrow_down_small:",":arrow_double_up:",":arrow_double_down:",":arrow_right:",":arrow_left:",":arrow_up:",":arrow_down:",":arrow_upper_right:",":arrow_lower_right:",":arrow_lower_left:",":arrow_upper_left:",":arrow_up_down:",":left_right_arrow:",":arrows_counterclockwise:",":arrow_right_hook:",":leftwards_arrow_with_hook:",":arrow_heading_up:",":arrow_heading_down:",":hash:",":asterisk:",":information_source:",":abc:",":abcd:",":capital_abcd:",":symbols:",":musical_note:",":notes:",":wavy_dash:",":curly_loop:",":heavy_check_mark:",":arrows_clockwise:",":heavy_plus_sign:",":heavy_minus_sign:",":heavy_division_sign:",":heavy_multiplication_x:",":heavy_dollar_sign:",":currency_exchange:",":copyright:",":registered:",":tm:",":end:",":back:",":on:",":top:",":soon:",":ballot_box_with_check:",":radio_button:",":white_circle:",":black_circle:",":red_circle:",":large_blue_circle:",":small_orange_diamond:",":small_blue_diamond:",":large_orange_diamond:",":large_blue_diamond:",":small_red_triangle:",":black_small_square:",":white_small_square:",":black_large_square:",":white_large_square:",":small_red_triangle_down:",":black_medium_square:",":white_medium_square:",":black_medium_small_square:",":white_medium_small_square:",":black_square_button:",":white_square_button:",":speaker:",":sound:",":loud_sound:",":mute:",":mega:",":loudspeaker:",":bell:",":no_bell:",":black_joker:",":mahjong:",":spades:",":clubs:",":hearts:",":diamonds:",":flower_playing_cards:",":thought_balloon:",":anger_right:",":speech_balloon:",":clock1:",":clock2:",":clock3:",":clock4:",":clock5:",":clock6:",":clock7:",":clock8:",":clock9:",":clock10:",":clock11:",":clock12:",":clock130:",":clock230:",":clock330:",":clock430:",":clock530:",":clock630:",":clock730:",":clock830:",":clock930:",":clock1030:",":clock1130:",":clock1230:",":eye_in_speech_bubble:"]},{"id":"flags","name":"Flags","emoji":[":flag_ac:",":flag_af:",":flag_al:",":flag_dz:",":flag_ad:",":flag_ao:",":flag_ai:",":flag_ag:",":flag_ar:",":flag_am:",":flag_aw:",":flag_au:",":flag_at:",":flag_az:",":flag_bs:",":flag_bh:",":flag_bd:",":flag_bb:",":flag_by:",":flag_be:",":flag_bz:",":flag_bj:",":flag_bm:",":flag_bt:",":flag_bo:",":flag_ba:",":flag_bw:",":flag_br:",":flag_bn:",":flag_bg:",":flag_bf:",":flag_bi:",":flag_cv:",":flag_kh:",":flag_cm:",":flag_ca:",":flag_ky:",":flag_cf:",":flag_td:",":flag_cl:",":flag_cn:",":flag_co:",":flag_km:",":flag_cg:",":flag_cd:",":flag_cr:",":flag_hr:",":flag_cu:",":flag_cy:",":flag_cz:",":flag_dk:",":flag_dj:",":flag_dm:",":flag_do:",":flag_ec:",":flag_eg:",":flag_sv:",":flag_gq:",":flag_er:",":flag_ee:",":flag_et:",":flag_fk:",":flag_fo:",":flag_fj:",":flag_fi:",":flag_fr:",":flag_pf:",":flag_ga:",":flag_gm:",":flag_ge:",":flag_de:",":flag_gh:",":flag_gi:",":flag_gr:",":flag_gl:",":flag_gd:",":flag_gu:",":flag_gt:",":flag_gn:",":flag_gw:",":flag_gy:",":flag_ht:",":flag_hn:",":flag_hk:",":flag_hu:",":flag_is:",":flag_in:",":flag_id:",":flag_ir:",":flag_iq:",":flag_ie:",":flag_il:",":flag_it:",":flag_ci:",":flag_jm:",":flag_jp:",":flag_je:",":flag_jo:",":flag_kz:",":flag_ke:",":flag_ki:",":flag_xk:",":flag_kw:",":flag_kg:",":flag_la:",":flag_lv:",":flag_lb:",":flag_ls:",":flag_lr:",":flag_ly:",":flag_li:",":flag_lt:",":flag_lu:",":flag_mo:",":flag_mk:",":flag_mg:",":flag_mw:",":flag_my:",":flag_mv:",":flag_ml:",":flag_mt:",":flag_mh:",":flag_mr:",":flag_mu:",":flag_mx:",":flag_fm:",":flag_md:",":flag_mc:",":flag_mn:",":flag_me:",":flag_ms:",":flag_ma:",":flag_mz:",":flag_mm:",":flag_na:",":flag_nr:",":flag_np:",":flag_nl:",":flag_nc:",":flag_nz:",":flag_ni:",":flag_ne:",":flag_ng:",":flag_nu:",":flag_kp:",":flag_no:",":flag_om:",":flag_pk:",":flag_pw:",":flag_ps:",":flag_pa:",":flag_pg:",":flag_py:",":flag_pe:",":flag_ph:",":flag_pl:",":flag_pt:",":flag_pr:",":flag_qa:",":flag_ro:",":flag_ru:",":flag_rw:",":flag_sh:",":flag_kn:",":flag_lc:",":flag_vc:",":flag_ws:",":flag_sm:",":flag_st:",":flag_sa:",":flag_sn:",":flag_rs:",":flag_sc:",":flag_sl:",":flag_sg:",":flag_sk:",":flag_si:",":flag_sb:",":flag_so:",":flag_za:",":flag_kr:",":flag_es:",":flag_lk:",":flag_sd:",":flag_sr:",":flag_sz:",":flag_se:",":flag_ch:",":flag_sy:",":flag_tw:",":flag_tj:",":flag_tz:",":flag_th:",":flag_tl:",":flag_tg:",":flag_to:",":flag_tt:",":flag_tn:",":flag_tr:",":flag_tm:",":flag_tv:",":flag_ug:",":flag_ua:",":flag_ae:",":flag_gb:",":flag_us:",":flag_vi:",":flag_uy:",":flag_uz:",":flag_vu:",":flag_va:",":flag_ve:",":flag_vn:",":flag_wf:",":flag_eh:",":flag_ye:",":flag_zm:",":flag_zw:",":flag_re:",":flag_ax:",":flag_ta:",":flag_io:",":flag_bq:",":flag_cx:",":flag_cc:",":flag_gg:",":flag_im:",":flag_yt:",":flag_nf:",":flag_pn:",":flag_bl:",":flag_pm:",":flag_gs:",":flag_tk:",":flag_bv:",":flag_hm:",":flag_sj:",":flag_um:",":flag_ic:",":flag_ea:",":flag_cp:",":flag_dg:",":flag_as:",":flag_aq:",":flag_vg:",":flag_ck:",":flag_cw:",":flag_eu:",":flag_gf:",":flag_tf:",":flag_gp:",":flag_mq:",":flag_mp:",":flag_sx:",":flag_ss:",":flag_tc:",":flag_mf:"]}]} \ No newline at end of file diff --git a/frontend/src/metabase/lib/formatting.js b/frontend/src/metabase/lib/formatting.js index 7720718d7867d62c5e9b41f24c1223202939c556..12d4c08b43bb02525fb8a0504c6baceadf1f5673 100644 --- a/frontend/src/metabase/lib/formatting.js +++ b/frontend/src/metabase/lib/formatting.js @@ -31,6 +31,8 @@ export type FormattingOptions = { majorWidth?: number, type?: "axis" | "cell" | "tooltip", jsx?: boolean, + // render links for type/URLs, type/Email, etc + rich?: boolean, // number options: comma?: boolean, compact?: boolean, @@ -47,10 +49,14 @@ 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"); +const DECIMAL_DEGREES_FORMATTER_COMPACT = d3.format(".02f"); const BINNING_DEGREES_FORMATTER = (value, binWidth) => { return d3.format(`.0${decimalCount(binWidth)}f`)(value); }; +const getMonthFormat = options => (options.compact ? "MMM" : "MMMM"); +const getDayFormat = options => (options.compact ? "ddd" : "dddd"); + // use en dashes, for Maz const RANGE_SEPARATOR = ` – `; @@ -106,7 +112,9 @@ export function formatCoordinate( const formattedValue = binWidth ? BINNING_DEGREES_FORMATTER(value, binWidth) - : DECIMAL_DEGREES_FORMATTER(value); + : options.compact + ? DECIMAL_DEGREES_FORMATTER_COMPACT(value) + : DECIMAL_DEGREES_FORMATTER(value); return formattedValue + "°" + direction; } @@ -156,25 +164,29 @@ export function formatTimeRangeWithUnit( } // 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"; + const monthFormat = + options.type === "tooltip" ? "MMMM" : getMonthFormat(options); + const condensed = options.compact || options.type === "tooltip"; const start = m.clone().startOf(unit); const end = m.clone().endOf(unit); if (start.isValid() && end.isValid()) { if (!condensed || start.year() !== end.year()) { + // January 1, 2018 - January 2, 2019 return ( start.format(`${monthFormat} D, YYYY`) + RANGE_SEPARATOR + end.format(`${monthFormat} D, YYYY`) ); } else if (start.month() !== end.month()) { + // January 1 - Feburary 2, 2018 return ( start.format(`${monthFormat} D`) + RANGE_SEPARATOR + end.format(`${monthFormat} D, YYYY`) ); } else { + // January 1 - 2, 2018 return ( start.format(`${monthFormat} D`) + RANGE_SEPARATOR + @@ -206,11 +218,11 @@ export function formatTimeWithUnit( case "hour": // 12 AM - January 1, 2015 return formatMajorMinor( m.format("h A"), - m.format("MMMM D, YYYY"), + m.format(`${getMonthFormat(options)} D, YYYY`), options, ); case "day": // January 1, 2015 - return m.format("MMMM D, YYYY"); + return m.format(`${getMonthFormat(options)} D, YYYY`); case "week": // 1st - 2015 if (options.type === "tooltip") { // tooltip show range like "January 1 - 7, 2017" @@ -230,11 +242,11 @@ export function formatTimeWithUnit( case "month": // January 2015 return options.jsx ? ( <div> - <span className="text-bold">{m.format("MMMM")}</span>{" "} + <span className="text-bold">{m.format(getMonthFormat(options))}</span>{" "} {m.format("YYYY")} </div> ) : ( - m.format("MMMM") + " " + m.format("YYYY") + m.format(`${getMonthFormat(options)} YYYY`) ); case "year": // 2015 return m.format("YYYY"); @@ -243,36 +255,22 @@ export function formatTimeWithUnit( ...options, majorWidth: 0, }); + case "minute-of-hour": + return m.format("m"); case "hour-of-day": // 12 AM - return moment() - .hour(value) - .format("h A"); + return m.format("h A"); case "day-of-week": // Sunday - return ( - moment() - // $FlowFixMe: - .day(value - 1) - .format("dddd") - ); + return m.format(getDayFormat(options)); case "day-of-month": - return moment() - .date(value) - .format("D"); + return m.format("D"); + case "day-of-year": + return m.format("DDD"); case "week-of-year": // 1st - return moment() - .week(value) - .format("wo"); + return m.format("wo"); case "month-of-year": // January - return ( - moment() - // $FlowFixMe: - .month(value - 1) - .format("MMMM") - ); + return m.format(getMonthFormat(options)); case "quarter-of-year": // January - return moment() - .quarter(value) - .format("[Q]Q"); + return m.format("[Q]Q"); default: return m.format("LLLL"); } @@ -290,9 +288,12 @@ export function formatTimeValue(value: Value) { // 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: Value, { jsx }: FormattingOptions = {}) { +export function formatEmail( + value: Value, + { jsx, rich }: FormattingOptions = {}, +) { const email = String(value); - if (jsx && EMAIL_WHITELIST_REGEX.test(email)) { + if (jsx && rich && EMAIL_WHITELIST_REGEX.test(email)) { return <ExternalLink href={"mailto:" + email}>{email}</ExternalLink>; } else { return email; @@ -302,9 +303,9 @@ export function formatEmail(value: Value, { jsx }: FormattingOptions = {}) { // 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: Value, { jsx }: FormattingOptions = {}) { +export function formatUrl(value: Value, { jsx, rich }: FormattingOptions = {}) { const url = String(value); - if (jsx && URL_WHITELIST_REGEX.test(url)) { + if (jsx && rich && URL_WHITELIST_REGEX.test(url)) { return ( <ExternalLink className="link link--wrappable" href={url}> {url} diff --git a/frontend/src/metabase/lib/greeting.js b/frontend/src/metabase/lib/greeting.js index e280444fffc3205a0d73505469306f1aed69de97..966cfb3427fa9d88b33025ccbd94cda0ab3f5497 100644 --- a/frontend/src/metabase/lib/greeting.js +++ b/frontend/src/metabase/lib/greeting.js @@ -14,7 +14,7 @@ const subheadPrefixes = [ t`What do you want to find out?`, ]; -var Greeting = { +const Greeting = { simpleGreeting: function() { // TODO - this can result in an undefined thing const randomIndex = Math.floor( @@ -25,7 +25,7 @@ var Greeting = { sayHello: function(personalization) { if (personalization) { - var g = Greeting.simpleGreeting(); + let g = Greeting.simpleGreeting(); if (g === t`How's it going`) { return g + ", " + personalization + "?"; } else { diff --git a/frontend/src/metabase/lib/i18n.js b/frontend/src/metabase/lib/i18n.js index f6c3abf944b94e151bf96e688f33d4d5382ef9e9..d5b6ee32fa636eb085692e6313ad4a9a0aa8af1a 100644 --- a/frontend/src/metabase/lib/i18n.js +++ b/frontend/src/metabase/lib/i18n.js @@ -10,7 +10,20 @@ export async function loadLocalization(locale) { export function setLocalization(translationsObject) { const locale = translationsObject.headers.language; + addMsgIds(translationsObject); + // add and set locale with C-3PO addLocale(locale, translationsObject); useLocale(locale); } + +// we delete msgid property since it's redundant, but have to add it back in to +// make c-3po happy +function addMsgIds(translationsObject) { + const msgs = translationsObject.translations[""]; + for (const msgid in msgs) { + if (msgs[msgid].msgid === undefined) { + msgs[msgid].msgid = msgid; + } + } +} diff --git a/frontend/src/metabase/lib/permissions.js b/frontend/src/metabase/lib/permissions.js index 55d2cf4e46264ab6df21d0f5ad7436be8224b868..f41ef50e1dd5ea86d405b0e3a1ef80b0fe96d9e1 100644 --- a/frontend/src/metabase/lib/permissions.js +++ b/frontend/src/metabase/lib/permissions.js @@ -71,7 +71,7 @@ export function updatePermission( } else { newValue = value; } - for (var i = 0; i < fullPath.length; i++) { + for (let i = 0; i < fullPath.length; i++) { if (typeof getIn(permissions, fullPath.slice(0, i)) === "string") { permissions = setIn(permissions, fullPath.slice(0, i), {}); } diff --git a/frontend/src/metabase/lib/query.js b/frontend/src/metabase/lib/query.js index ae9ae5f3e1d7fd73659a0ec23a2474e056d7da0f..cdfb23a347e695a4297bffa58d03cd3b78a9be40 100644 --- a/frontend/src/metabase/lib/query.js +++ b/frontend/src/metabase/lib/query.js @@ -79,7 +79,7 @@ const SORTABLE_AGGREGATION_TYPES = new Set([ "max", ]); -var Query = { +const Query = { isStructured(dataset_query) { return dataset_query && dataset_query.type === "query"; }, @@ -211,7 +211,7 @@ var Query = { }, canAddDimensions(query) { - var MAX_DIMENSIONS = 2; + let MAX_DIMENSIONS = 2; return query && query.breakout && query.breakout.length < MAX_DIMENSIONS; }, @@ -266,7 +266,7 @@ var Query = { return fields; } else if (Query.hasValidBreakout(query)) { // further filter field list down to only fields in our breakout clause - var breakoutFieldList = []; + let breakoutFieldList = []; const breakouts = Query.getBreakouts(query); breakouts.map(function(breakoutField) { @@ -518,7 +518,7 @@ var Query = { filterFn = _.identity, usedFields = {}, ) { - var results = { + let results = { count: 0, fields: null, fks: [], @@ -532,7 +532,7 @@ var Query = { results.fks = fields .filter(f => isFK(f.special_type) && f.target) .map(joinField => { - var targetFields = filterFn(joinField.target.table.fields).filter( + let targetFields = filterFn(joinField.target.table.fields).filter( f => (!Array.isArray(f.id) || f.id[0] !== "aggregation") && !usedFields[f.id], @@ -676,7 +676,7 @@ var Query = { }, getFilterClauseDescription(tableMetadata, filter, options) { - if (filter[0] === "AND" || filter[0] === "OR") { + if (mbqlEq(filter[0], "AND") || mbqlEq(filter[0], "OR")) { let clauses = filter .slice(1) .map(f => Query.getFilterClauseDescription(tableMetadata, f, options)); diff --git a/frontend/src/metabase/lib/query_time.js b/frontend/src/metabase/lib/query_time.js index 3f009e4ba9f2f6828c989aefacfe8b23b94ed269..d9baad1e73c313ddb5a23f5e85c6f84a633f1c93 100644 --- a/frontend/src/metabase/lib/query_time.js +++ b/frontend/src/metabase/lib/query_time.js @@ -3,6 +3,7 @@ import inflection from "inflection"; import { mbqlEq } from "metabase/lib/query/util"; import { formatTimeWithUnit } from "metabase/lib/formatting"; +import { parseTimestamp } from "metabase/lib/time"; export const DATETIME_UNITS = [ // "default", @@ -139,7 +140,7 @@ export function generateTimeIntervalDescription(n, unit) { export function generateTimeValueDescription(value, bucketing) { if (typeof value === "string") { - let m = moment(value); + const m = parseTimestamp(value, bucketing); if (bucketing) { return formatTimeWithUnit(value, bucketing); } else if (m.hours() || m.minutes()) { diff --git a/frontend/src/metabase/lib/redux.js b/frontend/src/metabase/lib/redux.js index 325f34ce6f49f283d9cb5db27dbe97dff427f3c9..946e78cd154e30a3f4025274d8477284df7ff051 100644 --- a/frontend/src/metabase/lib/redux.js +++ b/frontend/src/metabase/lib/redux.js @@ -12,7 +12,7 @@ export { handleActions, createAction } from "redux-actions"; // the promise returned from the thunk resolves or rejects, similar to redux-promise export function createThunkAction(actionType, actionThunkCreator) { function fn(...actionArgs) { - var thunk = actionThunkCreator(...actionArgs); + let thunk = actionThunkCreator(...actionArgs); return async function(dispatch, getState) { try { let payload = await thunk(dispatch, getState); diff --git a/frontend/src/metabase/lib/schema_metadata.js b/frontend/src/metabase/lib/schema_metadata.js index af7c5612690526313f9825db85b6ad0938c36bd5..749befdeeec24467172d70a74efbee54f03cb105 100644 --- a/frontend/src/metabase/lib/schema_metadata.js +++ b/frontend/src/metabase/lib/schema_metadata.js @@ -196,6 +196,7 @@ function equivalentArgument(field, table) { return { type: "select", values: [{ key: true, name: t`True` }, { key: false, name: t`False` }], + default: true, }; } @@ -217,15 +218,23 @@ function equivalentArgument(field, table) { } function longitudeFieldSelectArgument(field, table) { - return { - type: "select", - values: table.fields - .filter(field => isa(field.special_type, TYPE.Longitude)) - .map(field => ({ - key: field.id, - name: field.display_name, - })), - }; + const values = table.fields + .filter(field => isa(field.special_type, TYPE.Longitude)) + .map(field => ({ + key: field.id, + name: field.display_name, + })); + if (values.length === 1) { + return { + type: "hidden", + default: values[0].key, + }; + } else { + return { + type: "select", + values: values, + }; + } } const CASE_SENSITIVE_OPTION = { @@ -276,6 +285,13 @@ const OPERATORS = { t`Enter lower latitude`, t`Enter right longitude`, ], + formatOptions: [ + { hide: true }, + { column: { special_type: TYPE.Latitude }, compact: true }, + { column: { special_type: TYPE.Longitude }, compact: true }, + { column: { special_type: TYPE.Latitude }, compact: true }, + { column: { special_type: TYPE.Longitude }, compact: true }, + ], }, BETWEEN: { validArgumentsFilters: [comparableArgument, comparableArgument], @@ -312,8 +328,8 @@ const DEFAULT_OPERATORS = [ // ordered list of operators and metadata per type const OPERATORS_BY_TYPE_ORDERED = { [NUMBER]: [ - { name: "=", verboseName: t`Equal` }, - { name: "!=", verboseName: t`Not equal` }, + { name: "=", verboseName: t`Equal to` }, + { name: "!=", verboseName: t`Not equal to` }, { name: ">", verboseName: t`Greater than` }, { name: "<", verboseName: t`Less than` }, { name: "BETWEEN", verboseName: t`Between` }, @@ -358,7 +374,7 @@ const OPERATORS_BY_TYPE_ORDERED = { { name: "INSIDE", verboseName: t`Inside` }, ], [BOOLEAN]: [ - { name: "=", verboseName: t`Is`, multi: false, defaults: [true] }, + { name: "=", verboseName: t`Is`, multi: false }, { name: "IS_NULL", verboseName: t`Is empty` }, { name: "NOT_NULL", verboseName: t`Not empty` }, ], @@ -367,8 +383,8 @@ const OPERATORS_BY_TYPE_ORDERED = { }; const MORE_VERBOSE_NAMES = { - equal: "is equal to", - "not equal": "is not equal to", + "equal to": "is equal to", + "not equal to": "is not equal to", before: "is before", after: "is after", "not empty": "is not empty", @@ -407,7 +423,7 @@ function dimensionFields(fields) { return _.filter(fields, isDimension); } -var Aggregators = [ +let Aggregators = [ { name: t`Raw data`, short: "rows", @@ -490,7 +506,7 @@ var Aggregators = [ }, ]; -var BreakoutAggregator = { +let BreakoutAggregator = { name: t`Break out by dimension`, short: "breakout", validFieldsFilters: [dimensionFields], @@ -542,7 +558,7 @@ export const isCompatibleAggregatorForField = (aggregator, field) => aggregator.validFieldsFilters.every(filter => filter([field]).length === 1); export function getBreakouts(fields) { - var result = populateFields(BreakoutAggregator, fields); + let result = populateFields(BreakoutAggregator, fields); result.fields = result.fields[0]; result.validFieldsFilter = result.validFieldsFilters[0]; return result; @@ -607,8 +623,8 @@ export function getIconForField(field) { } export function computeMetadataStrength(table) { - var total = 0; - var completed = 0; + let total = 0; + let completed = 0; function score(value) { total++; if (value) { @@ -629,3 +645,9 @@ export function computeMetadataStrength(table) { return completed / total; } + +export function getFilterArgumentFormatOptions(operator, index) { + return ( + (operator && operator.formatOptions && operator.formatOptions[index]) || {} + ); +} diff --git a/frontend/src/metabase/lib/time.js b/frontend/src/metabase/lib/time.js index b46c97a56fd7a178aaffe72ca34a6037005720b3..d90462e21da165642b5186232969078883c4863d 100644 --- a/frontend/src/metabase/lib/time.js +++ b/frontend/src/metabase/lib/time.js @@ -1,5 +1,45 @@ import moment from "moment"; +const NUMERIC_UNIT_FORMATS = { + // workaround for https://github.com/metabase/metabase/issues/1992 + year: value => + moment() + .year(value) + .startOf("year"), + "minute-of-hour": value => + moment() + .minute(value) + .startOf("minute"), + "hour-of-day": value => + moment() + .hour(value) + .startOf("hour"), + "day-of-week": value => + moment() + .day(value - 1) + .startOf("day"), + "day-of-month": value => + moment("2016-01-01") // initial date must be in month with 31 days to format properly + .date(value) + .startOf("day"), + "day-of-year": value => + moment("2016-01-01") // initial date must be in leap year to format properly + .dayOfYear(value) + .startOf("day"), + "week-of-year": value => + moment() + .week(value) + .startOf("week"), + "month-of-year": value => + moment() + .month(value - 1) + .startOf("month"), + "quarter-of-year": value => + moment() + .quarter(value) + .startOf("quarter"), +}; + // only attempt to parse the timezone if we're sure we have one (either Z or ±hh:mm or +-hhmm) // moment normally interprets the DD in YYYY-MM-DD as an offset :-/ export function parseTimestamp(value, unit) { @@ -7,11 +47,8 @@ export function parseTimestamp(value, unit) { return value; } else if (typeof value === "string" && /(Z|[+-]\d\d:?\d\d)$/.test(value)) { return moment.parseZone(value); - } else if (unit === "year") { - // workaround for https://github.com/metabase/metabase/issues/1992 - return moment() - .year(value) - .startOf("year"); + } else if (unit in NUMERIC_UNIT_FORMATS) { + return NUMERIC_UNIT_FORMATS[unit](value); } else { return moment.utc(value); } diff --git a/frontend/src/metabase/lib/types.js b/frontend/src/metabase/lib/types.js index bc4c134c60228337529091054f36539112ffa4d6..f9bc4bfbd88d6da315af989c52affd1cdf393894 100644 --- a/frontend/src/metabase/lib/types.js +++ b/frontend/src/metabase/lib/types.js @@ -5,7 +5,7 @@ import MetabaseSettings from "metabase/lib/settings"; const PARENTS = MetabaseSettings.get("types"); /// Basically exactly the same as Clojure's isa? -/// Recurses through the type hierarchy until it can give you an anser. +/// Recurses through the type hierarchy until it can give you an answer. /// isa(TYPE.BigInteger, TYPE.Number) -> true /// isa(TYPE.Text, TYPE.Boolean) -> false export function isa(child, ancestor) { diff --git a/frontend/src/metabase/lib/urls.js b/frontend/src/metabase/lib/urls.js index a3d7095645467dbd621ec4aa8525414e6001f18f..7885dd94b84ae835e875b0ac199ff2e4a4f8c17f 100644 --- a/frontend/src/metabase/lib/urls.js +++ b/frontend/src/metabase/lib/urls.js @@ -83,7 +83,7 @@ export function label(label) { return `/questions/search?label=${encodeURIComponent(label.slug)}`; } -export function publicCard(uuid, type = null) { +export function publicQuestion(uuid, type = null) { const siteUrl = MetabaseSettings.get("site_url"); return `${siteUrl}/public/question/${uuid}` + (type ? `.${type}` : ``); } @@ -96,3 +96,7 @@ export function publicDashboard(uuid) { export function embedCard(token, type = null) { return `/embed/question/${token}` + (type ? `.${type}` : ``); } + +export function embedDashboard(token) { + return `/embed/dashboard/${token}`; +} diff --git a/frontend/src/metabase/lib/utils.js b/frontend/src/metabase/lib/utils.js index c6ce64ed1459bc1c9c94a9ec5c8432486789fa04..7a72fe7fcdb895f8347cb5bfc6f5270ddadccbdd 100644 --- a/frontend/src/metabase/lib/utils.js +++ b/frontend/src/metabase/lib/utils.js @@ -8,7 +8,7 @@ function s4() { } // provides functions for building urls to things we care about -var MetabaseUtils = { +let MetabaseUtils = { generatePassword: function(length, complexity) { const len = length || 14; @@ -25,10 +25,10 @@ var MetabaseUtils = { } function isStrongEnough(password) { - var uc = password.match(/([A-Z])/g); - var lc = password.match(/([a-z])/g); - var di = password.match(/([\d])/g); - var sc = password.match(/([!@#\$%\^\&*\)\(+=._-{}])/g); + let uc = password.match(/([A-Z])/g); + let lc = password.match(/([a-z])/g); + let di = password.match(/([\d])/g); + let sc = password.match(/([!@#\$%\^\&*\)\(+=._-{}])/g); return ( uc && @@ -50,7 +50,7 @@ var MetabaseUtils = { // pretty limited. just does 0-9 for right now. numberToWord: function(num) { - var names = [ + let names = [ t`zero`, t`one`, t`two`, @@ -113,7 +113,7 @@ var MetabaseUtils = { }, validEmail: function(email) { - var re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + let re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; return re.test(email); }, diff --git a/frontend/src/metabase/meta/Card.js b/frontend/src/metabase/meta/Card.js index ed2c484b1a6717c033e73573faeed3140356c968..b5059649d535c2de2820d8929dbb29749b9fff23 100644 --- a/frontend/src/metabase/meta/Card.js +++ b/frontend/src/metabase/meta/Card.js @@ -179,10 +179,21 @@ export function applyParameters( continue; } - const mapping = _.findWhere(parameterMappings, { - card_id: card.id || card.original_card_id, - parameter_id: parameter.id, - }); + const cardId = card.id || card.original_card_id; + const mapping = _.findWhere( + parameterMappings, + cardId != null + ? { + card_id: cardId, + parameter_id: parameter.id, + } + : // NOTE: this supports transient dashboards where cards don't have ids + // BUT will not work correctly with multiseries dashcards since + // there's no way to identify which card the mapping applies to. + { + parameter_id: parameter.id, + }, + ); if (mapping) { // mapped target, e.x. on a dashboard datasetQuery.parameters.push({ @@ -203,6 +214,10 @@ export function applyParameters( return datasetQuery; } +export function isTransientId(id: ?any) { + return id != null && typeof id === "string" && isNaN(parseInt(id)); +} + /** returns a question URL with parameters added to query string or MBQL filters */ export function questionUrlWithParameters( card: Card, @@ -229,6 +244,7 @@ export function questionUrlWithParameters( // If we have a clean question without parameters applied, don't add the dataset query hash if ( !cardIsDirty && + !isTransientId(card.id) && datasetQuery.parameters && datasetQuery.parameters.length === 0 ) { @@ -257,5 +273,13 @@ export function questionUrlWithParameters( console.warn("UNHANDLED PARAMETER", datasetParameter); } } + + if (isTransientId(card.id)) { + card = assoc(card, "id", null); + } + if (isTransientId(card.original_card_id)) { + card = assoc(card, "original_card_id", null); + } + return Urls.question(null, card.dataset_query ? card : undefined, query); } diff --git a/frontend/src/metabase/meta/Parameter.js b/frontend/src/metabase/meta/Parameter.js index cd9a1cf954210381b7d8eb5749796b198153fb18..f28d264f4cf14c0f9d7ebc7dd019b4931776f32a 100644 --- a/frontend/src/metabase/meta/Parameter.js +++ b/frontend/src/metabase/meta/Parameter.js @@ -14,6 +14,7 @@ import type { ParameterValue, ParameterValueOrArray, ParameterValues, + ParameterType, } from "metabase/meta/types/Parameter"; import type { FieldId } from "metabase/meta/types/Field"; import type { Metadata } from "metabase/meta/types/Metadata"; @@ -217,3 +218,13 @@ export function parameterToMBQLFilter( } } } + +export function getParameterIconName(parameterType: ?ParameterType) { + if (/^date\//.test(parameterType || "")) { + return "calendar"; + } else if (/^location\//.test(parameterType || "")) { + return "location"; + } else { + return "label"; + } +} diff --git a/frontend/src/metabase/meta/types/Auto.js b/frontend/src/metabase/meta/types/Auto.js new file mode 100644 index 0000000000000000000000000000000000000000..5a72a7640d22a25880d2ad350ebf5b7e72338e55 --- /dev/null +++ b/frontend/src/metabase/meta/types/Auto.js @@ -0,0 +1,23 @@ +/* @flow */ + +import type { TableId, SchemaName } from "metabase/meta/types/Table"; + +export type DatabaseCandidates = SchemaCandidates[]; + +export type SchemaCandidates = { + schema: SchemaName, + score: number, + tables: Candidate[], +}; + +export type Candidate = { + title: string, + description: string, + score: number, + rule: string, + url: string, + table?: { + id: TableId, + schema: SchemaName, + }, +}; diff --git a/frontend/src/metabase/meta/types/Dataset.js b/frontend/src/metabase/meta/types/Dataset.js index 06cdd97c7747ea329ac548066967a71287b571d5..48c73d4d326c0a7a6d587b6d6aba858c04c02f10 100644 --- a/frontend/src/metabase/meta/types/Dataset.js +++ b/frontend/src/metabase/meta/types/Dataset.js @@ -3,7 +3,7 @@ import type { ISO8601Time } from "."; import type { FieldId } from "./Field"; import type { DatasetQuery } from "./Card"; -import type { DatetimeUnit } from "./Query"; +import type { DatetimeUnit, FieldLiteral } from "./Query"; export type ColumnName = string; @@ -13,7 +13,7 @@ export type BinningInfo = { // TODO: incomplete export type Column = { - id: ?FieldId, + id: ?(FieldId | FieldLiteral), // NOTE: sometimes id is a field reference, e.x. nested queries? name: ColumnName, display_name: string, base_type: string, diff --git a/frontend/src/metabase/meta/types/Label.js b/frontend/src/metabase/meta/types/Label.js new file mode 100644 index 0000000000000000000000000000000000000000..17b8cac7a7650733cd197198669f7e754fe42a66 --- /dev/null +++ b/frontend/src/metabase/meta/types/Label.js @@ -0,0 +1,10 @@ +/* @flow */ + +export type LabelId = number; + +export type Label = { + id: LabelId, + name: string, + slug: string, + icon: string, +}; diff --git a/frontend/src/metabase/meta/types/Visualization.js b/frontend/src/metabase/meta/types/Visualization.js index e59453f964c0249a8b9b1deaaeb96f66ad3f5ec4..860bc38b6df65e50f54859ca09e2320ba4befacf 100644 --- a/frontend/src/metabase/meta/types/Visualization.js +++ b/frontend/src/metabase/meta/types/Visualization.js @@ -68,8 +68,11 @@ export type ClickActionPopoverProps = { }; export type SingleSeries = { card: Card, data: DatasetData }; -export type Series = SingleSeries[] & { _raw: Series }; +export type RawSeries = SingleSeries[]; +export type TransformedSeries = RawSeries & { _raw: Series }; +export type Series = RawSeries | TransformedSeries; +// These are the props provided to the visualization implementations BY the Visualization component export type VisualizationProps = { series: Series, card: Card, diff --git a/frontend/src/metabase/nav/containers/Navbar.jsx b/frontend/src/metabase/nav/containers/Navbar.jsx index 8c24fe6f8b3070b711c71afc168d57419e896420..142b0eaad733c7bc325ebcbb7e269bae99f90a4f 100644 --- a/frontend/src/metabase/nav/containers/Navbar.jsx +++ b/frontend/src/metabase/nav/containers/Navbar.jsx @@ -93,27 +93,27 @@ export default class Navbar extends Component { <ul className="sm-ml4 flex flex-full"> <AdminNavItem - name="Settings" + name={t`Settings`} path="/admin/settings" currentPath={this.props.path} /> <AdminNavItem - name="People" + name={t`People`} path="/admin/people" currentPath={this.props.path} /> <AdminNavItem - name="Data Model" + name={t`Data Model`} path="/admin/datamodel" currentPath={this.props.path} /> <AdminNavItem - name="Databases" + name={t`Databases`} path="/admin/databases" currentPath={this.props.path} /> <AdminNavItem - name="Permissions" + name={t`Permissions`} path="/admin/permissions" currentPath={this.props.path} /> diff --git a/frontend/src/metabase/new_query/containers/MetricSearch.jsx b/frontend/src/metabase/new_query/containers/MetricSearch.jsx index 138de77584ba47237a1dfd7a29123fb7b33ab5d3..ba366fbbb1a6cda8c31253389232db6b023c36f1 100644 --- a/frontend/src/metabase/new_query/containers/MetricSearch.jsx +++ b/frontend/src/metabase/new_query/containers/MetricSearch.jsx @@ -85,7 +85,7 @@ export default class MetricSearch extends Component { <EmptyState message={ <span> - ${t`Defining common metrics for your team makes it even easier to ask questions`} + {t`Defining common metrics for your team makes it even easier to ask questions`} </span> } image="/app/img/metrics_illustration" diff --git a/frontend/src/metabase/new_query/containers/NewQueryOptions.jsx b/frontend/src/metabase/new_query/containers/NewQueryOptions.jsx index fcbc60dd42a232dd5c0f276639cbdd13b56d9fdd..2af52b5b925f9d21d94c27bdb291c8b8d6160539 100644 --- a/frontend/src/metabase/new_query/containers/NewQueryOptions.jsx +++ b/frontend/src/metabase/new_query/containers/NewQueryOptions.jsx @@ -126,7 +126,7 @@ export class NewQueryOptions extends Component { return ( <div className="full-height flex"> - <div className="wrapper wrapper--trim lg-wrapper--trim xl-wrapper--trim flex-full px1 mt4 mb2 align-center"> + <div className="wrapper wrapper--trim lg-wrapper--trim xl-wrapper--trim flex-full px4 mt4 mb2 align-center"> <div className="flex align-center justify-center" style={{ minHeight: "100%" }} diff --git a/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx b/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx index ccd2afeef984c8e1912e57d9c97363e27b10a67d..2587b13cd994d682b366da5a0c554414ffcba9f6 100644 --- a/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx +++ b/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx @@ -3,10 +3,10 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { connect } from "react-redux"; +import { t } from "c-3po"; import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx"; import Icon from "metabase/components/Icon.jsx"; -import { t } from "c-3po"; import DateSingleWidget from "./widgets/DateSingleWidget.jsx"; import DateRangeWidget from "./widgets/DateRangeWidget.jsx"; import DateRelativeWidget from "./widgets/DateRelativeWidget.jsx"; @@ -23,6 +23,8 @@ import { makeGetMergedParameterFieldValues, } from "metabase/selectors/metadata"; +import { getParameterIconName } from "metabase/meta/Parameter"; + import S from "./ParameterWidget.css"; import cx from "classnames"; @@ -99,13 +101,6 @@ export default class ParameterValueWidget extends Component { } } - static getParameterIconName(parameterType) { - if (parameterType.search(/date/) !== -1) return "calendar"; - if (parameterType.search(/location/) !== -1) return "location"; - if (parameterType.search(/id/) !== -1) return "label"; - return "clipboard"; - } - state = { isFocused: false }; componentWillMount() { @@ -162,7 +157,7 @@ export default class ParameterValueWidget extends Component { if (!isEditing && !hasValue && !this.state.isFocused) { return ( <Icon - name={ParameterValueWidget.getParameterIconName(parameter.type)} + name={getParameterIconName(parameter.type)} className="flex-align-left mr1 flex-no-shrink" size={14} /> diff --git a/frontend/src/metabase/parameters/components/ParameterWidget.css b/frontend/src/metabase/parameters/components/ParameterWidget.css index 154d2d46d3e70ac6f7542b480b4c668c281f6d69..827be3d222cae0c717a78746240ce3f5ce5645ae 100644 --- a/frontend/src/metabase/parameters/components/ParameterWidget.css +++ b/frontend/src/metabase/parameters/components/ParameterWidget.css @@ -130,7 +130,7 @@ } :local(.removeButton:hover) { - color: var(--warning-color); + color: var(--error-color); } :local(.editNameIconContainer) { diff --git a/frontend/src/metabase/parameters/components/widgets/DateRangeWidget.jsx b/frontend/src/metabase/parameters/components/widgets/DateRangeWidget.jsx index b311447da4609eb7bad5efed14f7bc972c42e997..dea2154ba46382b3b6b9cb170cc0b633ebb0e509 100644 --- a/frontend/src/metabase/parameters/components/widgets/DateRangeWidget.jsx +++ b/frontend/src/metabase/parameters/components/widgets/DateRangeWidget.jsx @@ -1,24 +1,33 @@ import React, { Component } from "react"; +import PropTypes from "prop-types"; import Calendar from "metabase/components/Calendar.jsx"; import moment from "moment"; const SEPARATOR = "~"; // URL-safe +function parseDateRangeValue(value) { + const [start, end] = (value || "").split(SEPARATOR); + return { start, end }; +} +function serializeDateRangeValue({ start, end }) { + return [start, end].join(SEPARATOR); +} + export default class DateRangeWidget extends Component { constructor(props, context) { super(props, context); - this.state = { - start: null, - end: null, - }; + this.state = parseDateRangeValue(props.value); } - static propTypes = {}; + static propTypes = { + value: PropTypes.string, + setValue: PropTypes.func.isRequired, + }; static defaultProps = {}; static format = value => { - const [start, end] = (value || "").split(SEPARATOR); + const { start, end } = parseDateRangeValue(value); return start && end ? moment(start).format("MMMM D, YYYY") + " - " + @@ -26,13 +35,10 @@ export default class DateRangeWidget extends Component { : ""; }; - componentWillMount() { - this.componentWillReceiveProps(this.props); - } - componentWillReceiveProps(nextProps) { - const [start, end] = (nextProps.value || "").split(SEPARATOR); - this.setState({ start, end }); + if (nextProps.value !== this.props.value) { + this.setState(parseDateRangeValue(nextProps.value)); + } } render() { @@ -47,7 +53,7 @@ export default class DateRangeWidget extends Component { if (end == null) { this.setState({ start, end }); } else { - this.props.setValue([start, end].join(SEPARATOR)); + this.props.setValue(serializeDateRangeValue({ start, end })); } }} /> diff --git a/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx b/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx index 69ba035cb84053b281edd41dd4e61d77f5657eb4..ee93c42b714f7890fe9b2ebd660d5da70e445ae5 100644 --- a/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx +++ b/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx @@ -113,11 +113,12 @@ export default class ParameterFieldWidget extends Component<*, Props, State> { } else { return ( <Popover - tetherOptions={{ - attachment: "top left", - targetAttachment: "top left", - targetOffset: "-15 -25", - }} + horizontalAttachments={["left", "right"]} + verticalAttachments={["top"]} + alignHorizontalEdge + alignVerticalEdge + targetOffsetY={-19} + targetOffsetX={33} hasArrow={false} onClose={() => focusChanged(false)} > @@ -138,18 +139,21 @@ export default class ParameterFieldWidget extends Component<*, Props, State> { ? this.state.widgetWidth + BORDER_WIDTH * 2 : null, }} + minWidth={400} maxWidth={400} /> + {/* border between input and footer comes from border-bottom on FieldValuesWidget */} <div className="flex p1"> <Button primary className="ml-auto" + disabled={savedValue.length === 0 && unsavedValue.length === 0} onClick={() => { setValue(unsavedValue.length > 0 ? unsavedValue : null); focusChanged(false); }} > - Done + {savedValue.length > 0 ? "Update filter" : "Add filter"} </Button> </div> </Popover> diff --git a/frontend/src/metabase/public/components/widgets/EmbedCodePane.jsx b/frontend/src/metabase/public/components/widgets/EmbedCodePane.jsx index d7b471cb010a8abeb5d4d82c4e488d09b7fde4b3..7d575e5907281513ad5c39a35f1015c77ef5c09f 100644 --- a/frontend/src/metabase/public/components/widgets/EmbedCodePane.jsx +++ b/frontend/src/metabase/public/components/widgets/EmbedCodePane.jsx @@ -100,7 +100,7 @@ export default class EmbedCodePane extends Component { <div className="text-centered my2"> <h4>{jt`More ${( <ExternalLink href="https://github.com/metabase/embedding_reference_apps"> - examples on GitHub + {t`examples on GitHub`} </ExternalLink> )}`}</h4> </div> diff --git a/frontend/src/metabase/public/containers/PublicDashboard.jsx b/frontend/src/metabase/public/containers/PublicDashboard.jsx index 88a1940cdc3743a89f8d3f8a3f4ba32a96691153..ca4bd424a5e043e5693bd65d2f2f73af71a7af1a 100644 --- a/frontend/src/metabase/public/containers/PublicDashboard.jsx +++ b/frontend/src/metabase/public/containers/PublicDashboard.jsx @@ -26,6 +26,11 @@ import { import * as dashboardActions from "metabase/dashboard/dashboard"; +import { + setPublicDashboardEndpoints, + setEmbedDashboardEndpoints, +} from "metabase/services"; + import type { Dashboard } from "metabase/meta/types/Dashboard"; import type { Parameter } from "metabase/meta/types/Parameter"; @@ -89,6 +94,13 @@ export default class PublicDashboard extends Component { location, params: { uuid, token }, } = this.props; + + if (uuid) { + setPublicDashboardEndpoints(uuid); + } else if (token) { + setEmbedDashboardEndpoints(token); + } + initialize(); try { // $FlowFixMe diff --git a/frontend/src/metabase/public/containers/PublicQuestion.jsx b/frontend/src/metabase/public/containers/PublicQuestion.jsx index 248eac3053a09c316e4be71842f38e6ee87dfb4f..42b59077d79a315b3be7250bc85cbc246b53e76c 100644 --- a/frontend/src/metabase/public/containers/PublicQuestion.jsx +++ b/frontend/src/metabase/public/containers/PublicQuestion.jsx @@ -20,10 +20,15 @@ import { applyParameters, } from "metabase/meta/Card"; -import { PublicApi, EmbedApi } from "metabase/services"; +import { + PublicApi, + EmbedApi, + setPublicQuestionEndpoints, + setEmbedQuestionEndpoints, +} from "metabase/services"; import { setErrorPage } from "metabase/redux/app"; -import { addParamValues } from "metabase/redux/metadata"; +import { addParamValues, addFields } from "metabase/redux/metadata"; import { updateIn } from "icepick"; @@ -34,6 +39,7 @@ type Props = { height: number, setErrorPage: (error: { status: number }) => void, addParamValues: any => void, + addFields: any => void, }; type State = { @@ -45,6 +51,7 @@ type State = { const mapDispatchToProps = { setErrorPage, addParamValues, + addFields, }; @connect(null, mapDispatchToProps) @@ -69,6 +76,13 @@ export default class PublicQuestion extends Component { params: { uuid, token }, location: { query }, } = this.props; + + if (uuid) { + setPublicQuestionEndpoints(uuid); + } else if (token) { + setEmbedQuestionEndpoints(token); + } + try { let card; if (token) { @@ -82,6 +96,9 @@ export default class PublicQuestion extends Component { if (card.param_values) { this.props.addParamValues(card.param_values); } + if (card.param_fields) { + this.props.addFields(card.param_fields); + } let parameterValues: ParameterValues = {}; for (let parameter of getParameters(card)) { diff --git a/frontend/src/metabase/public/lib/code.js b/frontend/src/metabase/public/lib/code.js index 5ab7aa279c4e556b641e653e1fa262216558c8fd..f043c5d18797d63ea67bf1fea2c75540df2d132c 100644 --- a/frontend/src/metabase/public/lib/code.js +++ b/frontend/src/metabase/public/lib/code.js @@ -168,7 +168,7 @@ payload = { } token = jwt.encode(payload, METABASE_SECRET_KEY, algorithm="HS256") -iframeUrl = METABASE_SITE_URL + "/embed/${resourceType}/" + token${ +iframeUrl = METABASE_SITE_URL + "/embed/${resourceType}/" + token.decode("utf8")${ optionsToHashParams(displayOptions) ? " + " + JSON.stringify(optionsToHashParams(displayOptions)) : "" diff --git a/frontend/src/metabase/public/lib/embed.js b/frontend/src/metabase/public/lib/embed.js index 0c812928b975e363a2f9b864acf7ef2d2300bbc6..d6f228cf2efcba2fff8923c89a576f70232d3330 100644 --- a/frontend/src/metabase/public/lib/embed.js +++ b/frontend/src/metabase/public/lib/embed.js @@ -59,7 +59,7 @@ export function getUnsignedPreviewUrl( export function optionsToHashParams(options = {}) { options = { ...options }; // filter out null, undefined, "" - for (var name in options) { + for (let name in options) { if (options[name] == null || options[name] === "") { delete options[name]; } diff --git a/frontend/src/metabase/pulse/components/PulseCardPreview.jsx b/frontend/src/metabase/pulse/components/PulseCardPreview.jsx index 1ccbf48d936a779ca700f917905fff83f251353e..3db924022146e883bd9e8233665e394ba8b571a2 100644 --- a/frontend/src/metabase/pulse/components/PulseCardPreview.jsx +++ b/frontend/src/metabase/pulse/components/PulseCardPreview.jsx @@ -67,7 +67,12 @@ export default class PulseCardPreview extends Component { cardPreview && cardPreview.pulse_card_type == null; return ( - <div className="flex relative flex-full"> + <div + className="flex relative flex-full" + style={{ + maxWidth: 379, + }} + > <div className="absolute p2 text-grey-2" style={{ diff --git a/frontend/src/metabase/pulse/components/PulseEdit.jsx b/frontend/src/metabase/pulse/components/PulseEdit.jsx index b7c26174a6a28b935652bb9db8acb097c40b27bd..4740b0d3e8bd1ce65162f6fea2419ea619c68c5a 100644 --- a/frontend/src/metabase/pulse/components/PulseEdit.jsx +++ b/frontend/src/metabase/pulse/components/PulseEdit.jsx @@ -88,7 +88,7 @@ export default class PulseEdit extends Component { <span key={index}> {jt`This pulse will no longer be emailed to ${( <strong> - {c.recipients.length} {inflect("address", c.recipients.length)} + {c.recipients.length} {inflect(t`address`, c.recipients.length)} </strong> )} ${<strong>{c.schedule_type}</strong>}`}. </span> diff --git a/frontend/src/metabase/pulse/components/PulseEditCards.jsx b/frontend/src/metabase/pulse/components/PulseEditCards.jsx index b6f721492135ff52351885d1572f7b5f1a075ac1..a270d142a5998a6794af9047c28afd1ec69cc601 100644 --- a/frontend/src/metabase/pulse/components/PulseEditCards.jsx +++ b/frontend/src/metabase/pulse/components/PulseEditCards.jsx @@ -11,6 +11,17 @@ import MetabaseAnalytics from "metabase/lib/analytics"; const SOFT_LIMIT = 10; const HARD_LIMIT = 25; +const TABLE_MAX_ROWS = 20; +const TABLE_MAX_COLS = 10; + +function isAutoAttached(cardPreview) { + return ( + cardPreview && + cardPreview.pulse_card_type === "table" && + (cardPreview.row_count > TABLE_MAX_ROWS || + cardPreview.col_cound > TABLE_MAX_COLS) + ); +} export default class PulseEditCards extends Component { constructor(props) { @@ -69,9 +80,10 @@ export default class PulseEditCards extends Component { const showSoftLimitWarning = index === SOFT_LIMIT; let notices = []; const hasAttachment = - this.props.attachmentsEnabled && - card && - (card.include_csv || card.include_xls); + isAutoAttached(cardPreview) || + (this.props.attachmentsEnabled && + card && + (card.include_csv || card.include_xls)); if (hasAttachment) { notices.push({ head: t`Attachment`, @@ -85,6 +97,13 @@ export default class PulseEditCards extends Component { }); } if (cardPreview) { + if (isAutoAttached(cardPreview)) { + notices.push({ + type: "warning", + head: t`Heads up`, + body: t`We'll show the first 10 columns and 20 rows of this table in your Pulse. If you email this, we'll add a file attachment with all columns and up to 2,000 rows.`, + }); + } if (cardPreview.pulse_card_type == null && !hasAttachment) { notices.push({ type: "warning", @@ -97,7 +116,7 @@ export default class PulseEditCards extends Component { notices.push({ type: "warning", head: t`Looks like this pulse is getting big`, - body: t`We recommend keeping pulses small and focused to help keep them digestable and useful to the whole team.`, + body: t`We recommend keeping pulses small and focused to help keep them digestible and useful to the whole team.`, }); } return notices; @@ -164,7 +183,10 @@ export default class PulseEditCards extends Component { onChange={this.setCard.bind(this, index)} onRemove={this.removeCard.bind(this, index)} fetchPulseCardPreview={this.props.fetchPulseCardPreview} - attachmentsEnabled={this.props.attachmentsEnabled} + attachmentsEnabled={ + this.props.attachmentsEnabled && + !isAutoAttached(cardPreviews[card.id]) + } trackPulseEvent={this.trackPulseEvent} /> ) : ( diff --git a/frontend/src/metabase/pulse/components/PulseList.jsx b/frontend/src/metabase/pulse/components/PulseList.jsx index dc96c270241f70470c2b7c892cc9072a2100e54a..0c291a888d0f96d80bae2e6d4239246f425ef458 100644 --- a/frontend/src/metabase/pulse/components/PulseList.jsx +++ b/frontend/src/metabase/pulse/components/PulseList.jsx @@ -40,8 +40,8 @@ export default class PulseList extends Component { render() { let { pulses, user } = this.props; return ( - <div className="PulseList pt3"> - <div className="border-bottom mb2"> + <div className="PulseList px3"> + <div className="border-bottom mb2 mt3"> <div className="wrapper wrapper--trim flex align-center mb2"> <h1>{t`Pulses`}</h1> <a diff --git a/frontend/src/metabase/pulse/components/PulseListItem.jsx b/frontend/src/metabase/pulse/components/PulseListItem.jsx index 5fa35ddc0041886fbce8d85790c5460771866eee..db66a0ca628d6e55e9a7f5112a446dcfbe39e5f8 100644 --- a/frontend/src/metabase/pulse/components/PulseListItem.jsx +++ b/frontend/src/metabase/pulse/components/PulseListItem.jsx @@ -41,19 +41,23 @@ export default class PulseListItem extends Component { "PulseListItem--focused": this.props.scrollTo, })} > - <div className="flex px4 mb2"> - <div> - <h2 className="mb1">{pulse.name}</h2> - <span>{jt`Pulse by ${creator}`}</span> + <div className="px4 mb2"> + <div className="flex align-center mb1"> + <h2 className="break-word" style={{ maxWidth: "80%" }}> + {pulse.name} + </h2> + {!pulse.read_only && ( + <div className="ml-auto"> + <Link + to={"/pulse/" + pulse.id} + className="PulseEditButton PulseButton Button no-decoration text-bold" + > + {t`Edit`} + </Link> + </div> + )} </div> - {!pulse.read_only && ( - <div className="flex-align-right"> - <Link - to={"/pulse/" + pulse.id} - className="PulseEditButton PulseButton Button no-decoration text-bold" - >{t`Edit`}</Link> - </div> - )} + <span>{jt`Pulse by ${creator}`}</span> </div> <ol className="mb2 px4 flex flex-wrap"> {pulse.cards.map((card, index) => ( diff --git a/frontend/src/metabase/pulse/components/WhatsAPulse.jsx b/frontend/src/metabase/pulse/components/WhatsAPulse.jsx index c8e76f0c8cccd99d2ae58e3f5f102dcda798f659..cfa4f781ba0c23cc0ccfa15e61c66045a389f882 100644 --- a/frontend/src/metabase/pulse/components/WhatsAPulse.jsx +++ b/frontend/src/metabase/pulse/components/WhatsAPulse.jsx @@ -20,6 +20,7 @@ export default class WhatsAPulse extends Component { width={574} src="app/assets/img/pulse_empty_illustration.png" forceOriginalDimensions={false} + style={{ maxWidth: "574px", width: "100%" }} /> </div> <div diff --git a/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx b/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx index f26b4b0f2b7b650108c39ad6aa6135d536dafd71..5fab16f1332c8be5b50bcf80a35e706c47034ee0 100644 --- a/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx +++ b/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx @@ -151,7 +151,7 @@ export default class TimeseriesFilterWidget extends Component { } }} > - Apply + {t`Apply`} </Button> </div> </PopoverWithTrigger> diff --git a/frontend/src/metabase/qb/components/actions/GenerateDashboardAction.jsx b/frontend/src/metabase/qb/components/actions/GenerateDashboardAction.jsx new file mode 100644 index 0000000000000000000000000000000000000000..14fc79219039394853e24ec170ca937e52725902 --- /dev/null +++ b/frontend/src/metabase/qb/components/actions/GenerateDashboardAction.jsx @@ -0,0 +1,41 @@ +/* @flow */ + +import type { + ClickAction, + ClickActionProps, +} from "metabase/meta/types/Visualization"; +import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; +import { utf8_to_b64url } from "metabase/lib/card"; +import { t } from "c-3po"; + +export default ({ question, settings }: ClickActionProps): ClickAction[] => { + // currently time series xrays require the maximum fidelity + console.log(JSON.stringify(question.query().datasetQuery())); + let dashboard_url = "adhoc"; + + const query = question.query(); + if (!(query instanceof StructuredQuery)) { + return []; + } + + // aggregations + if (query.aggregations().length) { + return []; + } + if (question.card().id) { + dashboard_url = `/auto/dashboard/question/${question.card().id}`; + } else { + let encodedQueryDict = utf8_to_b64url( + JSON.stringify(question.query().datasetQuery()), + ); + dashboard_url = `/auto/dashboard/adhoc/${encodedQueryDict}`; + } + return [ + { + name: "generate-dashboard", + title: t`See an exploration of this question`, + icon: "bolt", + url: () => dashboard_url, + }, + ]; +}; diff --git a/frontend/src/metabase/qb/components/actions/XRayCard.jsx b/frontend/src/metabase/qb/components/actions/XRayCard.jsx index 58b1006551db7d609df23c64061fe8152a2cde1f..17e19ed8d143dc55d94f6ad69214d44ae32b86dc 100644 --- a/frontend/src/metabase/qb/components/actions/XRayCard.jsx +++ b/frontend/src/metabase/qb/components/actions/XRayCard.jsx @@ -16,7 +16,7 @@ export default ({ question, settings }: ClickActionProps): ClickAction[] => { return [ { name: "xray-card", - title: t`X-ray this question`, + title: t`Analyze this question`, icon: "beaker", url: () => `/xray/card/${question.card().id}/extended`, }, diff --git a/frontend/src/metabase/qb/components/actions/index.js b/frontend/src/metabase/qb/components/actions/index.js index bfbda41978d864fec0e45d3d4ac9cc32fc7baa8c..2a15c98e5ced645ed3b3b925f1fc998113003628 100644 --- a/frontend/src/metabase/qb/components/actions/index.js +++ b/frontend/src/metabase/qb/components/actions/index.js @@ -2,5 +2,10 @@ import UnderlyingDataAction from "./UnderlyingDataAction"; import UnderlyingRecordsAction from "./UnderlyingRecordsAction"; +// import GenerateDashboardAction from "./GenerateDashboardAction"; -export const DEFAULT_ACTIONS = [UnderlyingDataAction, UnderlyingRecordsAction]; +export const DEFAULT_ACTIONS = [ + UnderlyingDataAction, + UnderlyingRecordsAction, + // GenerateDashboardAction, +]; diff --git a/frontend/src/metabase/qb/components/drill/AutomaticDashboardDrill.jsx b/frontend/src/metabase/qb/components/drill/AutomaticDashboardDrill.jsx new file mode 100644 index 0000000000000000000000000000000000000000..cc089251c8824b8e36548107c7e755256dfe5ae5 --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/AutomaticDashboardDrill.jsx @@ -0,0 +1,47 @@ +/* @flow */ + +import { inflect } from "metabase/lib/formatting"; + +import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; +import { t } from "c-3po"; +import type { + ClickAction, + ClickActionProps, +} from "metabase/meta/types/Visualization"; + +export default ({ question, clicked }: ClickActionProps): ClickAction[] => { + const query = question.query(); + if (!(query instanceof StructuredQuery)) { + return []; + } + + // questions with a breakout + const dimensions = (clicked && clicked.dimensions) || []; + if (!clicked || dimensions.length === 0) { + return []; + } + + // the metric value should be the number of rows that will be displayed + const count = typeof clicked.value === "number" ? clicked.value : 2; + + return [ + { + name: "exploratory-dashboard", + section: "auto", + icon: "bolt", + title: t`X-ray ${inflect(t`these`, count, t`this`, t`these`)} ${inflect( + query.table().display_name, + count, + )}`, + url: () => { + const filters = query + .clearFilters() // clear existing filters so we don't duplicate them + .question() + .drillUnderlyingRecords(dimensions) + .query() + .filters(); + return question.getAutomaticDashboardUrl(filters); + }, + }, + ]; +}; diff --git a/frontend/src/metabase/qb/components/drill/SummarizeColumnByTimeDrill.js b/frontend/src/metabase/qb/components/drill/SummarizeColumnByTimeDrill.js index f4c15bf63cddc7aeec8f57b57a033ccda521298f..496f2f3e000062931ebe2ad434ca84d7d71dc33e 100644 --- a/frontend/src/metabase/qb/components/drill/SummarizeColumnByTimeDrill.js +++ b/frontend/src/metabase/qb/components/drill/SummarizeColumnByTimeDrill.js @@ -32,6 +32,9 @@ export default ({ question, clicked }: ClickActionProps): ClickAction[] => { return []; } const { column } = clicked; + const pivotFieldRef = isDate(column) + ? getFieldRefFromColumn(column) + : ["field-id", dateField.id]; return ["sum", "count"] .map(getAggregator) @@ -51,8 +54,6 @@ export default ({ question, clicked }: ClickActionProps): ClickAction[] => { ? [aggregator.short, getFieldRefFromColumn(column)] : [aggregator.short], ) - .pivot([ - ["datetime-field", getFieldRefFromColumn(dateField), "as", "day"], - ]), + .pivot([["datetime-field", pivotFieldRef, "day"]]), })); }; diff --git a/frontend/src/metabase/qb/components/drill/index.js b/frontend/src/metabase/qb/components/drill/index.js index 3075dc11c130b809c0573229d3302a53845fda7c..6e696f4dc2029956f665956a45970c829516246c 100644 --- a/frontend/src/metabase/qb/components/drill/index.js +++ b/frontend/src/metabase/qb/components/drill/index.js @@ -4,6 +4,7 @@ import SortAction from "./SortAction"; import ObjectDetailDrill from "./ObjectDetailDrill"; import QuickFilterDrill from "./QuickFilterDrill"; import UnderlyingRecordsDrill from "./UnderlyingRecordsDrill"; +import AutomaticDashboardDrill from "./AutomaticDashboardDrill"; import ZoomDrill from "./ZoomDrill"; export const DEFAULT_DRILLS = [ @@ -12,4 +13,5 @@ export const DEFAULT_DRILLS = [ ObjectDetailDrill, QuickFilterDrill, UnderlyingRecordsDrill, + AutomaticDashboardDrill, ]; diff --git a/frontend/src/metabase/qb/components/modes/SegmentMode.jsx b/frontend/src/metabase/qb/components/modes/SegmentMode.jsx index 81f4e7908919e2f4acae158fdde9e0ae2452616a..8ea44ec54df5b5143824464b58b60477075c0fae 100644 --- a/frontend/src/metabase/qb/components/modes/SegmentMode.jsx +++ b/frontend/src/metabase/qb/components/modes/SegmentMode.jsx @@ -6,7 +6,6 @@ import { DEFAULT_DRILLS } from "../drill"; import SummarizeBySegmentMetricAction from "../actions/SummarizeBySegmentMetricAction"; import CommonMetricsAction from "../actions/CommonMetricsAction"; import CountByTimeAction from "../actions/CountByTimeAction"; -import XRaySegment from "../actions/XRaySegment"; import SummarizeColumnDrill from "../drill/SummarizeColumnDrill"; import SummarizeColumnByTimeDrill from "../drill/SummarizeColumnByTimeDrill"; import CountByColumnDrill from "../drill/CountByColumnDrill"; @@ -20,7 +19,6 @@ const SegmentMode: QueryMode = { ...DEFAULT_ACTIONS, CommonMetricsAction, CountByTimeAction, - XRaySegment, SummarizeBySegmentMetricAction, // commenting this out until we sort out viz settings in QB2 // PlotSegmentField diff --git a/frontend/src/metabase/qb/components/modes/TimeseriesMode.jsx b/frontend/src/metabase/qb/components/modes/TimeseriesMode.jsx index 075516aba1034cff38c574ade26a553299b27dae..8501d54927a6333b1636ecd76a56859bb1607948 100644 --- a/frontend/src/metabase/qb/components/modes/TimeseriesMode.jsx +++ b/frontend/src/metabase/qb/components/modes/TimeseriesMode.jsx @@ -11,7 +11,6 @@ import { DEFAULT_DRILLS } from "../drill"; import PivotByCategoryAction from "../actions/PivotByCategoryAction"; import PivotByLocationAction from "../actions/PivotByLocationAction"; -import XRayCard from "../actions/XRayCard"; import PivotByCategoryDrill from "../drill/PivotByCategoryDrill"; import PivotByLocationDrill from "../drill/PivotByLocationDrill"; @@ -44,12 +43,7 @@ export const TimeseriesModeFooter = (props: Props) => { const TimeseriesMode: QueryMode = { name: "timeseries", - actions: [ - PivotByCategoryAction, - PivotByLocationAction, - XRayCard, - ...DEFAULT_ACTIONS, - ], + actions: [PivotByCategoryAction, PivotByLocationAction, ...DEFAULT_ACTIONS], drills: [PivotByCategoryDrill, PivotByLocationDrill, ...DEFAULT_DRILLS], ModeFooter: TimeseriesModeFooter, }; diff --git a/frontend/src/metabase/qb/lib/actions.js b/frontend/src/metabase/qb/lib/actions.js index 58df463ea6903c28b101eeb448b76ed2c68e7e71..678956f2ee672b9326b4efe1a9ad58aff3e58e3d 100644 --- a/frontend/src/metabase/qb/lib/actions.js +++ b/frontend/src/metabase/qb/lib/actions.js @@ -21,11 +21,16 @@ import Utils from "metabase/lib/utils"; import type Table from "metabase-lib/lib/metadata/Table"; import type { Card as CardObject } from "metabase/meta/types/Card"; +import type { FieldId } from "metabase/meta/types/Field"; import type { StructuredQuery, FieldFilter, Breakout, + LocalFieldReference, + ForeignFieldReference, + FieldLiteral, } from "metabase/meta/types/Query"; +import type { Column } from "metabase/meta/types/Dataset"; import type { DimensionValue } from "metabase/meta/types/Visualization"; import { parseTimestamp } from "metabase/lib/time"; @@ -52,9 +57,20 @@ export const toUnderlyingRecords = (card: CardObject): ?CardObject => { } }; -export const getFieldRefFromColumn = (col, fieldId = col.id) => { - if (col.fk_field_id != null) { - return ["fk->", col.fk_field_id, fieldId]; +export const getFieldRefFromColumn = ( + column: Column, + fieldId?: ?(FieldId | FieldLiteral) = column.id, +): LocalFieldReference | ForeignFieldReference | FieldLiteral => { + if (fieldId == null) { + throw new Error( + "getFieldRefFromColumn expects non-null fieldId or column with non-null id", + ); + } + if (Array.isArray(fieldId)) { + // NOTE: sometimes col.id is a field reference (e.x. nested queries), if so just return it + return fieldId; + } else if (column.fk_field_id != null) { + return ["fk->", column.fk_field_id, fieldId]; } else { return ["field-id", fieldId]; } @@ -88,13 +104,13 @@ export const filter = (card, operator, column, value) => { return newCard; }; -const drillFilter = (card, value, column) => { +export const drillFilter = (card, value, column) => { let filter; if (isDate(column)) { filter = [ "=", ["datetime-field", getFieldRefFromColumn(column), "as", column.unit], - parseTimestamp(value, column.unit).toISOString(), + parseTimestamp(value, column.unit).format(), ]; } else { const range = rangeForValue(value, column); @@ -388,11 +404,7 @@ const guessVisualization = (card: CardObject, tableMetadata: Table) => { card.display = "line"; } else if (_.all(breakoutFields, isCoordinate)) { card.display = "map"; - // NOTE Atte Keinänen 8/2/17: Heat/grid maps disabled in the first merged version of binning - // Currently show a pin map instead of heat map for double coordinate breakout - // This way the binning drill-through works in a somewhat acceptable way (although it is designed for heat maps) - card.visualization_settings["map.type"] = "pin"; - // card.visualization_settings["map.type"] = "grid"; + card.visualization_settings["map.type"] = "grid"; } else { card.display = "bar"; } diff --git a/frontend/src/metabase/qb/lib/drilldown.js b/frontend/src/metabase/qb/lib/drilldown.js index a12e60da5acf103be8c4cb2e1c7810d29ab663a7..8a31f7ec063798e725c4b5df827fda932ce0d4a2 100644 --- a/frontend/src/metabase/qb/lib/drilldown.js +++ b/frontend/src/metabase/qb/lib/drilldown.js @@ -15,9 +15,10 @@ import { getIn } from "icepick"; // Helpers for defining drill-down progressions const CategoryDrillDown = type => [field => isa(field.special_type, type)]; const DateTimeDrillDown = unit => [["datetime-field", isDate, unit]]; -const LatLonDrillDown = binWidth => [ - ["binning-strategy", isLatitude, "bin-width", binWidth], - ["binning-strategy", isLongitude, "bin-width", binWidth], + +const LatLonDrillDown = (binningStrategy, binWidth) => [ + ["binning-strategy", isLatitude, binningStrategy, binWidth], + ["binning-strategy", isLongitude, binningStrategy, binWidth], ]; /** @@ -41,23 +42,26 @@ const DEFAULT_DRILL_DOWN_PROGRESSIONS = [ // CategoryDrillDown(TYPE.City) ], // Country, State, or City => LatLon - [CategoryDrillDown(TYPE.Country), LatLonDrillDown(10)], - [CategoryDrillDown(TYPE.State), LatLonDrillDown(1)], - [CategoryDrillDown(TYPE.City), LatLonDrillDown(0.1)], + [ + CategoryDrillDown(TYPE.Country), // + LatLonDrillDown("bin-width", 10), + ], + [ + CategoryDrillDown(TYPE.State), // + LatLonDrillDown("bin-width", 1), + ], + [ + CategoryDrillDown(TYPE.City), // + LatLonDrillDown("bin-width", 0.1), + ], // LatLon drill downs [ - LatLonDrillDown(30), - LatLonDrillDown(10), - LatLonDrillDown(1), - LatLonDrillDown(0.1), - LatLonDrillDown(0.01), + LatLonDrillDown("bin-width", (binWidth: number) => binWidth >= 20), // + LatLonDrillDown("bin-width", 10), ], [ - [ - ["binning-strategy", isLatitude, "num-bins", () => true], - ["binning-strategy", isLongitude, "num-bins", () => true], - ], - LatLonDrillDown(1), + LatLonDrillDown("bin-width", () => true), // + LatLonDrillDown("bin-width", (binWidth: number) => binWidth / 10), ], // generic num-bins drill down [ @@ -72,7 +76,7 @@ const DEFAULT_DRILL_DOWN_PROGRESSIONS = [ "binning-strategy", isAny, "bin-width", - (previous: number) => previous / 10, + (binWidth: number) => binWidth / 10, ], ], ], diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js index 2eec18284676a11f86329e66b92fc1cc09ee2b69..5c2a35fac03fc920562359c35035035852e4cdd7 100644 --- a/frontend/src/metabase/query_builder/actions.js +++ b/frontend/src/metabase/query_builder/actions.js @@ -126,8 +126,8 @@ export const updateUrl = createThunkAction( if (!card) { return; } - var copy = cleanCopyCard(card); - var newState = { + let copy = cleanCopyCard(card); + let newState = { card: copy, cardId: copy.id, serializedCard: serializeCardForUrl(copy), @@ -139,7 +139,7 @@ export const updateUrl = createThunkAction( return; } - var url = urlForCardState(newState, dirty); + let url = urlForCardState(newState, dirty); // if the serialized card is identical replace the previous state instead of adding a new one // e.x. when saving a new card we want to replace the state and URL with one with the new card ID @@ -1270,8 +1270,8 @@ export const queryCompleted = (card, queryResults) => { const getQuestionWithDefaultVisualizationSettings = (question, series) => { const oldVizSettings = question.visualizationSettings(); const newVizSettings = { - ...getPersistableDefaultSettings(series), ...oldVizSettings, + ...getPersistableDefaultSettings(series), }; // Don't update the question unnecessarily @@ -1319,8 +1319,8 @@ export const followForeignKey = createThunkAction(FOLLOW_FOREIGN_KEY, fk => { if (!queryResult || !fk) return false; // extract the value we will use to filter our new query - var originValue; - for (var i = 0; i < queryResult.data.cols.length; i++) { + let originValue; + for (let i = 0; i < queryResult.data.cols.length; i++) { if (isPK(queryResult.data.cols[i].special_type)) { originValue = queryResult.data.rows[0][i]; } @@ -1351,8 +1351,8 @@ export const loadObjectDetailFKReferences = createThunkAction( const { qb: { card, queryResult, tableForeignKeys } } = getState(); function getObjectDetailIdValue(data) { - for (var i = 0; i < data.cols.length; i++) { - var coldef = data.cols[i]; + for (let i = 0; i < data.cols.length; i++) { + let coldef = data.cols[i]; if (isPK(coldef.special_type)) { return data.rows[0][i]; } diff --git a/frontend/src/metabase/query_builder/components/AggregationPopover.jsx b/frontend/src/metabase/query_builder/components/AggregationPopover.jsx index b50d3bc3a8afa6b29b4a3ec9441dd4e1485e10f3..32b0f7eb0efcd0429629ff27557f4c0d46cc4fcd 100644 --- a/frontend/src/metabase/query_builder/components/AggregationPopover.jsx +++ b/frontend/src/metabase/query_builder/components/AggregationPopover.jsx @@ -54,7 +54,7 @@ export default class AggregationPopover extends Component { customFields: PropTypes.object, availableAggregations: PropTypes.array, // Restricts the shown options to contents of `availableActions` only - showOnlyProvidedAggregations: PropTypes.boolean, + showOnlyProvidedAggregations: PropTypes.bool, }; componentDidUpdate() { diff --git a/frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx b/frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx index 4f6cf88b1cbea8914cc0a1aa7d92c83344305573..031dc330b875349400210436ed0deda4e18293d8 100644 --- a/frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx +++ b/frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx @@ -336,7 +336,7 @@ export class AlertCreatorTitle extends Component { const isAdmin = user.is_superuser; const isCurrentUser = alert.creator.id === user.id; const creator = - alert.creator.id === user.id ? "You" : alert.creator.first_name; + alert.creator.id === user.id ? t`You` : alert.creator.first_name; const text = !isCurrentUser && !isAdmin ? t`You're receiving ${creator}'s alerts` diff --git a/frontend/src/metabase/query_builder/components/AlertModals.jsx b/frontend/src/metabase/query_builder/components/AlertModals.jsx index 24d955b8ba7c28c6f1b8d289dac659d5b23985e8..6d3a6c357fb0be17c136de3f4bdb2ef186680306 100644 --- a/frontend/src/metabase/query_builder/components/AlertModals.jsx +++ b/frontend/src/metabase/query_builder/components/AlertModals.jsx @@ -39,6 +39,7 @@ import cxs from "cxs"; import ChannelSetupModal from "metabase/components/ChannelSetupModal"; import ButtonWithStatus from "metabase/components/ButtonWithStatus"; import { apiUpdateQuestion } from "metabase/query_builder/actions"; +import MetabaseAnalytics from "metabase/lib/analytics"; const getScheduleFromChannel = channel => _.pick( @@ -117,6 +118,8 @@ export class CreateAlertModalContent extends Component { // OR should the modal visibility be part of QB redux state // (maybe check how other modals are implemented) onAlertCreated(); + + MetabaseAnalytics.trackEvent("Alert", "Create", alert.alert_condition); }; proceedFromEducationalScreen = () => { @@ -215,7 +218,7 @@ export class AlertEducationalScreen extends Component { <p className={`${classes} ml2 text-left`} >{jt`When a raw data question ${( - <strong>returns any results</strong> + <strong>{t`returns any results`}</strong> )}`}</p> </div> <div @@ -226,7 +229,7 @@ export class AlertEducationalScreen extends Component { <p className={`${classes} mr2 text-right`} >{jt`When a line or bar ${( - <strong>crosses a goal line</strong> + <strong>{t`crosses a goal line`}</strong> )}`}</p> </div> <div @@ -236,7 +239,9 @@ export class AlertEducationalScreen extends Component { <RetinaImage src="app/assets/img/alerts/education-illustration-03-progress.png" /> <p className={`${classes} ml2 text-left`} - >{jt`When a progress bar ${<strong>reaches its goal</strong>}`}</p> + >{jt`When a progress bar ${( + <strong>{t`reaches its goal`}</strong> + )}`}</p> </div> </div> <Button @@ -286,6 +291,12 @@ export class UpdateAlertModalContent extends Component { await updateAlert(modifiedAlert); onAlertUpdated(); + + MetabaseAnalytics.trackEvent( + "Alert", + "Update", + modifiedAlert.alert_condition, + ); }; onDeleteAlert = async () => { @@ -646,15 +657,15 @@ export class RawDataAlertTip extends Component { export const MultiSeriesAlertTip = () => ( <div>{jt`${( - <strong>Heads up:</strong> + <strong>{t`Heads up`}:</strong> )} Goal-based alerts aren't yet supported for charts with more than one line, so this alert will be sent whenever the chart has ${( - <em>results</em> + <em>{t`results`}</em> )}.`}</div> ); export const NormalAlertTip = () => ( <div>{jt`${( - <strong>Tip:</strong> + <strong>{t`Tip`}:</strong> )} This kind of alert is most useful when your saved question doesn’t ${( - <em>usually</em> + <em>{t`usually`}</em> )} return any results, but you want to know when it does.`}</div> ); diff --git a/frontend/src/metabase/query_builder/components/ExpandableString.jsx b/frontend/src/metabase/query_builder/components/ExpandableString.jsx index 7195247d59809aa1ea8a2db70f17e94ab422237f..449859cb61b56f4ebefc47059459b00e196553cd 100644 --- a/frontend/src/metabase/query_builder/components/ExpandableString.jsx +++ b/frontend/src/metabase/query_builder/components/ExpandableString.jsx @@ -32,7 +32,7 @@ export default class ExpandableString extends Component { render() { if (!this.props.str) return false; - var truncated = Humanize.truncate(this.props.str || "", 140); + let truncated = Humanize.truncate(this.props.str || "", 140); if (this.state.expanded) { return ( diff --git a/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx b/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx index c03cf80286d4f7b311216e26da680ee4326fad3c..f509501b973cc38a4fe2297894a2e33f36d79c48 100644 --- a/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx +++ b/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx @@ -178,7 +178,13 @@ export default class NativeQueryEditor extends Component { const { query } = this.props; let editorElement = ReactDOM.findDOMNode(this.refs.editor); + // $FlowFixMe + if (typeof ace === "undefined" || !ace || !ace.edit) { + // fail gracefully-ish if ace isn't available, e.x. in integration tests + return; + } + this._editor = ace.edit(editorElement); // listen to onChange events diff --git a/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx b/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx index aeed7f18dc541ea6487bca45792a9aff6d4d9448..29caa19ef248d3e23e938291ed6d8ece18ffffd8 100644 --- a/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx +++ b/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx @@ -118,7 +118,7 @@ const PublicQueryButton = ({ <DownloadButton className={className} method="GET" - url={Urls.publicCard(uuid, type)} + url={Urls.publicQuestion(uuid, type)} params={{ parameters: JSON.stringify(json_query.parameters) }} extensions={[type]} > diff --git a/frontend/src/metabase/query_builder/components/QueryHeader.jsx b/frontend/src/metabase/query_builder/components/QueryHeader.jsx index 834879cfbb314d8f6cea99baa45158859cc4b12d..5225eb16707b2c84be12459b353f7ffc129252c5 100644 --- a/frontend/src/metabase/query_builder/components/QueryHeader.jsx +++ b/frontend/src/metabase/query_builder/components/QueryHeader.jsx @@ -173,7 +173,7 @@ export default class QueryHeader extends Component { async onFetchRevisions({ entity, id }) { // TODO: reduxify - var revisions = await RevisionApi.list({ entity, id }); + let revisions = await RevisionApi.list({ entity, id }); this.setState({ revisions }); } @@ -203,7 +203,7 @@ export default class QueryHeader extends Component { id: card && card.dataset_query && card.dataset_query.database, }); - var buttonSections = []; + let buttonSections = []; // A card that is either completely new or it has been derived from a saved question if (isNew && isDirty) { @@ -213,7 +213,7 @@ export default class QueryHeader extends Component { key="save" ref="saveModal" triggerClasses="h4 text-grey-4 text-brand-hover text-uppercase" - triggerElement="Save" + triggerElement={t`Save`} > <SaveQuestionModal card={this.props.card} @@ -222,7 +222,7 @@ export default class QueryHeader extends Component { // if saving modified question, don't show "add to dashboard" modal saveFn={card => this.onSave(card, false)} createFn={this.onCreate} - onClose={() => this.refs.saveModal.toggle()} + onClose={() => this.refs.saveModal && this.refs.saveModal.toggle()} /> </ModalWithTrigger>, ]); @@ -437,7 +437,7 @@ export default class QueryHeader extends Component { ]); // data reference button - var dataReferenceButtonClasses = cx("transition-color", { + let dataReferenceButtonClasses = cx("transition-color", { "text-brand": this.props.isShowingDataReference, "text-brand-hover": !this.state.isShowingDataReference, }); diff --git a/frontend/src/metabase/query_builder/components/QueryModeButton.jsx b/frontend/src/metabase/query_builder/components/QueryModeButton.jsx index 3e94101aa0ab10784f2ce928cfeedc6c58c9a9c3..315dc59bce972fea476ee35a5828cb7a19668c22 100644 --- a/frontend/src/metabase/query_builder/components/QueryModeButton.jsx +++ b/frontend/src/metabase/query_builder/components/QueryModeButton.jsx @@ -41,7 +41,7 @@ export default class QueryModeButton extends Component { } = this.props; // determine the type to switch to based on the type - var targetType = mode === "query" ? "native" : "query"; + let targetType = mode === "query" ? "native" : "query"; const engine = tableMetadata && tableMetadata.db.engine; const nativeQueryName = diff --git a/frontend/src/metabase/query_builder/components/QueryVisualization.jsx b/frontend/src/metabase/query_builder/components/QueryVisualization.jsx index 817247b80ce41f629fe9e9fa3f33a59d3313d263..afe9679d88377ede4ddf2bbc214e062106b88ceb 100644 --- a/frontend/src/metabase/query_builder/components/QueryVisualization.jsx +++ b/frontend/src/metabase/query_builder/components/QueryVisualization.jsx @@ -142,12 +142,13 @@ export default class QueryVisualization extends Component { message: ( // class name is included for the sake of making targeting the element in tests easier <div className="ShownRowCount"> - {jt`${ - result.data.rows_truncated != null ? t`Showing first` : t`Showing` - } ${<strong>{formatNumber(result.row_count)}</strong>} ${inflect( - "row", - result.data.rows.length, - )}`} + {result.data.rows_truncated != null + ? jt`Showing first ${( + <strong>{formatNumber(result.row_count)}</strong> + )} ${inflect(t`row`, result.data.rows.length)}` + : jt`Showing ${( + <strong>{formatNumber(result.row_count)}</strong> + )} ${inflect(t`row`, result.data.rows.length)}`} </div> ), }); diff --git a/frontend/src/metabase/query_builder/components/SelectionModule.jsx b/frontend/src/metabase/query_builder/components/SelectionModule.jsx index 8f5a93eb83f195392093dbc3b3f091efbbf6ddb3..78e509faac0f4e731b6ee49bdb58d43db7dd548c 100644 --- a/frontend/src/metabase/query_builder/components/SelectionModule.jsx +++ b/frontend/src/metabase/query_builder/components/SelectionModule.jsx @@ -16,7 +16,7 @@ export default class SelectionModule extends Component { this._toggleOpen = this._toggleOpen.bind(this); this.onClose = this.onClose.bind(this); // a selection module can be told to be open on initialization but otherwise is closed - var isInitiallyOpen = props.isInitiallyOpen || false; + let isInitiallyOpen = props.isInitiallyOpen || false; this.state = { open: isInitiallyOpen, @@ -83,8 +83,8 @@ export default class SelectionModule extends Component { return true; } // if an item that is normally in the expansion is selected then show the expansion - for (var i = 0; i < this.props.items.length; i++) { - var item = this.props.items[i]; + for (let i = 0; i < this.props.items.length; i++) { + let item = this.props.items[i]; if (this._itemIsSelected(item) && !this.props.expandFilter(item, i)) { return true; } @@ -93,9 +93,9 @@ export default class SelectionModule extends Component { } _displayCustom(values) { - var custom = []; + let custom = []; this.props.children.forEach(function(element) { - var newElement = element; + let newElement = element; newElement.props.children = values[newElement.props.content]; custom.push(element); }); @@ -104,20 +104,20 @@ export default class SelectionModule extends Component { _listItems(selection) { if (this.props.items) { - var sourceItems = this.props.items; + let sourceItems = this.props.items; - var isExpanded = this._isExpanded(); + let isExpanded = this._isExpanded(); if (!isExpanded) { sourceItems = sourceItems.filter(this.props.expandFilter); } - var items = sourceItems.map(function(item, index) { - var display = item ? item[this.props.display] || item : item; - var itemClassName = cx({ + let items = sourceItems.map(function(item, index) { + let display = item ? item[this.props.display] || item : item; + let itemClassName = cx({ SelectionItem: true, "SelectionItem--selected": selection === display, }); - var description = null; + let description = null; if ( this.props.descriptionKey && item && @@ -169,7 +169,7 @@ export default class SelectionModule extends Component { } _select(item) { - var index = this.props.index; + let index = this.props.index; // send back the item with the specified action if (this.props.action) { if (index !== undefined) { @@ -197,12 +197,12 @@ export default class SelectionModule extends Component { renderPopover(selection) { if (this.state.open) { - var itemListClasses = cx("SelectionItems", { + let itemListClasses = cx("SelectionItems", { "SelectionItems--open": this.state.open, "SelectionItems--expanded": this.state.expanded, }); - var searchBar; + let searchBar; if (this._enableSearch()) { searchBar = <SearchBar onFilter={this._filterSelections} />; } @@ -224,18 +224,18 @@ export default class SelectionModule extends Component { } render() { - var selection; + let selection; this.props.items.forEach(function(item) { if (this._itemIsSelected(item)) { selection = item[this.props.display]; } }, this); - var placeholder = selection || this.props.placeholder, + let placeholder = selection || this.props.placeholder, remove, removeable = !!this.props.remove; - var moduleClasses = cx({ + let moduleClasses = cx({ SelectionModule: true, selected: selection, removeable: removeable, diff --git a/frontend/src/metabase/query_builder/components/SortWidget.jsx b/frontend/src/metabase/query_builder/components/SortWidget.jsx index 48fe4a840f1e7b5f1669667411e92aa270ecae54..660ccf2b0d868397e675962e2849cf3f5c293790 100644 --- a/frontend/src/metabase/query_builder/components/SortWidget.jsx +++ b/frontend/src/metabase/query_builder/components/SortWidget.jsx @@ -59,7 +59,7 @@ export default class SortWidget extends Component { } render() { - var directionOptions = [ + let directionOptions = [ { key: "ascending", val: "ascending" }, { key: "descending", val: "descending" }, ]; diff --git a/frontend/src/metabase/query_builder/components/VisualizationError.jsx b/frontend/src/metabase/query_builder/components/VisualizationError.jsx index 9c26d23e35dcce0a310fdb76f4d2e870d17c9248..c973f5bee606c4363394751c5adadfc8895311a4 100644 --- a/frontend/src/metabase/query_builder/components/VisualizationError.jsx +++ b/frontend/src/metabase/query_builder/components/VisualizationError.jsx @@ -4,7 +4,8 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { t } from "c-3po"; import MetabaseSettings from "metabase/lib/settings"; -import VisualizationErrorMessage from "./VisualizationErrorMessage"; +import ErrorMessage from "metabase/components/ErrorMessage"; +import ErrorDetails from "metabase/components/ErrorDetails"; const EmailAdmin = () => { const adminEmail = MetabaseSettings.adminEmail(); @@ -40,7 +41,7 @@ class VisualizationError extends Component { // Some platforms like Heroku return a 503 for numerous types of errors so we can't use the status code to distinguish between timeouts and other failures. if (duration > 15 * 1000) { return ( - <VisualizationErrorMessage + <ErrorMessage type="timeout" title={t`Your question took too long`} message={t`We didn't get an answer back from your database in time, so we had to stop. You can try again in a minute, or if the problem persists, you can email an admin to let them know.`} @@ -49,7 +50,7 @@ class VisualizationError extends Component { ); } else { return ( - <VisualizationErrorMessage + <ErrorMessage type="serverError" title={t`We're experiencing server issues`} message={t`Try refreshing the page after waiting a minute or two. If the problem persists we'd recommend you contact an admin.`} @@ -86,24 +87,7 @@ class VisualizationError extends Component { <div className="QueryError2-details"> <h1 className="text-bold">{t`There was a problem with your question`}</h1> <p className="QueryError-messageText">{t`Most of the time this is caused by an invalid selection or bad input value. Double check your inputs and retry your query.`}</p> - <div className="pt2"> - <a - onClick={() => this.setState({ showError: true })} - className="link cursor-pointer" - >{t`Show error details`}</a> - </div> - <div - style={{ display: this.state.showError ? "inherit" : "none" }} - className="pt3 text-left" - > - <h2>{t`Here's the full error message`}</h2> - <div - style={{ fontFamily: "monospace" }} - className="QueryError2-detailBody bordered rounded bg-grey-0 text-bold p2 mt1" - > - {error} - </div> - </div> + <ErrorDetails className="pt2" details={error} /> </div> </div> ); diff --git a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx index fc3abe19269981bcaa524db43927fd763e7d5bc8..14cde0ebe87e0f7502ae2d05c7da99e1eaaa688b 100644 --- a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx +++ b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx @@ -1,14 +1,13 @@ /* eslint "react/prop-types": "warn" */ -import React from "react"; +import React, { Component } from "react"; import { t, jt } from "c-3po"; -import VisualizationErrorMessage from "./VisualizationErrorMessage"; +import ErrorMessage from "metabase/components/ErrorMessage"; import Visualization from "metabase/visualizations/components/Visualization.jsx"; import { datasetContainsNoResults } from "metabase/lib/dataset"; import { DatasetQuery } from "metabase/meta/types/Card"; import { CreateAlertModalContent } from "metabase/query_builder/components/AlertModals"; -import { Component } from "react/lib/ReactBaseClasses"; import Modal from "metabase/components/Modal"; import { ALERT_TYPE_ROWS } from "metabase-lib/lib/Alert"; @@ -55,7 +54,7 @@ export default class VisualizationResult extends Component { // successful query but there were 0 rows returned with the result return ( <div className="flex flex-full"> - <VisualizationErrorMessage + <ErrorMessage type="noRows" title="No results!" message={t`This may be the answer you’re looking for. If not, try removing or changing your filters to make them less specific.`} @@ -66,9 +65,9 @@ export default class VisualizationResult extends Component { <p> {jt`You can also ${( <a className="link" onClick={this.showCreateAlertModal}> - get an alert + {t`get an alert`} </a> - )} when there are any results.`} + )} when there are some results.`} </p> )} <button diff --git a/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx b/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx index 896677dd22c70e104afb92dbf9074453bca067fb..4d195d4c28b208d0563c2b64133c86678fcd345e 100644 --- a/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx +++ b/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx @@ -36,7 +36,7 @@ export default class VisualizationSettings extends React.Component { { card, data: result.data }, ]); - var triggerElement = ( + let triggerElement = ( <span className="px2 py1 text-bold cursor-pointer text-default flex align-center"> <Icon className="mr1" name={CardVisualization.iconName} size={12} /> {CardVisualization.uiName} diff --git a/frontend/src/metabase/query_builder/components/Warnings.jsx b/frontend/src/metabase/query_builder/components/Warnings.jsx index c8c8e726576a4f237e353323ac10df08074e80d0..173bee837b09e6eb34bff328a3cb744a01cf9569 100644 --- a/frontend/src/metabase/query_builder/components/Warnings.jsx +++ b/frontend/src/metabase/query_builder/components/Warnings.jsx @@ -19,7 +19,7 @@ const Warnings = ({ warnings, className, size = 16 }) => { return ( <Tooltip tooltip={tooltip}> - <Icon className={className} name="warning2" size={size} /> + <Icon className={className} name="warning" size={size} /> </Tooltip> ); }; diff --git a/frontend/src/metabase/query_builder/components/__mocks__/NativeQueryEditor.jsx b/frontend/src/metabase/query_builder/components/__mocks__/NativeQueryEditor.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e98c274a1a0b5f47e6cb4279e6ae0c706cf37192 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/__mocks__/NativeQueryEditor.jsx @@ -0,0 +1,15 @@ +import React from "react"; + +import Parameters from "metabase/parameters/components/Parameters"; + +const MockNativeQueryEditor = ({ location, query, setParameterValue }) => ( + <Parameters + parameters={query.question().parameters()} + query={location.query} + setParameterValue={setParameterValue} + syncQueryString + isQB + commitImmediately + /> +); +export default MockNativeQueryEditor; diff --git a/frontend/src/metabase/query_builder/components/dataref/DataReference.jsx b/frontend/src/metabase/query_builder/components/dataref/DataReference.jsx index 2480a3a9e6cc95ca412b4f998da2a88307ba19f6..7b2ece456dc6cea761d1f38bb8c55b3ac521e87d 100644 --- a/frontend/src/metabase/query_builder/components/dataref/DataReference.jsx +++ b/frontend/src/metabase/query_builder/components/dataref/DataReference.jsx @@ -51,11 +51,11 @@ export default class DataReference extends Component { } render() { - var content; + let content; if (this.state.stack.length === 0) { content = <MainPane {...this.props} show={this.show} />; } else { - var page = this.state.stack[this.state.stack.length - 1]; + let page = this.state.stack[this.state.stack.length - 1]; if (page.type === "table") { content = ( <TablePane {...this.props} show={this.show} table={page.item} /> @@ -75,7 +75,7 @@ export default class DataReference extends Component { } } - var backButton; + let backButton; if (this.state.stack.length > 0) { backButton = ( <a @@ -88,7 +88,7 @@ export default class DataReference extends Component { ); } - var closeButton = ( + let closeButton = ( <a className="flex-align-right text-default text-brand-hover no-decoration" onClick={this.close} diff --git a/frontend/src/metabase/query_builder/components/dataref/TablePane.jsx b/frontend/src/metabase/query_builder/components/dataref/TablePane.jsx index 831fe4575a8cc589fff644122cb3c91b10258327..f7fe08bde08930922a4df0877a028bf5861b6d76 100644 --- a/frontend/src/metabase/query_builder/components/dataref/TablePane.jsx +++ b/frontend/src/metabase/query_builder/components/dataref/TablePane.jsx @@ -66,9 +66,9 @@ export default class TablePane extends Component { render() { const { table, error } = this.state; if (table) { - var queryButton; + let queryButton; if (table.rows != null) { - var text = t`See the raw data for ${table.display_name}`; + let text = t`See the raw data for ${table.display_name}`; queryButton = ( <QueryButton className="border-bottom border-top mb3" @@ -78,13 +78,13 @@ export default class TablePane extends Component { /> ); } - var panes = { + let panes = { fields: table.fields.length, // "metrics": table.metrics.length, // "segments": table.segments.length, connections: this.state.tableForeignKeys.length, }; - var tabs = Object.entries(panes).map(([name, count]) => ( + let tabs = Object.entries(panes).map(([name, count]) => ( <a key={name} className={cx("Button Button--small", { @@ -97,7 +97,8 @@ export default class TablePane extends Component { </a> )); - var pane; + let pane; + let description; if (this.state.pane === "connections") { const fkCountsByTable = foreignKeyCountsByOriginTable( this.state.tableForeignKeys, @@ -140,12 +141,14 @@ export default class TablePane extends Component { ))} </ul> ); - } else var descriptionClasses = cx({ "text-grey-3": !table.description }); - var description = ( - <p className={descriptionClasses}> - {table.description || t`No description set.`} - </p> - ); + } else { + const descriptionClasses = cx({ "text-grey-3": !table.description }); + description = ( + <p className={descriptionClasses}> + {table.description || t`No description set.`} + </p> + ); + } return ( <div> diff --git a/frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx b/frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx index d24eadf6985fa6c1b03864a95c5840d2c94394e9..567a0b8634ffc8f32d1ca3da02f0a4b07bc254b5 100644 --- a/frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx +++ b/frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx @@ -54,7 +54,7 @@ export default class ExpressionWidget extends Component { onError={errorMessage => this.setState({ error: errorMessage })} /> <p className="h5 text-grey-5"> - {t`Think of this as being kind of like writing a formula in a spreadsheet program: you can use numbers, fields in this table, mathematical symbols like +, and some functions. So you could type something like Subtotal − Cost.`} + {t`Think of this as being kind of like writing a formula in a spreadsheet program: you can use numbers, fields in this table, mathematical symbols like +, and some functions. So you could type something like Subtotal - Cost.`} <a className="link" target="_blank" @@ -99,7 +99,7 @@ export default class ExpressionWidget extends Component { <div> {this.props.expression ? ( <a - className="pr2 ml2 text-warning link" + className="pr2 ml2 text-error link" onClick={() => this.props.onRemoveExpression(this.props.name)} >{t`Remove`}</a> ) : null} diff --git a/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx b/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx index df9ea37b2bed24ed50413f9f82741de0b7d74dd5..83bdbfd1cd4522cd0a18b2b9be18d23465f2fccd 100644 --- a/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx +++ b/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx @@ -16,7 +16,11 @@ import FieldValuesWidget from "metabase/components/FieldValuesWidget.jsx"; import Icon from "metabase/components/Icon.jsx"; import Query from "metabase/lib/query"; -import { isDate, isTime } from "metabase/lib/schema_metadata"; +import { + isDate, + isTime, + getFilterArgumentFormatOptions, +} from "metabase/lib/schema_metadata"; import { formatField, singularize } from "metabase/lib/formatting"; import cx from "classnames"; @@ -136,8 +140,8 @@ export default class FilterPopover extends Component { if (operator) { for (let i = 0; i < operator.fields.length; i++) { - if (operator.defaults && operator.defaults[i] !== undefined) { - filter.push(operator.defaults[i]); + if (operator.fields[i].default !== undefined) { + filter.push(operator.fields[i].default); } else { filter.push(undefined); } @@ -183,7 +187,7 @@ export default class FilterPopover extends Component { } } // arguments are non-null/undefined - for (var i = 2; i < filter.length; i++) { + for (let i = 2; i < filter.length; i++) { if (filter[i] == null) { return false; } @@ -220,7 +224,9 @@ export default class FilterPopover extends Component { values = [this.state.filter[2 + index]]; onValuesChange = values => this.setValue(index, values[0]); } - if (operatorField.type === "select") { + if (operatorField.type === "hidden") { + return null; + } else if (operatorField.type === "select") { return ( <SelectPicker key={index} @@ -244,6 +250,7 @@ export default class FilterPopover extends Component { searchField={field.filterSearchField()} autoFocus={index === 0} alwaysShowOptions={operator.fields.length === 1} + formatOptions={getFilterArgumentFormatOptions(operator, index)} minWidth={440} maxWidth={440} /> @@ -324,18 +331,27 @@ export default class FilterPopover extends Component { maxWidth: dimension.field().isDate() ? null : 500, }} > - <div className="FilterPopover-header border-bottom text-grey-3 p1 mt1 flex align-center"> - <a - className="cursor-pointer text-purple-hover transition-color flex align-center" - onClick={this.clearField} - > - <Icon name="chevronleft" size={18} /> - <h3 className="inline-block"> - {singularize(table.display_name)} - </h3> - </a> - <h3 className="mx1">-</h3> - <h3 className="text-default">{formatField(field)}</h3> + <div className="FilterPopover-header border-bottom text-grey-3 p1 flex align-center"> + <div className="flex py1"> + <a + className="cursor-pointer text-purple-hover transition-color flex align-center" + onClick={this.clearField} + > + <Icon name="chevronleft" size={16} /> + <h3 className="ml1">{singularize(table.display_name)}</h3> + </a> + <h3 className="mx1">-</h3> + <h3 className="text-default">{formatField(field)}</h3> + </div> + {isTime(field) || isDate(field) ? null : ( + <div className="flex flex-align-right pl3"> + <OperatorSelector + operator={operatorName} + operators={field.operators} + onOperatorChange={this.setOperator} + /> + </div> + )} </div> {isTime(field) ? ( <TimePicker @@ -350,18 +366,9 @@ export default class FilterPopover extends Component { onFilterChange={this.setFilter} /> ) : ( - <div> - <div className="inline-block px1 pt1"> - <OperatorSelector - operator={operatorName} - operators={field.operators} - onOperatorChange={this.setOperator} - /> - </div> - {this.renderPicker(filter, field)} - </div> + <div>{this.renderPicker(filter, field)}</div> )} - <div className="FilterPopover-footer border-top flex align-center p1 pl2"> + <div className="FilterPopover-footer flex align-center p1 pl2"> <FilterOptions filter={filter} onFilterChange={this.setFilter} diff --git a/frontend/src/metabase/query_builder/components/filters/FilterWidget.jsx b/frontend/src/metabase/query_builder/components/filters/FilterWidget.jsx index 006016213876d2f554cf1b65d3d60411914173b7..33ac5cb01516887a33503cbfadb94645027f57e9 100644 --- a/frontend/src/metabase/query_builder/components/filters/FilterWidget.jsx +++ b/frontend/src/metabase/query_builder/components/filters/FilterWidget.jsx @@ -10,6 +10,7 @@ import Value from "metabase/components/Value"; import { generateTimeFilterValuesDescriptions } from "metabase/lib/query_time"; import { hasFilterOptions } from "metabase/lib/query/filter"; +import { getFilterArgumentFormatOptions } from "metabase/lib/schema_metadata"; import cx from "classnames"; import _ from "underscore"; @@ -76,10 +77,20 @@ export default class FilterWidget extends Component { } else if (dimension.field().isDate() && !dimension.field().isTime()) { formattedValues = generateTimeFilterValuesDescriptions(filter); } else { - formattedValues = values - .filter(value => value !== undefined) - .map((value, index) => ( - <Value key={index} value={value} column={dimension.field()} remap /> + const valuesWithOptions = values.map((value, index) => [ + value, + getFilterArgumentFormatOptions(operator, index), + ]); + formattedValues = valuesWithOptions + .filter(([value, options]) => value !== undefined && !options.hide) + .map(([value, options], index) => ( + <Value + key={index} + value={value} + column={dimension.field()} + remap + {...options} + /> )); } diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx index d39970e2c4adf4eed81611ca7549803e92b0f521..6931e11ee3c9ae12dc08dd049bf68ab1a0568e21 100644 --- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx +++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx @@ -166,16 +166,30 @@ export default class QueryBuilder extends Component { nextProps.location.action === "POP" && getURL(nextProps.location) !== getURL(this.props.location) ) { + // the browser forward/back button was pressed this.props.popState(nextProps.location); + // NOTE: Tom Robinson 4/16/2018: disabled for now. this is to enable links + // from qb to other qb questions but it's also triggering when changing + // the display type + // } else if ( + // nextProps.location.action === "PUSH" && + // getURL(nextProps.location) !== getURL(this.props.location) && + // nextProps.question && + // getURL(nextProps.location) !== nextProps.question.getUrl() + // ) { + // // a link to a different qb url was clicked + // this.props.initializeQB(nextProps.location, nextProps.params); } else if ( this.props.location.hash !== "#?tutorial" && nextProps.location.hash === "#?tutorial" ) { + // tutorial link was clicked this.props.initializeQB(nextProps.location, nextProps.params); } else if ( getURL(nextProps.location) === "/question" && getURL(this.props.location) !== "/question" ) { + // "New Question" link was clicked this.props.initializeQB(nextProps.location, nextProps.params); } } diff --git a/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx b/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx index 8cad882d28b119e1fadfef91d763387d2b434229..bc6376144dbc9f04d96144d1c6046ecfa39910a0 100644 --- a/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx +++ b/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx @@ -50,7 +50,7 @@ export default class QuestionEmbedWidget extends Component { updateEmbeddingParams(card, embeddingParams) } getPublicUrl={({ public_uuid }, extension) => - Urls.publicCard(public_uuid, extension) + Urls.publicQuestion(public_uuid, extension) } extensions={["csv", "xlsx", "json"]} /> diff --git a/frontend/src/metabase/questions/components/CollectionBadge.jsx b/frontend/src/metabase/questions/components/CollectionBadge.jsx index b124cadc51001c9df33a69c801eabd03630dfc48..f243c76da4f456c899a2ad168832dee36a2f8654 100644 --- a/frontend/src/metabase/questions/components/CollectionBadge.jsx +++ b/frontend/src/metabase/questions/components/CollectionBadge.jsx @@ -6,22 +6,23 @@ import * as Urls from "metabase/lib/urls"; import Color from "color"; import cx from "classnames"; -const CollectionBadge = ({ className, collection }) => ( - <Link - to={Urls.collection(collection)} - className={cx(className, "flex align-center px1 rounded mx1")} - style={{ - fontSize: 14, - color: Color(collection.color) - .darken(0.1) - .hex(), - backgroundColor: Color(collection.color) - .lighten(0.4) - .hex(), - }} - > - {collection.name} - </Link> -); +const CollectionBadge = ({ className, collection }) => { + const color = Color(collection.color); + const darkened = color.darken(0.1); + const lightened = color.lighten(0.4); + return ( + <Link + to={Urls.collection(collection)} + className={cx(className, "flex align-center px1 rounded mx1")} + style={{ + fontSize: 14, + color: lightened.isDark() ? "#fff" : darkened, + backgroundColor: lightened, + }} + > + {collection.name} + </Link> + ); +}; export default CollectionBadge; diff --git a/frontend/src/metabase/questions/components/Item.jsx b/frontend/src/metabase/questions/components/Item.jsx index a938ed4a3f67914cdc8034ccc876853abf89c081..2c8ec21485c2e859b2750ff5bcd320d86f44d8d1 100644 --- a/frontend/src/metabase/questions/components/Item.jsx +++ b/frontend/src/metabase/questions/components/Item.jsx @@ -1,3 +1,4 @@ +/* @flow */ /* eslint "react/prop-types": "warn" */ import React from "react"; import PropTypes from "prop-types"; @@ -12,19 +13,46 @@ import CheckBox from "metabase/components/CheckBox.jsx"; import Tooltip from "metabase/components/Tooltip.jsx"; import ModalWithTrigger from "metabase/components/ModalWithTrigger.jsx"; import MoveToCollection from "../containers/MoveToCollection.jsx"; -import Labels from "./Labels.jsx"; import CollectionBadge from "./CollectionBadge.jsx"; import * as Urls from "metabase/lib/urls"; const ITEM_ICON_SIZE = 20; +import type { Item as ItemType, Entity } from "../types"; +import type { Collection } from "metabase/meta/types/Collection"; +import type { Label } from "metabase/meta/types/Label"; + +type ItemProps = ItemType & { + showCollectionName: boolean, + setItemSelected: (selected: { [key: number]: boolean }) => void, + setFavorited: (id: number, favorited: boolean) => void, + setArchived: (id: number, archived: boolean, undoable: boolean) => void, + onEntityClick: (entity: Entity) => void, +}; + +type ItemBodyProps = { + entity: Entity, + id: number, + name: string, + description: string, + labels: Label[], + favorite: boolean, + collection: ?Collection, + setFavorited: (id: number, favorited: boolean) => void, + onEntityClick: (entity: Entity) => void, +}; + +type ItemCreatedProps = { + created: string, + by: string, +}; + const Item = ({ entity, id, name, description, - labels, created, by, favorite, @@ -37,7 +65,7 @@ const Item = ({ setArchived, showCollectionName, onEntityClick, -}) => ( +}: ItemProps) => ( <div className={cx("hover-parent hover--visibility", S.item)}> <div className="flex flex-full align-center"> <div @@ -75,7 +103,6 @@ const Item = ({ id={id} name={name} description={description} - labels={labels} favorite={favorite} collection={showCollectionName && collection} setFavorited={setFavorited} @@ -124,7 +151,6 @@ Item.propTypes = { created: PropTypes.string.isRequired, description: PropTypes.string, by: PropTypes.string.isRequired, - labels: PropTypes.array.isRequired, collection: PropTypes.object, selected: PropTypes.bool.isRequired, favorite: PropTypes.bool.isRequired, @@ -143,12 +169,11 @@ const ItemBody = pure( id, name, description, - labels, favorite, collection, setFavorited, onEntityClick, - }) => ( + }: ItemBodyProps) => ( <div className={S.itemBody}> <div className={cx("flex", S.itemTitle)}> <Link @@ -180,7 +205,6 @@ const ItemBody = pure( /> </Tooltip> )} - <Labels labels={labels} /> </div> <div className={cx( @@ -203,7 +227,7 @@ ItemBody.propTypes = { }; const ItemCreated = pure( - ({ created, by }) => + ({ created, by }: ItemCreatedProps) => created || by ? ( <div className={S.itemSubtitle}> {t`Created` + (created ? ` ${created}` : ``) + (by ? t` by ${by}` : ``)} diff --git a/frontend/src/metabase/questions/components/LabelIconPicker.css b/frontend/src/metabase/questions/components/LabelIconPicker.css deleted file mode 100644 index 61c3418d733664105f125b24bb5520493a2813f4..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/questions/components/LabelIconPicker.css +++ /dev/null @@ -1,63 +0,0 @@ -@import "../Questions.css"; - -:local(.sectionHeader) { - composes: p1 px3 from "style"; - composes: flex align-center from "style"; - display: flex; - align-items: center; - position: absolute; - bottom: 0px; -} - -:local(.list) { - composes: flex align-center from "style"; - composes: px2 from "style"; -} - -:local(.sectionList) { - composes: list; - composes: border-top from "style"; - composes: py1 from "style"; - justify-content: space-around; -} - -:local(.option) { - composes: p1 from "style"; - composes: flex layout-centered from "style"; - composes: cursor-pointer from "style"; - border: 1px solid transparent; - width: 50px; - height: 50px; -} - -:local(.option):hover { - border: 1px solid rgb(235, 235, 235); - border-radius: 3px; - background-color: rgb(248, 248, 248); - cursor: pointer; -} - -:local(.dropdownButton) { - composes: flex rounded px1 layout-centered from "style"; - border: 1px solid #d9d9d9; - padding: 10px; -} - -:local(.dropdownButton):hover { - border-color: #aaa; -} - -:local(.chevron) { - composes: ml1 from "style"; - color: #d9d9d9; -} - -:local(.category) { - justify-content: space-between; - composes: p1 cursor-pointer transition-color from "style"; - color: #cfe4f5; -} - -:local(.category):hover { - color: var(--blue-color); -} diff --git a/frontend/src/metabase/questions/components/LabelIconPicker.jsx b/frontend/src/metabase/questions/components/LabelIconPicker.jsx deleted file mode 100644 index e7e9ec7c55871f86521d85d1859744358fee243d..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/questions/components/LabelIconPicker.jsx +++ /dev/null @@ -1,147 +0,0 @@ -/* eslint "react/prop-types": "warn" */ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { t } from "c-3po"; -import S from "./LabelIconPicker.css"; - -import Icon from "metabase/components/Icon.jsx"; -import LabelIcon from "metabase/components/LabelIcon.jsx"; -import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx"; - -import { List } from "react-virtualized"; -import "react-virtualized/styles.css"; - -import * as colors from "metabase/lib/colors"; -import { categories } from "metabase/lib/emoji"; - -const ROW_HEIGHT = 45; -const VISIBLE_ROWS = 6; -const HEIGHT = VISIBLE_ROWS * ROW_HEIGHT; -const WIDTH = 330; - -const ICONS_PER_ROW = 6; - -const ROWS = []; -const CATEGORY_ROW_MAP = {}; - -function pushHeader(title) { - ROWS.push({ type: "header", title: title }); -} -function pushIcons(icons) { - for (let icon of icons) { - let current = ROWS[ROWS.length - 1]; - if (current.type !== "icons" || current.icons.length === ICONS_PER_ROW) { - current = { type: "icons", icons: [] }; - ROWS.push(current); - } - current.icons.push(icon); - } -} - -// Colors -const ALL_COLORS = [].concat( - ...[colors.saturated, colors.normal, colors.desaturated].map(o => - Object.values(o), - ), -); -pushHeader(t`Colors`); -pushIcons(ALL_COLORS); - -// Emoji -categories.map(category => { - CATEGORY_ROW_MAP[category.id] = ROWS.length; - pushHeader(category.name); - pushIcons(category.emoji); -}); - -export default class LabelIconPicker extends Component { - constructor(props, context) { - super(props, context); - this.state = { - topIndex: 0, - scrollToIndex: 0, - }; - } - - static propTypes = { - value: PropTypes.string, - onChange: PropTypes.func.isRequired, - }; - - scrollToCategory(id) { - let categoryIndex = CATEGORY_ROW_MAP[id]; - if (categoryIndex > this.state.topIndex) { - this.setState({ scrollToIndex: categoryIndex + VISIBLE_ROWS - 1 }); - } else { - this.setState({ scrollToIndex: categoryIndex }); - } - } - - render() { - const { value, onChange } = this.props; - return ( - <PopoverWithTrigger - triggerElement={<LabelIconButton value={value} />} - ref="popover" - > - <List - width={WIDTH} - height={HEIGHT} - rowCount={ROWS.length} - rowHeight={ROW_HEIGHT} - rowRenderer={({ index, key, style }) => - ROWS[index].type === "header" ? ( - <div key={key} style={style} className={S.sectionHeader}> - {ROWS[index].title} - </div> - ) : ( - <ul key={key} style={style} className={S.list}> - {ROWS[index].icons.map(icon => ( - <li - key={icon} - className={S.option} - onClick={() => { - onChange(icon); - this.refs.popover.close(); - }} - > - <LabelIcon icon={icon} size={28} /> - </li> - ))} - </ul> - ) - } - scrollToIndex={this.state.scrollToIndex} - onRowsRendered={({ - overscanStartIndex, - overscanStopIndex, - startIndex, - stopIndex, - }) => this.setState({ topIndex: startIndex })} - /> - <ul className={S.sectionList} style={{ width: WIDTH }}> - {categories.map(category => ( - <li - key={category.id} - className={S.category} - onClick={() => this.scrollToCategory(category.id)} - > - <Icon name={`emoji${category.id}`} /> - </li> - ))} - </ul> - </PopoverWithTrigger> - ); - } -} - -const LabelIconButton = ({ value = "#eee" }) => ( - <span className={S.dropdownButton}> - <LabelIcon icon={value} size={28} /> - <Icon className={S.chevron} name="chevrondown" size={14} /> - </span> -); - -LabelIconButton.propTypes = { - value: PropTypes.string, -}; diff --git a/frontend/src/metabase/questions/components/LabelPicker.jsx b/frontend/src/metabase/questions/components/LabelPicker.jsx index 6babfc61297a0f9f8b6562b4020c1096b96c75fa..7e62eface4a3b245a1cdaea7d3cf2c9adae4cc04 100644 --- a/frontend/src/metabase/questions/components/LabelPicker.jsx +++ b/frontend/src/metabase/questions/components/LabelPicker.jsx @@ -52,7 +52,7 @@ const LabelPicker = ({ labels, count, item, setLabeled }) => ( <Tooltip tooltip={t`In an upcoming release, Labels will be removed in favor of Collections.`} > - <Icon name="warning2" className="text-error float-right" /> + <Icon name="warning" className="text-error float-right" /> </Tooltip> </div> </div> diff --git a/frontend/src/metabase/questions/components/Labels.css b/frontend/src/metabase/questions/components/Labels.css deleted file mode 100644 index 49dd79656ea5b78ef153911e48ff4ec2bcb0774e..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/questions/components/Labels.css +++ /dev/null @@ -1,31 +0,0 @@ -:local(.list) { - composes: inline from "style"; -} - -:local(.listItem) { - composes: inline-block from "style"; - margin-bottom: 8px; -} - -:local(.label) { - composes: inline from "style"; - composes: text-white from "style"; - composes: text-bold from "style"; - font-size: 14px; - border-radius: 2px; - padding: 0.25em 0.5em; - margin: 0 0.25em; - line-height: 1; - white-space: nowrap; -} - -:local(.emojiLabel) { - composes: bg-white from "style"; - color: rgba(0, 0, 0, 0.54); - border: 1px solid #deeaf1; - box-shadow: 1px 1px 0 0 #bfd5e1; -} - -:local(.emojiIcon) { - margin-right: 0.5em; -} diff --git a/frontend/src/metabase/questions/components/Labels.jsx b/frontend/src/metabase/questions/components/Labels.jsx deleted file mode 100644 index b56b84708df5e755ac43a729e0a4b26b67a2b5b8..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/questions/components/Labels.jsx +++ /dev/null @@ -1,79 +0,0 @@ -/* eslint "react/prop-types": "warn" */ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { Link } from "react-router"; -import S from "./Labels.css"; -import color from "color"; -import * as Urls from "metabase/lib/urls"; - -import EmojiIcon from "metabase/components/EmojiIcon.jsx"; - -import cx from "classnames"; - -const Labels = ({ labels }) => ( - <ul className={S.list}> - {labels.map(label => ( - <li className={S.listItem} key={label.id}> - <Label {...label} /> - </li> - ))} - </ul> -); - -Labels.propTypes = { - labels: PropTypes.array.isRequired, -}; - -class Label extends Component { - constructor(props) { - super(props); - this.state = { - hovered: false, - }; - } - render() { - const { name, icon, slug } = this.props; - const { hovered } = this.state; - return ( - <Link - to={Urls.label({ slug })} - onMouseEnter={() => this.setState({ hovered: true })} - onMouseLeave={() => this.setState({ hovered: false })} - > - {icon.charAt(0) === ":" ? ( - <span className={cx(S.label, S.emojiLabel)}> - <EmojiIcon name={icon} className={S.emojiIcon} /> - <span>{name}</span> - </span> - ) : icon.charAt(0) === "#" ? ( - <span - className={S.label} - style={{ - backgroundColor: hovered - ? color(icon) - .darken(0.1) - .hex() - : icon, - boxShadow: `1px 1px 0 ${color(icon) - .darken(hovered ? 0.1 : 0.2) - .hex()}`, - transition: "background .3s ease-in-out", - }} - > - {name} - </span> - ) : ( - <span className={S.label}>{name}</span> - )} - </Link> - ); - } -} - -Label.propTypes = { - name: PropTypes.string.isRequired, - icon: PropTypes.string.isRequired, - slug: PropTypes.string.isRequired, -}; - -export default Labels; diff --git a/frontend/src/metabase/questions/components/List.jsx b/frontend/src/metabase/questions/components/List.jsx index 33eececdce502d3309f1d9b8a64dc1a2a93080d2..0d1c5c7bd9a95e2cc342aaed12a74cd190a0e21d 100644 --- a/frontend/src/metabase/questions/components/List.jsx +++ b/frontend/src/metabase/questions/components/List.jsx @@ -1,22 +1,106 @@ +/* @flow */ /* eslint "react/prop-types": "warn" */ import React from "react"; import PropTypes from "prop-types"; +import Item from "./Item"; + import S from "./List.css"; -import pure from "recompose/pure"; -import EntityItem from "../containers/EntityItem.jsx"; +// $FlowFixMe: react-virtualized ignored +import { List as VirtalizedList, WindowScroller } from "react-virtualized"; +import "react-virtualized/styles.css"; + +const HORIZONTAL_PADDING = 32; +const ROW_HEIGHT = 87; + +import type { Item as ItemType, Entity } from "../types"; + +type Props = { + items: ItemType[], -const List = ({ entityIds, ...props }) => ( - <ul className={S.list}> - {entityIds.map(entityId => ( - <EntityItem key={entityId} entityId={entityId} {...props} /> - ))} - </ul> -); + editable: boolean, + showCollectionName: boolean, -List.propTypes = { - entityIds: PropTypes.array.isRequired, + setItemSelected: (selected: { [key: number]: boolean }) => void, + setFavorited: (id: number, favorited: boolean) => void, + setArchived: (id: number, archived: boolean, undoable: boolean) => void, + onEntityClick: (entity: Entity) => void, }; -export default pure(List); +type ReactVirtualizedRowRendererProps = { + index: number, + key: string, + style: { [key: string]: any }, +}; + +export default class List extends React.Component { + props: Props; + + _list: ?React.Element<VirtalizedList>; + + static propTypes = { + items: PropTypes.array.isRequired, + + editable: PropTypes.bool, + showCollectionName: PropTypes.bool.isRequired, + + setItemSelected: PropTypes.func.isRequired, + setFavorited: PropTypes.func.isRequired, + setArchived: PropTypes.func.isRequired, + onEntityClick: PropTypes.func, + }; + + renderRow = ({ index, key, style }: ReactVirtualizedRowRendererProps) => { + const { + editable, + setItemSelected, + setFavorited, + setArchived, + onEntityClick, + showCollectionName, + items, + } = this.props; + const item = items[index]; + return ( + <div + key={key} + style={{ ...style, display: item.visible ? undefined : "none" }} + > + <Item + setItemSelected={editable ? setItemSelected : null} + setFavorited={editable ? setFavorited : null} + setArchived={editable ? setArchived : null} + onEntityClick={onEntityClick} + showCollectionName={showCollectionName} + {...item} + /> + </div> + ); + }; + + render() { + let { items } = this.props; + + return ( + <WindowScroller> + {({ height, width, isScrolling, registerChild, scrollTop }) => ( + <div ref={registerChild}> + <VirtalizedList + ref={l => (this._list = l)} + className={S.list} + autoHeight + height={height} + isScrolling={isScrolling} + rowCount={items.length} + rowHeight={ROW_HEIGHT} + rowRenderer={this.renderRow} + scrollTop={scrollTop} + width={width - HORIZONTAL_PADDING * 2} + /> + </div> + )} + </WindowScroller> + ); + } +} diff --git a/frontend/src/metabase/questions/containers/AddToDashboard.jsx b/frontend/src/metabase/questions/containers/AddToDashboard.jsx index 9260875d7406660e9e84523b98c4669f26f995dd..2c91c6e198f6a1f8a404a9d91f03165504a53237 100644 --- a/frontend/src/metabase/questions/containers/AddToDashboard.jsx +++ b/frontend/src/metabase/questions/containers/AddToDashboard.jsx @@ -84,44 +84,50 @@ export default class AddToDashboard extends Component { render() { const { query, collection } = this.state; return ( - <ModalContent - title={t`Pick a question to add`} - className="px4 mb4 scroll-y" - onClose={() => this.props.onClose()} - > - <div className="py1"> - <div className="flex align-center"> - {!query ? ( - <ExpandingSearchField - defaultValue={query && query.q} - onSearch={value => - this.setState({ - collection: null, - query: { q: value }, - }) - } - /> - ) : ( - <HeaderWithBack - name={collection && collection.name} - onBack={() => this.setState({ collection: null, query: null })} - /> - )} - {query && ( - <div className="ml-auto flex align-center"> - <h5>Sort by</h5> - <Button borderless>{t`Last modified`}</Button> - <Button borderless>{t`Alphabetical order`}</Button> - </div> - )} + <div className="wrapper wrapper--trim"> + <ModalContent + title={t`Pick a question to add`} + className="mb4 scroll-y" + onClose={() => this.props.onClose()} + > + <div className="py1"> + <div className="flex align-center ml3 mb3"> + {!query ? ( + <ExpandingSearchField + defaultValue={query && query.q} + onSearch={value => + this.setState({ + collection: null, + query: { q: value }, + }) + } + /> + ) : ( + <HeaderWithBack + name={collection && collection.name} + onBack={() => + this.setState({ collection: null, query: null }) + } + /> + )} + {query && ( + <div className="ml-auto flex align-center pr2"> + <h5>{t`Sort by`}</h5> + <Button borderless>{t`Last modified`}</Button> + <Button borderless>{t`Alphabetical order`}</Button> + </div> + )} + </div> + </div> + <div className="mx4"> + {query + ? // a search term has been entered so show the questions list + this.renderQuestionList() + : // show the collections list + this.renderCollections()} </div> - </div> - {query - ? // a search term has been entered so show the questions list - this.renderQuestionList() - : // show the collections list - this.renderCollections()} - </ModalContent> + </ModalContent> + </div> ); } } diff --git a/frontend/src/metabase/questions/containers/CollectionEditorForm.jsx b/frontend/src/metabase/questions/containers/CollectionEditorForm.jsx index 95ffa5e0a4b76f7216c65cfd6b92be1e5e6316d1..bf76c8c21f66917cd3d6798e779a031eabd438a5 100644 --- a/frontend/src/metabase/questions/containers/CollectionEditorForm.jsx +++ b/frontend/src/metabase/questions/containers/CollectionEditorForm.jsx @@ -72,7 +72,10 @@ export class CollectionEditorForm extends Component { footer={<CollectionEditorFormActions {...this.props} />} onClose={onClose} > - <div className="NewForm ml-auto mr-auto mt4 pt2" style={{ width: 540 }}> + <div + className="NewForm ml-auto mr-auto mt4 pt2" + style={{ width: "100%", maxWidth: 540 }} + > <FormField displayName={t`Name`} {...fields.name}> <Input className="Form-input full" diff --git a/frontend/src/metabase/questions/containers/EditLabels.css b/frontend/src/metabase/questions/containers/EditLabels.css deleted file mode 100644 index 9cd0efd8f6dc24b299a2ac89bb6439a25ca190aa..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/questions/containers/EditLabels.css +++ /dev/null @@ -1,51 +0,0 @@ -@import "../Questions.css"; - -:local(.header) { - composes: header from "../Questions.css"; -} - -:local(.editor) { - composes: flex-full from "style"; -} - -:local(.list) { - composes: pt2 from "style"; -} - -:local(.label), -:local(.labelEditing) { - composes: flex align-center from "style"; - composes: border-top from "style"; - composes: py2 from "style"; -} - -:local(.label) { - composes: pl1 from "style"; -} - -:local(.name) { - composes: flex-full from "style"; - composes: ml4 from "style"; - font-size: 18px; - color: var(--title-color); -} - -:local(.edit) { - font-weight: bold; - composes: pr2 from "style"; - color: var(--brand-color); -} - -:local(.delete) { - composes: cursor-pointer from "style"; -} - -:local(.delete), -:local(.edit) { - visibility: hidden; -} - -:local(.label):hover :local(.delete), -:local(.label):hover :local(.edit) { - visibility: visible; -} diff --git a/frontend/src/metabase/questions/containers/EditLabels.jsx b/frontend/src/metabase/questions/containers/EditLabels.jsx deleted file mode 100644 index 56ab31cc5d43b037ab456d4aa9b0142d04d58710..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/questions/containers/EditLabels.jsx +++ /dev/null @@ -1,150 +0,0 @@ -/* eslint "react/prop-types": "warn" */ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { connect } from "react-redux"; -import { t } from "c-3po"; -import S from "./EditLabels.css"; - -import Confirm from "metabase/components/Confirm.jsx"; -import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx"; - -import * as labelsActions from "../labels"; -import { - getLabels, - getLabelsLoading, - getLabelsError, - getEditingLabelId, -} from "../selectors"; - -import * as colors from "metabase/lib/colors"; - -const mapStateToProps = (state, props) => { - return { - labels: getLabels(state, props), - labelsLoading: getLabelsLoading(state, props), - labelsError: getLabelsError(state, props), - editingLabelId: getEditingLabelId(state, props), - }; -}; - -const mapDispatchToProps = { - ...labelsActions, -}; - -import Icon from "metabase/components/Icon.jsx"; - -// import LabelEditor from "../components/LabelEditor.jsx"; -import LabelEditorForm from "./LabelEditorForm.jsx"; -import LabelIcon from "metabase/components/LabelIcon.jsx"; -import EmptyState from "metabase/components/EmptyState.jsx"; - -@connect(mapStateToProps, mapDispatchToProps) -export default class EditLabels extends Component { - static propTypes = { - style: PropTypes.object, - labels: PropTypes.array.isRequired, - labelsLoading: PropTypes.bool.isRequired, - labelsError: PropTypes.any, - editingLabelId: PropTypes.number, - saveLabel: PropTypes.func.isRequired, - editLabel: PropTypes.func.isRequired, - deleteLabel: PropTypes.func.isRequired, - loadLabels: PropTypes.func.isRequired, - }; - - componentWillMount() { - this.props.loadLabels(); - } - - render() { - const { - style, - labels, - labelsLoading, - labelsError, - editingLabelId, - saveLabel, - editLabel, - deleteLabel, - } = this.props; - return ( - <div className={S.editor} style={style}> - <div className="wrapper wrapper--trim"> - <div className={S.header}>{t`Add and edit labels`}</div> - <div className="bordered border-error rounded p2 mb2"> - <h3 className="text-error mb1">{t`Heads up!`}</h3> - <div - >{t`In an upcoming release, Labels will be removed in favor of Collections.`}</div> - </div> - </div> - <LabelEditorForm - onSubmit={saveLabel} - initialValues={{ icon: colors.normal.blue, name: "" }} - submitButtonText={t`Create Label`} - className="wrapper wrapper--trim" - /> - <LoadingAndErrorWrapper - loading={labelsLoading} - error={labelsError} - noBackground - noWrapper - > - {() => - labels.length > 0 ? ( - <div className="wrapper wrapper--trim"> - <ul className={S.list}> - {labels.map( - label => - editingLabelId === label.id ? ( - <li key={label.id} className={S.labelEditing}> - <LabelEditorForm - formKey={String(label.id)} - className="flex-full" - onSubmit={saveLabel} - initialValues={label} - submitButtonText={t`Update Label`} - /> - <a - className={" text-grey-1 text-grey-4-hover ml2"} - onClick={() => editLabel(null)} - >{t`Cancel`}</a> - </li> - ) : ( - <li key={label.id} className={S.label}> - <LabelIcon icon={label.icon} size={28} /> - <span className={S.name}>{label.name}</span> - <a - className={S.edit} - onClick={() => editLabel(label.id)} - >{t`Edit`}</a> - <Confirm - title={t`Delete label "${label.name}"`} - action={() => deleteLabel(label.id)} - > - <Icon - className={ - S.delete + " text-grey-1 text-grey-4-hover" - } - name="close" - size={14} - /> - </Confirm> - </li> - ), - )} - </ul> - </div> - ) : ( - <div className="full-height full flex-full flex align-center justify-center"> - <EmptyState - message={t`Create labels to group and manage questions.`} - icon="label" - /> - </div> - ) - } - </LoadingAndErrorWrapper> - </div> - ); - } -} diff --git a/frontend/src/metabase/questions/containers/EntityItem.jsx b/frontend/src/metabase/questions/containers/EntityItem.jsx deleted file mode 100644 index 902e87e0eeb5a79fe43cf445903c0f39c1b67fc1..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/questions/containers/EntityItem.jsx +++ /dev/null @@ -1,69 +0,0 @@ -/* eslint "react/prop-types": "warn" */ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { connect } from "react-redux"; - -import Item from "../components/Item.jsx"; - -import { setItemSelected, setFavorited, setArchived } from "../questions"; -import { makeGetItem } from "../selectors"; - -const makeMapStateToProps = () => { - const getItem = makeGetItem(); - const mapStateToProps = (state, props) => { - return { - item: getItem(state, props), - }; - }; - return mapStateToProps; -}; - -const mapDispatchToProps = { - setItemSelected, - setFavorited, - setArchived, -}; - -@connect(makeMapStateToProps, mapDispatchToProps) -export default class EntityItem extends Component { - static propTypes = { - item: PropTypes.object.isRequired, - setItemSelected: PropTypes.func.isRequired, - setFavorited: PropTypes.func.isRequired, - setArchived: PropTypes.func.isRequired, - editable: PropTypes.bool, - showCollectionName: PropTypes.bool, - onEntityClick: PropTypes.func, - onMove: PropTypes.func, - }; - - render() { - let { - item, - editable, - setItemSelected, - setFavorited, - setArchived, - onMove, - onEntityClick, - showCollectionName, - } = this.props; - return ( - <li - className="relative" - style={{ display: item.visible ? undefined : "none" }} - > - <Item - setItemSelected={editable ? setItemSelected : null} - setFavorited={editable ? setFavorited : null} - setArchived={editable ? setArchived : null} - onMove={editable ? onMove : null} - onEntityClick={onEntityClick} - showCollectionName={showCollectionName} - entity={item} - {...item} - /> - </li> - ); - } -} diff --git a/frontend/src/metabase/questions/containers/EntityList.jsx b/frontend/src/metabase/questions/containers/EntityList.jsx index 9242e6a9c51af43d2c82609c0c67801d895d3450..aa09d832e2072b4e4356137c897d32be9ac4bf4e 100644 --- a/frontend/src/metabase/questions/containers/EntityList.jsx +++ b/frontend/src/metabase/questions/containers/EntityList.jsx @@ -1,3 +1,4 @@ +/* @flow weak */ /* eslint "react/prop-types": "warn" */ import React, { Component } from "react"; import PropTypes from "prop-types"; @@ -22,11 +23,11 @@ import { setItemSelected, setAllSelected, setArchived, + setFavorited, } from "../questions"; import { loadLabels } from "../labels"; import { - getSection, - getEntityIds, + getVisibleItems, getSectionLoading, getSectionError, getSearchText, @@ -39,8 +40,7 @@ import { const mapStateToProps = (state, props) => { return { - section: getSection(state, props), - entityIds: getEntityIds(state, props), + items: getVisibleItems(state, props), loading: getSectionLoading(state, props), error: getSectionError(state, props), @@ -56,10 +56,12 @@ const mapStateToProps = (state, props) => { }; const mapDispatchToProps = { - setItemSelected, - setAllSelected, setSearchText, + setAllSelected, + setItemSelected, setArchived, + setFavorited, + loadEntities, loadLabels, }; @@ -117,9 +119,9 @@ export default class EntityList extends Component { entityType: PropTypes.string.isRequired, section: PropTypes.string, + items: PropTypes.array.isRequired, loading: PropTypes.bool.isRequired, error: PropTypes.any, - entityIds: PropTypes.array.isRequired, searchText: PropTypes.string.isRequired, setSearchText: PropTypes.func.isRequired, visibleCount: PropTypes.number.isRequired, @@ -130,6 +132,7 @@ export default class EntityList extends Component { setItemSelected: PropTypes.func.isRequired, setAllSelected: PropTypes.func.isRequired, setArchived: PropTypes.func.isRequired, + setFavorited: PropTypes.func.isRequired, loadEntities: PropTypes.func.isRequired, loadLabels: PropTypes.func.isRequired, @@ -152,7 +155,7 @@ export default class EntityList extends Component { componentDidUpdate(prevProps) { // Scroll to the top of the list if the section changed // A little hacky, something like https://github.com/taion/scroll-behavior might be better - if (this.props.section !== prevProps.section) { + if (!_.isEqual(this.props.entityQuery, prevProps.entityQuery)) { ReactDOM.findDOMNode(this).scrollTop = 0; } } @@ -184,7 +187,7 @@ export default class EntityList extends Component { loading, error, entityType, - entityIds, + items, searchText, setSearchText, showSearchWidget, @@ -196,6 +199,7 @@ export default class EntityList extends Component { setItemSelected, setAllSelected, setArchived, + setFavorited, onChangeSection, showCollectionName, editable, @@ -205,7 +209,7 @@ export default class EntityList extends Component { const section = this.getSection(); const hasEntitiesInPlainState = - entityIds.length > 0 || section.section !== "all"; + items.length > 0 || section.section !== "all"; const showActionHeader = editable && selectedCount > 0; const showSearchHeader = hasEntitiesInPlainState && showSearchWidget; @@ -249,12 +253,14 @@ export default class EntityList extends Component { error={error} > {() => - entityIds.length > 0 ? ( + items.length > 0 ? ( <List + items={items} entityType={entityType} - entityIds={entityIds} editable={editable} setItemSelected={setItemSelected} + setArchived={setArchived} + setFavorited={setFavorited} onEntityClick={onEntityClick} showCollectionName={showCollectionName} /> diff --git a/frontend/src/metabase/questions/containers/LabelEditorForm.css b/frontend/src/metabase/questions/containers/LabelEditorForm.css deleted file mode 100644 index c79d8f49fbefed0e11b0d7257e3731e5c087f73b..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/questions/containers/LabelEditorForm.css +++ /dev/null @@ -1,12 +0,0 @@ -:local(.nameInput) { - composes: flex-full ml1 from "style"; - font-size: 18px; -} - -:local(.invalid) { - composes: border-error from "style"; -} - -:local(.errorMessage) { - composes: px1 pt1 text-bold text-error from "style"; -} diff --git a/frontend/src/metabase/questions/containers/LabelEditorForm.jsx b/frontend/src/metabase/questions/containers/LabelEditorForm.jsx deleted file mode 100644 index 15a54097f534ecbfa25a7e1436e60230b2fb6164..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/questions/containers/LabelEditorForm.jsx +++ /dev/null @@ -1,82 +0,0 @@ -/* eslint "react/prop-types": "warn" */ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import S from "./LabelEditorForm.css"; -import { t } from "c-3po"; -import LabelIconPicker from "../components/LabelIconPicker.jsx"; - -import { reduxForm } from "redux-form"; - -import cx from "classnames"; - -@reduxForm({ - form: "label", - fields: ["icon", "name", "id"], - validate: values => { - const errors = {}; - if (!values.name) { - errors.name = true; - } - if (!values.icon) { - errors.icon = t`Icon is required`; - } - return errors; - }, -}) -export default class LabelEditorForm extends Component { - static propTypes = { - className: PropTypes.string, - fields: PropTypes.object.isRequired, - invalid: PropTypes.bool.isRequired, - error: PropTypes.any, - submitButtonText: PropTypes.string.isRequired, - handleSubmit: PropTypes.func.isRequired, - }; - - render() { - const { - fields: { icon, name }, - error, - handleSubmit, - invalid, - className, - submitButtonText, - } = this.props; - const nameInvalid = - name.invalid && - ((name.active && name.value) || (!name.active && name.visited)); - const errorMessage = name.error || error; - return ( - <form className={className} onSubmit={handleSubmit}> - <div className="flex"> - <LabelIconPicker {...icon} /> - <div className="full"> - <div className="flex"> - <input - className={cx(S.nameInput, "input", { - [S.invalid]: nameInvalid, - })} - type="text" - placeholder={t`Name`} - {...name} - /> - <button - className={cx("Button", "ml1", { - disabled: invalid, - "Button--primary": !invalid, - })} - type="submit" - > - {submitButtonText} - </button> - </div> - {nameInvalid && - errorMessage && ( - <div className={S.errorMessage}>{errorMessage}</div> - )} - </div> - </div> - </form> - ); - } -} diff --git a/frontend/src/metabase/questions/containers/QuestionIndex.jsx b/frontend/src/metabase/questions/containers/QuestionIndex.jsx index dbc3c25970ace20f24329b7aefd6bef4f85bf40d..c685ed76440006798b7cdf7cc914515c262186d1 100644 --- a/frontend/src/metabase/questions/containers/QuestionIndex.jsx +++ b/frontend/src/metabase/questions/containers/QuestionIndex.jsx @@ -27,9 +27,13 @@ import EmptyState from "metabase/components/EmptyState"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; export const CollectionEmptyState = () => ( - <div className="flex align-center p2 mt4 bordered border-med border-brand rounded bg-grey-0 text-brand"> - <Icon name="collection" size={32} className="mr2" /> - <div className="flex-full"> + <div className="flex flex-column sm-flex-row align-center p2 mt4 bordered border-med border-brand rounded bg-grey-0 text-brand"> + <Icon + name="collection" + size={32} + className="mb2 sm-mr2 sm-mb0 hide sm-show" + /> + <div className="flex-full text-centered sm-text-left"> <h3>{t`Create collections for your saved questions`}</h3> <div className="mt1"> {t`Collections help you organize your questions and allow you to decide who gets to see what.`}{" "} @@ -41,7 +45,7 @@ export const CollectionEmptyState = () => ( </a> </div> </div> - <Link to="/collections/create"> + <Link to="/collections/create" className="mt2 sm-mt0"> <Button primary>{t`Create a collection`}</Button> </Link> </div> @@ -144,7 +148,7 @@ export class QuestionIndex extends Component { return ( <div className={cx("relative px4", { - "full-height flex flex-column bg-slate-extra-light": showNoSavedQuestionsState, + "full-height bg-slate-extra-light": showNoSavedQuestionsState, })} > {/* Use loading wrapper only for displaying the loading indicator as EntityList component should always be in DOM */} diff --git a/frontend/src/metabase/questions/selectors.js b/frontend/src/metabase/questions/selectors.js index 1ecdc1051ceafb82aa6b8d6fd8a7d77bae890789..c2c15afdd91209a5bf01da1fb8ad1e6107068ac3 100644 --- a/frontend/src/metabase/questions/selectors.js +++ b/frontend/src/metabase/questions/selectors.js @@ -1,8 +1,9 @@ +/* @flow weak */ + import { createSelector } from "reselect"; import moment from "moment"; import { getIn } from "icepick"; import _ from "underscore"; -import { t } from "c-3po"; import visualizations from "metabase/visualizations"; import { caseInsensitiveSearch } from "metabase/lib/string"; @@ -13,8 +14,6 @@ export const getEntityQuery = (state, props) => ? JSON.stringify(props.entityQuery) : state.questions.lastEntityQuery; -export const getSection = (state, props) => - props.entityQuery && JSON.stringify(props.entityQuery); export const getLoadingInitialEntities = (state, props) => state.questions.loadingInitialEntities; export const getEntities = (state, props) => state.questions.entities; @@ -67,49 +66,16 @@ export const getEntityIds = createSelector( : [], ); -const getEntity = (state, props) => - getEntities(state, props)[props.entityType][props.entityId]; - -const getEntitySelected = (state, props) => - getSelectedIds(state, props)[props.entityId] || false; - -const getEntityVisible = (state, props) => - caseInsensitiveSearch( - getEntity(state, props).name, - getSearchText(state, props), - ); - const getLabelEntities = (state, props) => state.labels.entities.labels; -export const makeGetItem = () => { - const getItem = createSelector( - [getEntity, getEntitySelected, getEntityVisible, getLabelEntities], - (entity, selected, visible, labelEntities) => ({ - name: entity.name, - id: entity.id, - created: entity.created_at ? moment(entity.created_at).fromNow() : null, - by: entity.creator && entity.creator.common_name, - icon: visualizations.get(entity.display).iconName, - favorite: entity.favorite, - archived: entity.archived, - collection: entity.collection, - labels: entity.labels - ? entity.labels.map(labelId => labelEntities[labelId]).filter(l => l) - : [], - selected, - visible, - description: entity.description, - }), - ); - return getItem; -}; - +// returns raw entity objects for the current section export const getAllEntities = createSelector( [getEntityIds, getEntityType, getEntities], (entityIds, entityType, entities) => entityIds.map(entityId => getIn(entities, [entityType, entityId])), ); +// returns visible raw entity objects for the current section export const getVisibleEntities = createSelector( [getAllEntities, getSearchText], (allEntities, searchText) => @@ -118,48 +84,87 @@ export const getVisibleEntities = createSelector( ), ); +// returns selected raw entity objects for the current section export const getSelectedEntities = createSelector( [getVisibleEntities, getSelectedIds], (visibleEntities, selectedIds) => visibleEntities.filter(entity => selectedIds[entity.id]), ); +function iconForEntity(entity) { + const viz = visualizations.get(entity.display); + return viz && viz.iconName; +} + +function labelsForEntity(entity, labelEntities) { + return entity.labels + ? entity.labels.map(labelId => labelEntities[labelId]).filter(l => l) + : []; +} + +function itemForEntity(entity, selectedIds, labelEntities) { + return { + entity: entity, + id: entity.id, + name: entity.name, + created: entity.created_at ? moment(entity.created_at).fromNow() : null, + by: entity.creator && entity.creator.common_name, + icon: iconForEntity(entity), + favorite: entity.favorite, + archived: entity.archived, + collection: entity.collection, + labels: labelsForEntity(entity, labelEntities), + selected: selectedIds[entity.id] || false, + visible: true, + description: entity.description, + }; +} + +// return enhanced "item" objects suitable for diplay by List/Item components +export const getAllItems = createSelector( + [getAllEntities, getLabelEntities, getSelectedIds], + (entities, labelEntities, selectedIds) => + entities.map(entity => itemForEntity(entity, selectedIds, labelEntities)), +); + +// returns visible items +export const getVisibleItems = createSelector( + [getAllItems, getSearchText], + (allItems, searchText) => + allItems.filter(item => caseInsensitiveSearch(item.name, searchText)), +); + +// return total item count export const getTotalCount = createSelector( [getAllEntities], entities => entities.length, ); +// returns visible item count export const getVisibleCount = createSelector( [getVisibleEntities], visibleEntities => visibleEntities.length, ); +// returns selected item count export const getSelectedCount = createSelector( [getSelectedEntities], selectedEntities => selectedEntities.length, ); +// returns true if all visible items are selected export const getAllAreSelected = createSelector( [getSelectedCount, getVisibleCount], (selectedCount, visibleCount) => selectedCount === visibleCount && visibleCount > 0, ); +// returns true if the current section is the archive export const getSectionIsArchive = createSelector( [getQuery], query => query && query.f === "archived", ); -const sections = [ - { id: "all", name: t`All questions`, icon: "all" }, - { id: "fav", name: t`Favorites`, icon: "star" }, - { id: "recent", name: t`Recently viewed`, icon: "recents" }, - { id: "mine", name: t`Saved by me`, icon: "mine" }, - { id: "popular", name: t`Most popular`, icon: "popular" }, -]; - -export const getSections = (state, props) => sections; - export const getEditingLabelId = (state, props) => state.labels.editing; export const getLabels = createSelector( @@ -203,26 +208,3 @@ export const getLabelsWithSelectedState = createSelector( : counts[label.id] === selectedCount ? true : null, })), ); - -export const getSectionName = createSelector( - [getSection, getSections, getLabels], - (sectionId, sections, labels) => { - let match = sectionId && sectionId.match(/^(.*)-(.*)/); - if (match) { - if (match[1] === "label") { - let label = _.findWhere(labels, { slug: match[2] }); - if (label && label.name) { - return label.name; - } - } - } else { - let section = _.findWhere(sections, { id: sectionId }); - if (section) { - return section.name || ""; - } else if (sectionId === "archived") { - return t`Archive`; - } - } - return ""; - }, -); diff --git a/frontend/src/metabase/questions/types.js b/frontend/src/metabase/questions/types.js new file mode 100644 index 0000000000000000000000000000000000000000..cddfdf68f442fd0ff16dfa1e0bccb7a416287927 --- /dev/null +++ b/frontend/src/metabase/questions/types.js @@ -0,0 +1,23 @@ +/* flow */ + +import type { Collection } from "metabase/meta/types/Collection"; + +export type Entity = { + id: number, +}; + +export type Item = { + entity: Entity, + id: number, + name: string, + description: string, + created: ?string, + by: ?string, + icon: ?string, + favorite: boolean, + archived: boolean, + selected: boolean, + visible: boolean, + collection: Collection, + labels: Label[], +}; diff --git a/frontend/src/metabase/redux/metadata.js b/frontend/src/metabase/redux/metadata.js index 6c7016151658d8629fdd137ccdcb581cd2162f09..806e05c32e46d5118c771e40db0e9fdaed6e665f 100644 --- a/frontend/src/metabase/redux/metadata.js +++ b/frontend/src/metabase/redux/metadata.js @@ -355,7 +355,8 @@ export const fetchField = createThunkAction(FETCH_FIELD, function( return async function(dispatch, getState) { const requestStatePath = ["metadata", "fields", fieldId]; const existingStatePath = requestStatePath; - const getData = () => MetabaseApi.field_get({ fieldId }); + const getData = async () => + normalize(await MetabaseApi.field_get({ fieldId }), FieldSchema); return await fetchData({ dispatch, @@ -420,6 +421,11 @@ export const updateFieldValues = createThunkAction( export const ADD_PARAM_VALUES = "metabase/metadata/ADD_PARAM_VALUES"; export const addParamValues = createAction(ADD_PARAM_VALUES); +export const ADD_FIELDS = "metabase/metadata/ADD_FIELDS"; +export const addFields = createAction(ADD_FIELDS, fields => { + return normalize(fields, [FieldSchema]); +}); + export const UPDATE_FIELD = "metabase/metadata/UPDATE_FIELD"; export const updateField = createThunkAction(UPDATE_FIELD, function(field) { return async function(dispatch, getState) { @@ -689,15 +695,6 @@ const tables = handleActions({}, {}); const fields = handleActions( { - [FETCH_FIELD]: { - next: (state, { payload: field }) => ({ - ...state, - [field.id]: { - ...(state[field.id] || {}), - ...field, - }, - }), - }, [FETCH_FIELD_VALUES]: { next: (state, { payload: fieldValues }) => fieldValues diff --git a/frontend/src/metabase/reference/components/EditHeader.jsx b/frontend/src/metabase/reference/components/EditHeader.jsx index b7c62f2865952d9c97f3b4a80e0a192de5372a64..680d8e65c1f1b5c61b9f4c35ed218c7ed1600ce1 100644 --- a/frontend/src/metabase/reference/components/EditHeader.jsx +++ b/frontend/src/metabase/reference/components/EditHeader.jsx @@ -15,7 +15,7 @@ const EditHeader = ({ onSubmit, revisionMessageFormField, }) => ( - <div className={cx("EditHeader wrapper py1", S.editHeader)}> + <div className={cx("EditHeader wrapper p1", S.editHeader)}> <div>{t`You are editing this page`}</div> <div className={S.editHeaderButtons}> <button diff --git a/frontend/src/metabase/reference/components/Formula.jsx b/frontend/src/metabase/reference/components/Formula.jsx index e7aa3ccb1a2484d71aa4405544fd5464da85a9bf..225651db7176516a78c2d7acf811241c9a01a31a 100644 --- a/frontend/src/metabase/reference/components/Formula.jsx +++ b/frontend/src/metabase/reference/components/Formula.jsx @@ -2,7 +2,7 @@ import React, { Component } from "react"; import cx from "classnames"; import { connect } from "react-redux"; import { t } from "c-3po"; -import ReactCSSTransitionGroup from "react-addons-css-transition-group"; +import { CSSTransitionGroup } from "react-transition-group"; import S from "./Formula.css"; @@ -52,7 +52,7 @@ export default class Formula extends Component { <Icon name="beaker" size={24} /> <span className={S.formulaTitle}>{t`View the ${type} formula`}</span> </div> - <ReactCSSTransitionGroup + <CSSTransitionGroup transitionName="formulaDefinition" transitionEnterTimeout={300} transitionLeaveTimeout={300} @@ -66,7 +66,7 @@ export default class Formula extends Component { /> </div> )} - </ReactCSSTransitionGroup> + </CSSTransitionGroup> </div> ); } diff --git a/frontend/src/metabase/reference/components/GuideHeader.jsx b/frontend/src/metabase/reference/components/GuideHeader.jsx index a560cb0a8f1a6afc5658eab3b20b9a0f46f0eff1..8362926d69fb5702db0372b6dbcc1b6c47ffd2c1 100644 --- a/frontend/src/metabase/reference/components/GuideHeader.jsx +++ b/frontend/src/metabase/reference/components/GuideHeader.jsx @@ -6,12 +6,11 @@ import EditButton from "metabase/reference/components/EditButton.jsx"; const GuideHeader = ({ startEditing, isSuperuser }) => ( <div> - <div className="wrapper wrapper--trim py4 my3"> + <div className="wrapper wrapper--trim sm-py4 sm-my3"> <div className="flex align-center"> - <h1 - className="text-dark" - style={{ fontWeight: 700 }} - >{t`Start here.`}</h1> + <h1 className="text-dark" style={{ fontWeight: 700 }}> + {t`Start here`}. + </h1> {isSuperuser && ( <span className="ml-auto"> <EditButton startEditing={startEditing} /> diff --git a/frontend/src/metabase/reference/databases/FieldSidebar.jsx b/frontend/src/metabase/reference/databases/FieldSidebar.jsx index 407308cff5bb4ccb99f93109c12ad299971ee50e..fa7a6d3a7c367f888d19dc4e8d1c35efb90f766e 100644 --- a/frontend/src/metabase/reference/databases/FieldSidebar.jsx +++ b/frontend/src/metabase/reference/databases/FieldSidebar.jsx @@ -44,14 +44,15 @@ const FieldSidebar = ({ icon="document" name={t`Details`} /> - {showXray && ( + + { <SidebarItem - key={`/xray/field/${field.id}/approximate`} - href={`/xray/field/${field.id}/approximate`} - icon="beaker" - name={t`X-ray this Field`} + key={`/auto/dashboard/field/${field.id}`} + href={`/auto/dashboard/field/${field.id}`} + icon="bolt" + name={t`X-ray this field`} /> - )} + } </ul> </div> ); diff --git a/frontend/src/metabase/reference/databases/NoDatabasesEmptyState.jsx b/frontend/src/metabase/reference/databases/NoDatabasesEmptyState.jsx index 5ad64b6a0cd85a839e81b5260fae02a42f75b348..7579339a7ccb5adb99d322b43b4b69c4419a51cf 100644 --- a/frontend/src/metabase/reference/databases/NoDatabasesEmptyState.jsx +++ b/frontend/src/metabase/reference/databases/NoDatabasesEmptyState.jsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import React from "react"; import { t } from "c-3po"; import AdminAwareEmptyState from "metabase/components/AdminAwareEmptyState"; diff --git a/frontend/src/metabase/reference/databases/TableSidebar.jsx b/frontend/src/metabase/reference/databases/TableSidebar.jsx index 45b02723236282d01c02e38f23f9881aa1b32e8f..4d2a38c1ef60817cfcaa22b1d9e55ca74ff305a2 100644 --- a/frontend/src/metabase/reference/databases/TableSidebar.jsx +++ b/frontend/src/metabase/reference/databases/TableSidebar.jsx @@ -44,14 +44,12 @@ const TableSidebar = ({ database, table, style, className, showXray }) => ( icon="all" name={t`Questions about this table`} /> - {showXray && ( - <SidebarItem - key={`/xray/table/${table.id}/approximate`} - href={`/xray/table/${table.id}/approximate`} - icon="beaker" - name={t`X-ray this table`} - /> - )} + <SidebarItem + key={`/auto/dashboard/table/${table.id}`} + href={`/auto/dashboard/table/${table.id}`} + icon="bolt" + name={t`X-ray this table`} + /> </ol> </div> ); diff --git a/frontend/src/metabase/reference/guide/GettingStartedGuide.jsx b/frontend/src/metabase/reference/guide/GettingStartedGuide.jsx index 584b453f1d807086a4b7d6ea24b34471350ea22c..e42b023373d9cb593e3ab48b257b76c1d4b1f760 100644 --- a/frontend/src/metabase/reference/guide/GettingStartedGuide.jsx +++ b/frontend/src/metabase/reference/guide/GettingStartedGuide.jsx @@ -138,7 +138,7 @@ export default class GettingStartedGuide extends Component { } = this.props; return ( - <div className="full relative py4" style={style}> + <div className="full relative p3" style={style}> <LoadingAndErrorWrapper className="full" style={style} diff --git a/frontend/src/metabase/reference/metrics/MetricSidebar.jsx b/frontend/src/metabase/reference/metrics/MetricSidebar.jsx index 5f82db2624a0da6418fc31bb08dffdedc5624c70..be3a5eb17fa3f011be14db3d8b24054ff98caa77 100644 --- a/frontend/src/metabase/reference/metrics/MetricSidebar.jsx +++ b/frontend/src/metabase/reference/metrics/MetricSidebar.jsx @@ -32,6 +32,12 @@ const MetricSidebar = ({ metric, user, style, className }) => ( icon="all" name={t`Questions about ${metric.name}`} /> + <SidebarItem + key={`/auto/dashboard/metric/${metric.id}`} + href={`/auto/dashboard/metric/${metric.id}`} + icon="bolt" + name={t`X-ray this metric`} + /> {user && user.is_superuser && ( <SidebarItem diff --git a/frontend/src/metabase/reference/segments/SegmentSidebar.jsx b/frontend/src/metabase/reference/segments/SegmentSidebar.jsx index d2124cf4613ff97653d5f7054af1b23b0c1a2b33..ae96ecf0e006963f960b462a6131dfad8b7246a2 100644 --- a/frontend/src/metabase/reference/segments/SegmentSidebar.jsx +++ b/frontend/src/metabase/reference/segments/SegmentSidebar.jsx @@ -39,11 +39,12 @@ const SegmentSidebar = ({ segment, user, style, className }) => ( name={t`Questions about this segment`} /> <SidebarItem - key={`/xray/segment/${segment.id}/approximate`} - href={`/xray/segment/${segment.id}/approximate`} - icon="all" + key={`/auto/dashboard/segment/${segment.id}`} + href={`/auto/dashboard/segment/${segment.id}`} + icon="bolt" name={t`X-ray this segment`} /> + {user && user.is_superuser && ( <SidebarItem diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 5db1d0bdb2a73b2902bf3914fbfc7a42a53d637a..61d34e5ffb1fc3c607f044514370150df94408a3 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -25,6 +25,7 @@ import HomepageApp from "metabase/home/containers/HomepageApp.jsx"; import Dashboards from "metabase/dashboards/containers/Dashboards.jsx"; import DashboardsArchive from "metabase/dashboards/containers/DashboardsArchive.jsx"; import DashboardApp from "metabase/dashboard/containers/DashboardApp.jsx"; +import AutomaticDashboardApp from "metabase/dashboard/containers/AutomaticDashboardApp.jsx"; import QuestionIndex from "metabase/questions/containers/QuestionIndex.jsx"; import Archive from "metabase/questions/containers/Archive.jsx"; @@ -32,7 +33,6 @@ import CollectionPage from "metabase/questions/containers/CollectionPage.jsx"; import CollectionEdit from "metabase/questions/containers/CollectionEdit.jsx"; import CollectionCreate from "metabase/questions/containers/CollectionCreate.jsx"; import SearchResults from "metabase/questions/containers/SearchResults.jsx"; -import EditLabels from "metabase/questions/containers/EditLabels.jsx"; import CollectionPermissions from "metabase/admin/permissions/containers/CollectionsPermissionsApp.jsx"; import EntityList from "metabase/questions/containers/EntityList.jsx"; @@ -40,6 +40,7 @@ import PulseEditApp from "metabase/pulse/containers/PulseEditApp.jsx"; import PulseListApp from "metabase/pulse/containers/PulseListApp.jsx"; import QueryBuilder from "metabase/query_builder/containers/QueryBuilder.jsx"; import SetupApp from "metabase/setup/containers/SetupApp.jsx"; +import PostSetupApp from "metabase/setup/containers/PostSetupApp.jsx"; import UserSettingsApp from "metabase/user/containers/UserSettingsApp.jsx"; // new question @@ -202,6 +203,8 @@ export const getRoutes = store => ( <Route component={IsAuthenticated}> {/* HOME */} <Route path="/" component={HomepageApp} /> + <Route path="/explore" component={PostSetupApp} /> + <Route path="/explore/:databaseId" component={PostSetupApp} /> {/* DASHBOARD LIST */} <Route @@ -224,6 +227,8 @@ export const getRoutes = store => ( <ModalRoute path="history" modal={DashboardHistoryModal} /> </Route> + <Route path="/auto/dashboard/*" component={AutomaticDashboardApp} /> + {/* QUERY BUILDER */} <Route path="/question"> <IndexRoute component={QueryBuilder} /> @@ -272,10 +277,6 @@ export const getRoutes = store => ( <Route path=":collectionId" component={CollectionEdit} /> </Route> - <Route path="/labels"> - <IndexRoute component={EditLabels} /> - </Route> - {/* REFERENCE */} <Route path="/reference" title={`Data Reference`}> <IndexRedirect to="/reference/guide" /> diff --git a/frontend/src/metabase/schema.js b/frontend/src/metabase/schema.js index 2a2b61874945e6cd547b04fcbb6bac8a471a16fe..656b8141eed80b1034d9194007b5e10305c997c7 100644 --- a/frontend/src/metabase/schema.js +++ b/frontend/src/metabase/schema.js @@ -22,6 +22,10 @@ TableSchema.define({ FieldSchema.define({ target: FieldSchema, table: TableSchema, + name_field: FieldSchema, + dimensions: { + human_readable_field: FieldSchema, + }, }); SegmentSchema.define({ diff --git a/frontend/src/metabase/selectors/metadata.js b/frontend/src/metabase/selectors/metadata.js index acb3dce8da15f26be3c9d25b9def6748fe2678af..492529a3619d37778ca954fc709ed39d8b0ec905 100644 --- a/frontend/src/metabase/selectors/metadata.js +++ b/frontend/src/metabase/selectors/metadata.js @@ -84,13 +84,20 @@ export const getMetadata = createSelector( hydrateList(meta.tables, "segments", meta.segments); hydrateList(meta.tables, "metrics", meta.metrics); - hydrate(meta.tables, "db", t => meta.databases[t.db_id || t.db]); - - hydrate(meta.segments, "table", s => meta.tables[s.table_id]); - hydrate(meta.metrics, "table", m => meta.tables[m.table_id]); - hydrate(meta.fields, "table", f => meta.tables[f.table_id]); - - hydrate(meta.fields, "target", f => meta.fields[f.fk_target_field_id]); + hydrate(meta.tables, "db", t => meta.database(t.db_id || t.db)); + + hydrate(meta.segments, "table", s => meta.table(s.table_id)); + hydrate(meta.metrics, "table", m => meta.table(m.table_id)); + hydrate(meta.fields, "table", f => meta.table(f.table_id)); + + hydrate(meta.fields, "target", f => meta.field(f.fk_target_field_id)); + hydrate(meta.fields, "name_field", f => { + if (f.name_field != null) { + return meta.field(f.name_field); + } else if (f.table && f.isPK()) { + return _.find(f.table.fields, f => f.isEntityName()); + } + }); hydrate(meta.fields, "operators", f => getOperators(f, f.table)); hydrate(meta.tables, "aggregation_options", t => @@ -221,10 +228,14 @@ export const makeGetMergedParameterFieldValues = () => { export function copyObjects(metadata, objects, Klass) { let copies = {}; for (const object of Object.values(objects)) { - // $FlowFixMe - copies[object.id] = new Klass(object); - // $FlowFixMe - copies[object.id].metadata = metadata; + if (object && object.id != null) { + // $FlowFixMe + copies[object.id] = new Klass(object); + // $FlowFixMe + copies[object.id].metadata = metadata; + } else { + console.warn("Missing id:", object); + } } return copies; } diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index d1922d8d1098df3c62de49d8e33da304b74dbe8b..dd38bc3a0e27427bc9a59b0fe60936674f4b9ab6 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -11,6 +11,12 @@ const embedBase = IS_EMBED_PREVIEW ? "/api/preview_embed" : "/api/embed"; // $FlowFixMe: Flow doesn't understand webpack loader syntax import getGAMetadata from "promise-loader?global!metabase/lib/ga-metadata"; // eslint-disable-line import/default +import type { Data, Options } from "metabase/lib/api"; + +import type { DatabaseId } from "metabase/meta/types/Database"; +import type { DatabaseCandidates } from "metabase/meta/types/Auto"; +import type { DashboardWithCards } from "metabase/meta/types/Dashboard"; + export const ActivityApi = { list: GET("/api/activity"), recent_views: GET("/api/activity/recent_views"), @@ -42,7 +48,10 @@ export const CardApi = { export const DashboardApi = { list: GET("/api/dashboard"), + // creates a new empty dashboard create: POST("/api/dashboard"), + // saves a complete transient dashboard + save: POST("/api/dashboard/save"), get: GET("/api/dashboard/:dashId"), update: PUT("/api/dashboard/:id"), delete: DELETE("/api/dashboard/:dashId"), @@ -84,9 +93,23 @@ export const EmbedApi = { ), }; +type $AutoApi = { + dashboard: ({ subPath: string }) => DashboardWithCards, + db_candidates: ({ id: DatabaseId }) => DatabaseCandidates, +}; + +export const AutoApi: $AutoApi = { + dashboard: GET("/api/automagic-dashboards/:subPath", { + // this prevents the `subPath` parameter from being URL encoded + raw: { subPath: true }, + }), + db_candidates: GET("/api/automagic-dashboards/database/:id/candidates"), +}; + export const EmailApi = { updateSettings: PUT("/api/email"), sendTest: POST("/api/email/test"), + clear: DELETE("/api/email"), }; export const SlackApi = { @@ -315,4 +338,41 @@ export const I18NApi = { locale: GET("/app/locales/:locale.json"), }; +export function setPublicQuestionEndpoints(uuid: string) { + setFieldEndpoints("/api/public/card/:uuid", { uuid }); +} +export function setPublicDashboardEndpoints(uuid: string) { + setFieldEndpoints("/api/public/dashboard/:uuid", { uuid }); +} +export function setEmbedQuestionEndpoints(token: string) { + if (!IS_EMBED_PREVIEW) { + setFieldEndpoints("/api/embed/card/:token", { token }); + } +} +export function setEmbedDashboardEndpoints(token: string) { + if (!IS_EMBED_PREVIEW) { + setFieldEndpoints("/api/embed/dashboard/:token", { token }); + } +} + +function GET_with(url: string, params: Data) { + return (data: Data, options?: Options) => + GET(url)({ ...params, ...data }, options); +} + +export function setFieldEndpoints(prefix: string, params: Data) { + MetabaseApi.field_values = GET_with( + prefix + "/field/:fieldId/values", + params, + ); + MetabaseApi.field_search = GET_with( + prefix + "/field/:fieldId/search/:searchFieldId", + params, + ); + MetabaseApi.field_remapping = GET_with( + prefix + "/field/:fieldId/remapping/:remappedFieldId", + params, + ); +} + global.services = exports; diff --git a/frontend/src/metabase/setup/components/DatabaseConnectionStep.jsx b/frontend/src/metabase/setup/components/DatabaseConnectionStep.jsx index 84185c292d019b0f5ace5c30699876c6ae31303d..0a151a3c965b994c0c2c73909d4e29d8fdee0c45 100644 --- a/frontend/src/metabase/setup/components/DatabaseConnectionStep.jsx +++ b/frontend/src/metabase/setup/components/DatabaseConnectionStep.jsx @@ -122,7 +122,7 @@ export default class DatabaseConnectionStep extends Component { return ( <label className="Select Form-offset mt1"> <select defaultValue={engine} onChange={this.chooseDatabaseEngine}> - <option value="">Select the type of Database you use</option> + <option value="">{t`Select the type of Database you use`}</option> {engineNames.map(opt => ( <option key={opt} value={opt}> {engines[opt]["driver-name"]} @@ -185,7 +185,7 @@ export default class DatabaseConnectionStep extends Component { formError={formError} hiddenFields={{ ssl: true }} submitFn={this.connectionDetailsCaptured} - submitButtonText={"Next"} + submitButtonText={t`Next`} /> ) : null} diff --git a/frontend/src/metabase/setup/components/PreferencesStep.jsx b/frontend/src/metabase/setup/components/PreferencesStep.jsx index d6f78e486ef7772a5335467bcb6793e3565302e2..a802869a409939fa8ee5404f75dffb6097006d89 100644 --- a/frontend/src/metabase/setup/components/PreferencesStep.jsx +++ b/frontend/src/metabase/setup/components/PreferencesStep.jsx @@ -103,7 +103,7 @@ export default class PreferencesStep extends Component { <div className="Form-field Form-offset"> <ul style={{ listStyle: "disc inside", lineHeight: "200%" }}> <li>{jt`Metabase ${( - <span style={{ fontWeight: "bold" }}>never</span> + <span style={{ fontWeight: "bold" }}>{t`never`}</span> )} collects anything about your data or question results.`}</li> <li>{t`All collection is completely anonymous.`}</li> <li diff --git a/frontend/src/metabase/setup/components/Setup.jsx b/frontend/src/metabase/setup/components/Setup.jsx index 15838ea9a5019f1b3bfec0f49d3de14acd232fd5..082118c0aeee8065af5a5f47e34202bbe26f7fdc 100644 --- a/frontend/src/metabase/setup/components/Setup.jsx +++ b/frontend/src/metabase/setup/components/Setup.jsx @@ -45,7 +45,9 @@ export default class Setup extends Component { {t`If you feel stuck`},{" "} <a className="link" - href={"http://www.metabase.com/docs/" + tag + "/setting-up-metabase"} + href={ + "http://www.metabase.com/docs/" + tag + "/setting-up-metabase.html" + } target="_blank" >{t`our getting started guide`}</a>{" "} {t`is just a click away.`} @@ -148,7 +150,7 @@ export default class Setup extends Component { </div> <div className="pt4 pb2"> <Link - to="/?new" + to="/explore" className="Button Button--primary" onClick={this.completeSetup.bind(this)} >{t`Take me to Metabase`}</Link> diff --git a/frontend/src/metabase/setup/containers/PostSetupApp.jsx b/frontend/src/metabase/setup/containers/PostSetupApp.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ed33eadb28968a4e23b890c2dcbde2aa9d301a4b --- /dev/null +++ b/frontend/src/metabase/setup/containers/PostSetupApp.jsx @@ -0,0 +1,199 @@ +/* @flow */ + +import React, { Component } from "react"; + +import { Link } from "react-router"; +import ExplorePane from "metabase/components/ExplorePane"; +import MetabotLogo from "metabase/components/MetabotLogo"; +import ProgressBar from "metabase/components/ProgressBar"; +import Quotes from "metabase/components/Quotes"; +import { withBackground } from "metabase/hoc/Background"; + +import { MetabaseApi, AutoApi } from "metabase/services"; +import _ from "underscore"; +import cx from "classnames"; +import { t } from "c-3po"; + +const CANDIDATES_POLL_INTERVAL = 2000; +// ensure this is 1 second offset from CANDIDATES_POLL_INTERVAL due to +// concurrency issue in candidates endpoint +const CANDIDATES_TIMEOUT = 11000; + +const QUOTES = [ + t`Metabot is admiring your integers…`, + t`Metabot is performing billions of differential equations…`, + t`Metabot is doing science…`, + t`Metabot is checking out your metrics…`, + t`Metabot is looking for trends and outliers…`, + t`Metabot is consulting the quantum abacus…`, + t`Metabot is feeling pretty good about all this…`, +]; + +import type { DatabaseCandidates } from "metabase/meta/types/Auto"; + +type Props = { + params: { + databaseId?: number, + }, +}; +type State = { + databaseId: ?number, + isSample: ?boolean, + candidates: ?DatabaseCandidates, + sampleCandidates: ?DatabaseCandidates, +}; + +@withBackground("bg-slate-extra-light") +export default class PostSetupApp extends Component { + props: Props; + state: State = { + databaseId: null, + isSample: null, + candidates: null, + sampleCandidates: null, + }; + + _sampleTimeout: ?number; + _pollTimer: ?number; + + // $FlowFixMe: doesn't expect componentWillMount to return Promise<void> + async componentWillMount() { + // If we get passed in a database id, just use that. + // Don't fall back to the sample dataset + if (this.props.params.databaseId) { + this.setState({ databaseId: this.props.params.databaseId }, () => { + this._loadCandidates(); + }); + } else { + // Otherwise, it's a fresh start. Grab the last added database + const [sampleDbs, otherDbs] = _.partition( + await MetabaseApi.db_list(), + db => db.is_sample, + ); + if (otherDbs.length > 0) { + this.setState({ databaseId: otherDbs[0].id, isSample: false }, () => { + this._loadCandidates(); + }); + // If things are super slow for whatever reason, + // just load candidates for sample dataset + this._sampleTimeout = setTimeout(async () => { + this._sampleTimeout = null; + this.setState({ + sampleCandidates: await AutoApi.db_candidates({ + id: sampleDbs[0].id, + }), + }); + }, CANDIDATES_TIMEOUT); + } else { + this.setState({ databaseId: sampleDbs[0].id, isSample: true }, () => { + this._loadCandidates(); + }); + } + } + this._pollTimer = setInterval( + this._loadCandidates, + CANDIDATES_POLL_INTERVAL, + ); + } + componentWillUnmount() { + this._clearTimers(); + } + _clearTimers() { + if (this._pollTimer != null) { + clearInterval(this._pollTimer); + this._pollTimer = null; + } + if (this._sampleTimeout != null) { + clearInterval(this._sampleTimeout); + this._sampleTimeout = null; + } + } + _loadCandidates = async () => { + try { + const { databaseId } = this.state; + if (databaseId != null) { + const candidates = await AutoApi.db_candidates({ + id: databaseId, + }); + if (candidates && candidates.length > 0) { + this._clearTimers(); + this.setState({ candidates }); + } + } + } catch (e) { + console.log(e); + } + }; + render() { + let { candidates, sampleCandidates, isSample } = this.state; + + return ( + <div className="full-height"> + <div className="flex full-height"> + <div + style={{ maxWidth: 587 }} + className="ml-auto mr-auto mt-auto mb-auto py2" + > + {!candidates ? ( + <div> + <h2 className="text-centered mx4 px4"> + {t`We’ll show you some interesting explorations of your data in + just a few minutes.`} + </h2> + <BorderedPanel className="p4 my4 flex"> + <div className="mt1"> + <MetabotLogo /> + </div> + <div className="flex-full ml3 mt1"> + <div className="mb1"> + <Quotes quotes={QUOTES} period={2000} /> + </div> + {/*The percentage is hardcoded so we can animate this*/} + <ProgressBar percentage={1} animated /> + </div> + </BorderedPanel> + {sampleCandidates && ( + <BorderedPanel> + <ExplorePane + candidates={sampleCandidates} + title={null} + description={t`This seems to be taking a while. In the meantime, you can check out one of these example explorations to see what Metabase can do for you.`} + /> + </BorderedPanel> + )} + </div> + ) : ( + <BorderedPanel> + <ExplorePane + candidates={candidates} + description={ + isSample + ? t`Once you connect your own data, I can show you some automatic explorations called x-rays. Here are some examples with sample data.` + : t`I took a look at the data you just connected, and I have some explorations of interesting things I found. Hope you like them!` + } + /> + </BorderedPanel> + )} + <div className="m4 text-centered"> + <Link + to="/" + className="no-decoration text-bold text-grey-3 text-grey-4-hover" + > + {t`I'm done exploring for now`} + </Link> + </div> + </div> + </div> + </div> + ); + } +} + +const BorderedPanel = ({ className, style, children }) => ( + <div + className={cx("bordered rounded shadowed bg-white", className)} + style={style} + > + {children} + </div> +); diff --git a/frontend/src/metabase/store.js b/frontend/src/metabase/store.js index 022146bf976938a7c68703aeb6c68d0ccb3706c9..50c8777688d60b8f7999d706c1ae7c0f6e4dc483 100644 --- a/frontend/src/metabase/store.js +++ b/frontend/src/metabase/store.js @@ -3,6 +3,7 @@ import { combineReducers, applyMiddleware, createStore, compose } from "redux"; import { reducer as form } from "redux-form"; import { routerReducer as routing, routerMiddleware } from "react-router-redux"; +import MetabaseAnalytics from "metabase/lib/analytics"; import promise from "redux-promise"; import logger from "redux-logger"; @@ -28,6 +29,73 @@ const devToolsExtension = window.devToolsExtension ? window.devToolsExtension() : f => f; +// Look for redux action names that take the form `metabase/<app_section>/<ACTION_NAME> +const METABASE_TRACKABLE_ACTION_REGEX = /^metabase\/(.+)\/([^\/]+)$/; + +/** + * Track events by looking at redux dispatch + * ----- + * This redux middleware is meant to help automate event capture for instances + * that opt in to anonymous tracking by looking at redux actions and either + * using the name of the action, or defined analytics metadata to send event + * data to GA. This makes it un-necessary to instrument individual redux actions + * + * Any actions with a name takes the form `metabase/.../...` will be automatially captured + * + * Ignoring actions: + * Any actions we want to ignore can be bypassed by including a meta object with ignore: true + * { + * type: "...", + * meta: { + * analytics: { ignore: true } + * } + * } + * + * Customizing event names: + * If we don't want to use the action name metadata can be added to the action + * to customize the name + * + * { + * type: "...", + * meta: { + * analytics: { + * category: "foo", + * action: "bar", + * label: "baz", + * value: "qux" + * } + * } + *} + */ +export const trackEvent = ({ dispatch, getState }) => next => action => { + // look for the meta analytics object if it exists, this gets used to + // do customization of the event identifiers sent to GA + const analytics = action.meta && action.meta.analytics; + + if (analytics) { + if (!analytics.ignore) { + MetabaseAnalytics.trackEvent( + analytics.category, + analytics.action, + analytics.label, + analytics.value, + ); + } + } else if (METABASE_TRACKABLE_ACTION_REGEX.test(action.type)) { + // if there is no analytics metadata on the action, look to see if it's + // an action name we want to track based on the format of the aciton name + + // eslint doesn't like the _ to ignore the first bit + // eslint-disable-next-line + const [_, categoryName, actionName] = action.type.match( + METABASE_TRACKABLE_ACTION_REGEX, + ); + + MetabaseAnalytics.trackEvent(categoryName, actionName); + } + return next(action); +}; + export function getStore(reducers, history, intialState, enhancer = a => a) { const reducer = combineReducers({ ...reducers, @@ -37,6 +105,7 @@ export function getStore(reducers, history, intialState, enhancer = a => a) { const middleware = [ thunkWithDispatchAction, + trackEvent, promise, ...(DEBUG ? [logger] : []), ...(history ? [routerMiddleware(history)] : []), diff --git a/frontend/src/metabase/tutorial/PageFlag.css b/frontend/src/metabase/tutorial/PageFlag.css index 146dfe199ff3984fda3d647cd6bd3f6c64f1beb4..91cbded10f50e93243516bc4383d6478a941c8bf 100644 --- a/frontend/src/metabase/tutorial/PageFlag.css +++ b/frontend/src/metabase/tutorial/PageFlag.css @@ -32,6 +32,7 @@ } .PageFlag:after { + top: 0; left: -12px; border-top: 12px solid transparent; border-bottom: 12px solid transparent; diff --git a/frontend/src/metabase/tutorial/PageFlag.jsx b/frontend/src/metabase/tutorial/PageFlag.jsx index e70bbe48052b84f735e35e2d3d75887e1a4de936..2c8240804e6b81259947b8c9395cf7e0a62394c8 100644 --- a/frontend/src/metabase/tutorial/PageFlag.jsx +++ b/frontend/src/metabase/tutorial/PageFlag.jsx @@ -2,16 +2,39 @@ import React, { Component } from "react"; import "./PageFlag.css"; -import ReactCSSTransitionGroup from "react-addons-css-transition-group"; +import { CSSTransitionGroup } from "react-transition-group"; -import BodyComponent from "metabase/components/BodyComponent.jsx"; +import BodyComponent from "metabase/components/BodyComponent"; import cx from "classnames"; @BodyComponent export default class PageFlag extends Component { + componentWillMount() { + // sometimes the position of target changes, track it here + this._timer = setInterval(() => { + if (this.props.target) { + const p1 = this._position; + const p2 = this.props.target.getBoundingClientRect(); + if ( + !p1 || + p1.left !== p2.left || + p1.top !== p2.top || + p1.width !== p2.width || + p1.height !== p2.height + ) { + this.forceUpdate(); + } + } + }, 100); + } + + componentWillUnmount() { + clearInterval(this._timer); + } + renderPageFlag() { - let position = this.props.target.getBoundingClientRect(); + let position = (this._position = this.props.target.getBoundingClientRect()); let isLarge = !!this.props.text; let style = { position: "absolute", @@ -34,13 +57,13 @@ export default class PageFlag extends Component { render() { return ( - <ReactCSSTransitionGroup + <CSSTransitionGroup transitionName="PageFlag" transitionEnterTimeout={250} transitionLeaveTimeout={250} > {this.props.target ? [this.renderPageFlag()] : []} - </ReactCSSTransitionGroup> + </CSSTransitionGroup> ); } } diff --git a/frontend/src/metabase/tutorial/Portal.jsx b/frontend/src/metabase/tutorial/Portal.jsx index 9318f9127195941d089bfbd2df8ab964f8892870..1378b4a396e684c7587db648f177fdfceb5ca6a0 100644 --- a/frontend/src/metabase/tutorial/Portal.jsx +++ b/frontend/src/metabase/tutorial/Portal.jsx @@ -17,13 +17,36 @@ export default class Portal extends Component { }; componentWillMount() { + // sometimes the position of target changes, track it here + this._timer = setInterval(() => { + const p1 = this.state.position; + const p2 = + this.props.target && + this.props.target !== true && + this.props.target.getBoundingClientRect(); + if ( + p1 && + p2 && + (p1.left !== p2.left || + p1.top !== p2.top || + p1.width !== p2.width || + p1.height !== p2.height) + ) { + this.setState({ position: p2 }); + } + }, 100); this.componentWillReceiveProps(this.props); } + componentWillUnmount() { + clearInterval(this._timer); + } + componentWillReceiveProps(newProps) { if (newProps.target !== this.state.target) { const { target, padding } = newProps; let position; + // setting target={true} causes the portal to close and disappear if (target === true) { position = { top: this.state.position.top + this.state.position.height / 2, diff --git a/frontend/src/metabase/user/components/SetUserPassword.jsx b/frontend/src/metabase/user/components/SetUserPassword.jsx index 6369684a99300e0700d0a1e1477f4e92d1ad6e0b..e326aa672f6622cea05cd0e4b381db7c2d4143e0 100644 --- a/frontend/src/metabase/user/components/SetUserPassword.jsx +++ b/frontend/src/metabase/user/components/SetUserPassword.jsx @@ -34,7 +34,7 @@ export default class SetUserPassword extends Component { let isValid = true; // required: first_name, last_name, email - for (var fieldName in this.refs) { + for (let fieldName in this.refs) { let node = ReactDOM.findDOMNode(this.refs[fieldName]); if (node.required && MetabaseUtils.isEmpty(node.value)) isValid = false; } diff --git a/frontend/src/metabase/user/components/UpdateUserDetails.jsx b/frontend/src/metabase/user/components/UpdateUserDetails.jsx index ca8e1c870a328cb46e1f2b553e9d6b15dfd6bce6..6683743dff1bb4ec87ed3e54663e3461e02b11a1 100644 --- a/frontend/src/metabase/user/components/UpdateUserDetails.jsx +++ b/frontend/src/metabase/user/components/UpdateUserDetails.jsx @@ -33,7 +33,7 @@ export default class UpdateUserDetails extends Component { let isValid = true; // required: first_name, last_name, email - for (var fieldName in this.refs) { + for (let fieldName in this.refs) { let node = ReactDOM.findDOMNode(this.refs[fieldName]); if (node.required && MetabaseUtils.isEmpty(node.value)) isValid = false; } diff --git a/frontend/src/metabase/visualizations/components/ChartClickActions.jsx b/frontend/src/metabase/visualizations/components/ChartClickActions.jsx index 72781527387aebc99fb3738c09cb99cfb25ef14c..7f375754ed42dc0c3a9d067c962e1633d6b8d219 100644 --- a/frontend/src/metabase/visualizations/components/ChartClickActions.jsx +++ b/frontend/src/metabase/visualizations/components/ChartClickActions.jsx @@ -5,6 +5,7 @@ import cx from "classnames"; import Icon from "metabase/components/Icon"; import Popover from "metabase/components/Popover"; +import { Link } from "react-router"; import MetabaseAnalytics from "metabase/lib/analytics"; @@ -46,6 +47,9 @@ const SECTIONS = { distribution: { icon: "bar", }, + auto: { + icon: "bolt", + }, }; // give them indexes so we can sort the sections by the above ordering (JS objects are ordered) Object.values(SECTIONS).map((section, index) => { @@ -53,6 +57,9 @@ Object.values(SECTIONS).map((section, index) => { section.index = index; }); +const getGALabelForAction = action => + action ? `${action.section || ""}:${action.name || ""}` : null; + type Props = { clicked: ?ClickObject, clickActions: ?(ClickAction[]), @@ -80,6 +87,11 @@ export default class ChartClickActions extends Component { handleClickAction = (action: ClickAction) => { const { onChangeCardAndRun } = this.props; if (action.popover) { + MetabaseAnalytics.trackEvent( + "Actions", + "Open Click Action Popover", + getGALabelForAction(action), + ); this.setState({ popoverAction: action }); } else if (action.question) { const nextQuestion = action.question(); @@ -87,7 +99,7 @@ export default class ChartClickActions extends Component { MetabaseAnalytics.trackEvent( "Actions", "Executed Click Action", - `${action.section || ""}:${action.name || ""}`, + getGALabelForAction(action), ); onChangeCardAndRun({ nextCard: nextQuestion.card() }); } @@ -113,7 +125,7 @@ export default class ChartClickActions extends Component { MetabaseAnalytics.trackEvent( "Action", "Executed Click Action", - `${popoverAction.section || ""}:${popoverAction.name || ""}`, + getGALabelForAction(popoverAction), ); } onChangeCardAndRun({ nextCard }); @@ -122,6 +134,7 @@ export default class ChartClickActions extends Component { MetabaseAnalytics.trackEvent( "Action", "Dismissed Click Action Menu", + getGALabelForAction(popoverAction), ); this.close(); }} @@ -187,14 +200,43 @@ export const ChartClickAction = ({ action: any, isLastItem: any, handleClickAction: any, -}) => ( - <div - className={cx("text-brand-hover cursor-pointer", { - pr2: isLastItem, - pr4: !isLastItem, - })} - onClick={() => handleClickAction(action)} - > - {action.title} - </div> -); +}) => { + const className = cx( + "text-brand-hover cursor-pointer no-decoration", + isLastItem ? "pr2" : "pr4", + ); + // NOTE: Tom Robinson 4/16/2018: disabling <Link> for `question` click actions + // for now since on dashboards currently they need to go through + // navigateToNewCardFromDashboard to merge in parameters., + // Also need to sort out proper logic in QueryBuilder's componentWillReceiveProps + // if (action.question) { + // return ( + // <Link to={action.question().getUrl()} className={className}> + // {action.title} + // </Link> + // ); + // } else + if (action.url) { + return ( + <Link + to={action.url()} + className={className} + onClick={() => + MetabaseAnalytics.trackEvent( + "Actions", + "Executed Click Action", + getGALabelForAction(action), + ) + } + > + {action.title} + </Link> + ); + } else { + return ( + <div className={className} onClick={() => handleClickAction(action)}> + {action.title} + </div> + ); + } +}; diff --git a/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx b/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx index 568b7055bac691b610beb207dcbf9c8ca399cb38..666af838d769ce44ca6feb7996af8c793dd9f454 100644 --- a/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx +++ b/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx @@ -228,7 +228,7 @@ export default class ChoroplethMap extends Component { const groups = ss.ckmeans(domain, heatMapColors.length); - var colorScale = d3.scale + let colorScale = d3.scale .quantile() .domain(groups.map(cluster => cluster[0])) .range(heatMapColors); diff --git a/frontend/src/metabase/visualizations/components/FunnelNormal.jsx b/frontend/src/metabase/visualizations/components/FunnelNormal.jsx index 4e3efe93b30c059d472082bb55bff05000e9c988..23f8ffc4d03c780219f5d3b104eac877c69e3150 100644 --- a/frontend/src/metabase/visualizations/components/FunnelNormal.jsx +++ b/frontend/src/metabase/visualizations/components/FunnelNormal.jsx @@ -70,7 +70,7 @@ export default class Funnel extends Component { const formatPercent = percent => `${(100 * percent).toFixed(2)} %`; // Initial infos (required for step calculation) - var infos: StepInfo[] = [ + let infos: StepInfo[] = [ { value: rows[0][metricIndex], graph: { @@ -82,7 +82,7 @@ export default class Funnel extends Component { }, ]; - var remaining: number = rows[0][metricIndex]; + let remaining: number = rows[0][metricIndex]; rows.map((row, rowIndex) => { remaining -= infos[rowIndex].value - row[metricIndex]; diff --git a/frontend/src/metabase/visualizations/components/LeafletMap.css b/frontend/src/metabase/visualizations/components/LeafletMap.css new file mode 100644 index 0000000000000000000000000000000000000000..d51ab531b74f545cdabc6c7727ca1d019ad9ee31 --- /dev/null +++ b/frontend/src/metabase/visualizations/components/LeafletMap.css @@ -0,0 +1,4 @@ +/* hide leaflet-draw controls */ +.leaflet-draw.leaflet-control { + display: none; +} diff --git a/frontend/src/metabase/visualizations/components/LeafletMap.jsx b/frontend/src/metabase/visualizations/components/LeafletMap.jsx index 2f22994e8b5cbe7cf3cd87db1fbee0f19ab872f8..a9c69c47e3b77e5e0cf5bf74e842c4744515e14e 100644 --- a/frontend/src/metabase/visualizations/components/LeafletMap.jsx +++ b/frontend/src/metabase/visualizations/components/LeafletMap.jsx @@ -4,6 +4,8 @@ import ReactDOM from "react-dom"; import MetabaseSettings from "metabase/lib/settings"; import "leaflet/dist/leaflet.css"; +import "./LeafletMap.css"; + import L from "leaflet"; import "leaflet-draw"; diff --git a/frontend/src/metabase/visualizations/components/LegendVertical.jsx b/frontend/src/metabase/visualizations/components/LegendVertical.jsx index c3230164b429fe73021fa50eab5072527b50829e..054e6cd355f6036ad7bfcd53842eab6f5b0b0bca 100644 --- a/frontend/src/metabase/visualizations/components/LegendVertical.jsx +++ b/frontend/src/metabase/visualizations/components/LegendVertical.jsx @@ -30,7 +30,7 @@ export default class LegendVertical extends Component { this.setState({ overflowCount: 0, size }); } else if (this.state.overflowCount === 0) { let overflowCount = 0; - for (var i = 0; i < this.props.titles.length; i++) { + for (let i = 0; i < this.props.titles.length; i++) { let itemSize = ReactDOM.findDOMNode( this.refs["item" + i], ).getBoundingClientRect(); diff --git a/frontend/src/metabase/visualizations/components/TableInteractive.jsx b/frontend/src/metabase/visualizations/components/TableInteractive.jsx index 64fd902303af3236cb6d0ff13cb4c1aa4a8e32db..3bd56696b7d5ee392bde4cfd3a9fab2ed675e8b5 100644 --- a/frontend/src/metabase/visualizations/components/TableInteractive.jsx +++ b/frontend/src/metabase/visualizations/components/TableInteractive.jsx @@ -19,6 +19,8 @@ import _ from "underscore"; import cx from "classnames"; import ExplicitSize from "metabase/components/ExplicitSize.jsx"; + +// $FlowFixMe: had to ignore react-virtualized in flow, probably due to different version import { Grid, ScrollSync } from "react-virtualized"; import Draggable from "react-draggable"; @@ -29,6 +31,21 @@ const RESIZE_HANDLE_WIDTH = 5; import type { VisualizationProps } from "metabase/meta/types/Visualization"; +function pickRowsToMeasure(rows, columnIndex, count = 10) { + const rowIndexes = []; + // measure up to 10 non-nil cells + for ( + let rowIndex = 0; + rowIndex < rows.length && rowIndexes.length < count; + rowIndex++ + ) { + if (rows[rowIndex][columnIndex] != null) { + rowIndexes.push(rowIndex); + } + } + return rowIndexes; +} + type Props = VisualizationProps & { width: number, height: number, @@ -141,75 +158,61 @@ export default class TableInteractive extends Component { } _measure() { - const { data: { cols } } = this.props; - - let contentWidths = cols.map((col, index) => this._measureColumn(index)); - - let columnWidths: number[] = cols.map((col, index) => { - if (this.columnNeedsResize) { - if (this.columnNeedsResize[index] && !this.columnHasResized[index]) { - this.columnHasResized[index] = true; - return contentWidths[index] + 1; // + 1 to make sure it doen't wrap? - } else if (this.state.columnWidths[index]) { - return this.state.columnWidths[index]; - } else { - return 0; - } - } else { - return contentWidths[index] + 1; - } - }); - - delete this.columnNeedsResize; - - this.setState({ contentWidths, columnWidths }, this.recomputeGridSize); - } - - _measureColumn(columnIndex: number) { - const { data: { rows } } = this.props; - let width = MIN_COLUMN_WIDTH; - - // measure column header - width = Math.max( - width, - this._measureCell( - this.tableHeaderRenderer({ - columnIndex, - rowIndex: 0, - key: "", - style: {}, - }), - ), - ); - - // measure up to 10 non-nil cells - let remaining = 10; - for ( - let rowIndex = 0; - rowIndex < rows.length && remaining > 0; - rowIndex++ - ) { - if (rows[rowIndex][columnIndex] != null) { - const cellWidth = this._measureCell( - this.cellRenderer({ rowIndex, columnIndex, key: "", style: {} }), + const { data: { cols, rows } } = this.props; + + ReactDOM.render( + <div style={{ display: "flex" }}> + {cols.map((column, columnIndex) => ( + <div className="fake-column" key={"column-" + columnIndex}> + {this.tableHeaderRenderer({ + columnIndex, + rowIndex: 0, + key: "header", + style: {}, + })} + {pickRowsToMeasure(rows, columnIndex).map(rowIndex => + this.cellRenderer({ + rowIndex, + columnIndex, + key: "row-" + rowIndex, + style: {}, + }), + )} + </div> + ))} + </div>, + this._div, + () => { + const contentWidths = [].map.call( + this._div.getElementsByClassName("fake-column"), + columnElement => columnElement.offsetWidth, ); - width = Math.max(width, cellWidth); - remaining--; - } - } - - return width; - } - - _measureCell(cell: React.Element<any>) { - ReactDOM.unstable_renderSubtreeIntoContainer(this, cell, this._div); - - // 2px for border? - const width = this._div.clientWidth + 2; - - ReactDOM.unmountComponentAtNode(this._div); - return width; + const columnWidths: number[] = cols.map((col, index) => { + if (this.columnNeedsResize) { + if ( + this.columnNeedsResize[index] && + !this.columnHasResized[index] + ) { + this.columnHasResized[index] = true; + return contentWidths[index] + 1; // + 1 to make sure it doen't wrap? + } else if (this.state.columnWidths[index]) { + return this.state.columnWidths[index]; + } else { + return 0; + } + } else { + return contentWidths[index] + 1; + } + }); + + ReactDOM.unmountComponentAtNode(this._div); + + delete this.columnNeedsResize; + + this.setState({ contentWidths, columnWidths }, this.recomputeGridSize); + }, + ); } recomputeGridSize = () => { @@ -275,11 +278,12 @@ export default class TableInteractive extends Component { "justify-end": isColumnRightAligned(column), link: isClickable && isID(column), })} - onClick={ - isClickable && - (e => { - onVisualizationClick({ ...clicked, element: e.currentTarget }); - }) + onMouseUp={ + isClickable + ? e => { + onVisualizationClick({ ...clicked, element: e.currentTarget }); + } + : undefined } > <div className="cellData"> @@ -288,6 +292,7 @@ export default class TableInteractive extends Component { column: column, type: "cell", jsx: true, + rich: true, })} </div> </div> @@ -348,11 +353,13 @@ export default class TableInteractive extends Component { "justify-end": isRightAligned, }, )} - onClick={ - isClickable && - (e => { - onVisualizationClick({ ...clicked, element: e.currentTarget }); - }) + // use onMouseUp instead of onClick since we can stopPropation when resizing headers + onMouseUp={ + isClickable + ? e => { + onVisualizationClick({ ...clicked, element: e.currentTarget }); + } + : undefined } > <div className="cellData"> @@ -379,6 +386,8 @@ export default class TableInteractive extends Component { bounds={{ left: RESIZE_HANDLE_WIDTH }} position={{ x: this.getColumnWidth({ index: columnIndex }), y: 0 }} onStop={(e, { x }) => { + // prevent onVisualizationClick from being fired + e.stopPropagation(); this.onColumnResize(columnIndex, x); }} > diff --git a/frontend/src/metabase/visualizations/components/TableSimple.jsx b/frontend/src/metabase/visualizations/components/TableSimple.jsx index 397278eb2991465ff0966fea62c904b2f86ee3b0..2cc16e6fe42420b59b2c9414311c50621ca0306c 100644 --- a/frontend/src/metabase/visualizations/components/TableSimple.jsx +++ b/frontend/src/metabase/visualizations/components/TableSimple.jsx @@ -181,13 +181,14 @@ export default class TableSimple extends Component { "cursor-pointer text-brand-hover": isClickable, })} onClick={ - isClickable && - (e => { - onVisualizationClick({ - ...clicked, - element: e.currentTarget, - }); - }) + isClickable + ? e => { + onVisualizationClick({ + ...clicked, + element: e.currentTarget, + }); + } + : undefined } > {cell == null @@ -195,6 +196,7 @@ export default class TableSimple extends Component { : formatValue(cell, { column: cols[columnIndex], jsx: true, + rich: true, })} </span> </td> diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index 425ad081f9c84a890328dc8e1dd3300111f5a50d..b5254e7f108257a3cb1b89ca17e2c63a3d8cb57c 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -46,12 +46,13 @@ import type { HoverObject, ClickObject, Series, + RawSeries, OnChangeCardAndRun, } from "metabase/meta/types/Visualization"; import Metadata from "metabase-lib/lib/metadata/Metadata"; type Props = { - rawSeries: Series, + rawSeries: RawSeries, className: string, @@ -130,6 +131,7 @@ export default class Visualization extends Component { } static defaultProps = { + className: "full-height", showTitle: false, isDashboard: false, isEditing: false, @@ -162,6 +164,13 @@ export default class Visualization extends Component { } } + componentDidCatch(error, info) { + console.error("Error caught in <Visualization>", error, info); + this.setState({ + error: new Error("An error occurred displaying this visualization."), + }); + } + // $FlowFixMe getWarnings(props = this.props, state = this.state) { let warnings = state.warnings || []; @@ -473,7 +482,7 @@ export default class Visualization extends Component { </div> ) : ( <div> - {t`This is usually pretty fast, but seems to be taking awhile right now.`} + {t`This is usually pretty fast but seems to be taking awhile right now.`} </div> )} </div> diff --git a/frontend/src/metabase/visualizations/index.js b/frontend/src/metabase/visualizations/index.js index 492ae0a896654ce35e37d5b5b36f23146dd5cf4f..7a62db9110d723d5f1d908c4ac100bac9daf12be 100644 --- a/frontend/src/metabase/visualizations/index.js +++ b/frontend/src/metabase/visualizations/index.js @@ -70,7 +70,6 @@ export function getVisualizationTransformed(series: Series) { series = CardVisualization.transformSeries(series); } if (series !== lastSeries) { - // $FlowFixMe series = [...series]; // $FlowFixMe series._raw = lastSeries; diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js b/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js index ded75cfd38cb51acfe9fb55673c15ba06a9a9eb2..73a1a1d0c908d4e54a8a23be4c5c5baefdfef201 100644 --- a/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js +++ b/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js @@ -1,6 +1,14 @@ +/* @flow weak */ + import d3 from "d3"; import { clipPathReference } from "metabase/lib/dom"; +import { adjustYAxisTicksIfNeeded } from "./apply_axis"; + +const X_LABEL_MIN_SPACING = 2; // minimum space we want to leave between labels +const X_LABEL_ROTATE_90_THRESHOLD = 24; // tick width breakpoint for switching from 45° to 90° +const X_LABEL_HIDE_THRESHOLD = 12; // tick width breakpoint for hiding labels entirely +const X_LABEL_MAX_LABEL_HEIGHT_RATIO = 0.7; // percent rotated labels are allowed to take // +-------------------------------------------------------------------------------------------------------------------+ // | ON RENDER FUNCTIONS | @@ -33,11 +41,11 @@ const DOT_OVERLAP_COUNT_LIMIT = 8; const DOT_OVERLAP_RATIO = 0.1; const DOT_OVERLAP_DISTANCE = 8; -function onRenderEnableDots(chart, settings) { +function onRenderEnableDots(chart) { let enableDots; const dots = chart.svg().selectAll(".dc-tooltip .dot")[0]; - if (settings["line.marker_enabled"] != null) { - enableDots = !!settings["line.marker_enabled"]; + if (chart.settings["line.marker_enabled"] != null) { + enableDots = !!chart.settings["line.marker_enabled"]; } else if (dots.length > 500) { // more than 500 dots is almost certainly too dense, don't waste time computing the voronoi map enableDots = false; @@ -207,20 +215,20 @@ function onRenderCleanupGoal(chart, onGoalHover, isSplitAxis) { } } -function onRenderHideDisabledLabels(chart, settings) { - if (!settings["graph.x_axis.labels_enabled"]) { +function onRenderHideDisabledLabels(chart) { + if (!chart.settings["graph.x_axis.labels_enabled"]) { chart.selectAll(".x-axis-label").remove(); } - if (!settings["graph.y_axis.labels_enabled"]) { + if (!chart.settings["graph.y_axis.labels_enabled"]) { chart.selectAll(".y-axis-label").remove(); } } -function onRenderHideDisabledAxis(chart, settings) { - if (!settings["graph.x_axis.axis_enabled"]) { +function onRenderHideDisabledAxis(chart) { + if (!chart.settings["graph.x_axis.axis_enabled"]) { chart.selectAll(".axis.x").remove(); } - if (!settings["graph.y_axis.axis_enabled"]) { + if (!chart.settings["graph.y_axis.axis_enabled"]) { chart.selectAll(".axis.y, .axis.yr").remove(); } } @@ -252,20 +260,45 @@ function onRenderSetClassName(chart, isStacked) { chart.svg().classed("stacked", isStacked); } +function getXAxisRotation(chart) { + let match = String(chart.settings["graph.x_axis.axis_enabled"] || "").match( + /^rotate-(\d+)$/, + ); + if (match) { + return parseInt(match[1], 10); + } else { + return 0; + } +} + +function onRenderRotateAxis(chart) { + let degrees = getXAxisRotation(chart); + if (degrees !== 0) { + chart.selectAll("g.x text").attr("transform", function() { + const { width, height } = this.getBBox(); + return (// translate left half the width so the right edge is at the tick + `translate(-${width / 2},${-height / 2}) ` + + // rotate counter-clockwise around the right edge + `rotate(${-degrees}, ${width / 2}, ${height})` ); + }); + } +} + // the various steps that get called -function onRender(chart, settings, onGoalHover, isSplitAxis, isStacked) { +function onRender(chart, onGoalHover, isSplitAxis, isStacked) { onRenderRemoveClipPath(chart); onRenderMoveContentToTop(chart); onRenderSetDotStyle(chart); - onRenderEnableDots(chart, settings); + onRenderEnableDots(chart); onRenderVoronoiHover(chart); onRenderCleanupGoal(chart, onGoalHover, isSplitAxis); // do this before hiding x-axis - onRenderHideDisabledLabels(chart, settings); - onRenderHideDisabledAxis(chart, settings); + onRenderHideDisabledLabels(chart); + onRenderHideDisabledAxis(chart); onRenderHideBadAxis(chart); onRenderDisableClickFiltering(chart); onRenderFixStackZIndex(chart); onRenderSetClassName(chart, isStacked); + onRenderRotateAxis(chart); } // +-------------------------------------------------------------------------------------------------------------------+ @@ -273,9 +306,9 @@ function onRender(chart, settings, onGoalHover, isSplitAxis, isStacked) { // +-------------------------------------------------------------------------------------------------------------------+ // run these first so the rest of the margin computations take it into account -function beforeRenderHideDisabledAxesAndLabels(chart, settings) { - onRenderHideDisabledLabels(chart, settings); - onRenderHideDisabledAxis(chart, settings); +function beforeRenderHideDisabledAxesAndLabels(chart) { + onRenderHideDisabledLabels(chart); + onRenderHideDisabledAxis(chart); onRenderHideBadAxis(chart); } @@ -321,19 +354,118 @@ function computeMinHorizontalMargins(chart) { return min; } -function beforeRenderFixMargins(chart, settings) { +function computeXAxisLabelMaxSize(chart) { + let maxWidth = 0; + let maxHeight = 0; + chart.selectAll("g.x text").each(function() { + const { width, height } = this.getBBox(); + maxWidth = Math.max(maxWidth, width); + maxHeight = Math.max(maxHeight, height); + }); + return { width: maxWidth, height: maxHeight }; +} + +function rotateSize(size, rotation) { + return { + width: Math.sin(rotation * (Math.PI / 180)) * size.width, + height: Math.sin(rotation * (Math.PI / 180)) * size.height, + }; +} + +function computeXAxisMargin(chart) { + const rotation = getXAxisRotation(chart); + const maxSize = computeXAxisLabelMaxSize(chart); + const rotatedMaxSize = rotateSize(maxSize, rotation); + return Math.max(0, rotatedMaxSize.width - maxSize.height); // subtract the existing height +} + +export function checkXAxisLabelOverlap(chart, selector = "g.x text") { + const rects = []; + for (const elem of chart.selectAll(selector)[0]) { + rects.push(elem.getBoundingClientRect()); + if ( + rects.length > 1 && + rects[rects.length - 2].right + X_LABEL_MIN_SPACING > + rects[rects.length - 1].left + ) { + return true; + } + } + return false; +} + +function checkLabelHeight(chart, rotation) { + const rotatedMaxSize = rotateSize( + computeXAxisLabelMaxSize(chart), + rotation + 180, + ); + const xAxisSize = chart.selectAll("g.y")[0][0].getBBox(); + const ratio = Math.abs(rotatedMaxSize.width) / xAxisSize.height; + return ratio < X_LABEL_MAX_LABEL_HEIGHT_RATIO; +} + +function computeXAxisSpacing(chart) { + const rects = []; + let minXAxisSpacing = Infinity; + for (const elem of chart.selectAll("g.x text")[0]) { + rects.push(elem.getBoundingClientRect()); + if (rects.length > 1) { + const left = rects[rects.length - 2], + right = rects[rects.length - 1]; + const xAxisSpacing = + right.left + right.width / 2 - (left.left + left.width / 2); + minXAxisSpacing = Math.min(minXAxisSpacing, xAxisSpacing); + } + } + return minXAxisSpacing; +} + +function beforeRenderComputeXAxisLabelType(chart) { + // treat graph.x_axis.axis_enabled === true as "auto" + if (chart.settings["graph.x_axis.axis_enabled"] === true) { + const overlaps = checkXAxisLabelOverlap(chart); + if (overlaps) { + if (chart.isOrdinal()) { + const spacing = computeXAxisSpacing(chart); + if (spacing < X_LABEL_HIDE_THRESHOLD) { + chart.settings["graph.x_axis.axis_enabled"] = false; + } else if (spacing < X_LABEL_ROTATE_90_THRESHOLD) { + if (checkLabelHeight(chart, 90)) { + chart.settings["graph.x_axis.axis_enabled"] = "rotate-90"; + } else { + chart.settings["graph.x_axis.axis_enabled"] = false; + } + } else { + if (checkLabelHeight(chart, 45)) { + chart.settings["graph.x_axis.axis_enabled"] = "rotate-45"; + } else { + chart.settings["graph.x_axis.axis_enabled"] = false; + } + } + } else { + chart.settings["graph.x_axis.axis_enabled"] = false; + } + } + } +} + +function beforeRenderFixMargins(chart) { // run before adjusting margins const mins = computeMinHorizontalMargins(chart); + const xAxisMargin = computeXAxisMargin(chart); + + // re-adjust Y axis ticks to account for xAxisMargin due to rotated labels + adjustYAxisTicksIfNeeded(chart.yAxis(), chart.height() - xAxisMargin); + adjustYAxisTicksIfNeeded(chart.rightYAxis(), chart.height() - xAxisMargin); // adjust the margins to fit the X and Y axis tick and label sizes, if enabled adjustMargin( chart, "bottom", "height", - X_AXIS_PADDING, + X_AXIS_PADDING + xAxisMargin, ".axis.x", ".x-axis-label", - settings["graph.x_axis.labels_enabled"], ); adjustMargin( chart, @@ -342,7 +474,6 @@ function beforeRenderFixMargins(chart, settings) { Y_AXIS_PADDING, ".axis.y", ".y-axis-label.y-label", - settings["graph.y_axis.labels_enabled"], ); adjustMargin( chart, @@ -351,7 +482,6 @@ function beforeRenderFixMargins(chart, settings) { Y_AXIS_PADDING, ".axis.yr", ".y-axis-label.yr-label", - settings["graph.y_axis.labels_enabled"], ); // set margins to the max of the various mins @@ -370,9 +500,10 @@ function beforeRenderFixMargins(chart, settings) { } // collection of function calls that get made *before* we tell the Chart to render -function beforeRender(chart, settings) { - beforeRenderHideDisabledAxesAndLabels(chart, settings); - beforeRenderFixMargins(chart, settings); +function beforeRender(chart) { + beforeRenderComputeXAxisLabelType(chart); + beforeRenderHideDisabledAxesAndLabels(chart); + beforeRenderFixMargins(chart); } // +-------------------------------------------------------------------------------------------------------------------+ @@ -382,14 +513,13 @@ function beforeRender(chart, settings) { /// once chart has rendered and we can access the SVG, do customizations to axis labels / etc that you can't do through dc.js export default function lineAndBarOnRender( chart, - settings, onGoalHover, isSplitAxis, isStacked, ) { - beforeRender(chart, settings); + beforeRender(chart); chart.on("renderlet.on-render", () => - onRender(chart, settings, onGoalHover, isSplitAxis, isStacked), + onRender(chart, onGoalHover, isSplitAxis, isStacked), ); chart.render(); } diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js index e3ce80790d7afacf7e54a35cbeecbadb6e0dc735..00ded6ef4cc2ff4518f5d5a14da4243e349023e3 100644 --- a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js +++ b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js @@ -502,31 +502,19 @@ function addGoalChartAndGetOnGoalHover( }; } -function applyXAxisSettings({ settings, series }, xAxisProps, parent) { - if (isTimeseries(settings)) - applyChartTimeseriesXAxis(parent, settings, series, xAxisProps); - else if (isQuantitative(settings)) - applyChartQuantitativeXAxis(parent, settings, series, xAxisProps); - else applyChartOrdinalXAxis(parent, settings, series, xAxisProps); +function applyXAxisSettings(parent, series, xAxisProps) { + if (isTimeseries(parent.settings)) + applyChartTimeseriesXAxis(parent, series, xAxisProps); + else if (isQuantitative(parent.settings)) + applyChartQuantitativeXAxis(parent, series, xAxisProps); + else applyChartOrdinalXAxis(parent, series, xAxisProps); } -function applyYAxisSettings({ settings }, { yLeftSplit, yRightSplit }, parent) { +function applyYAxisSettings(parent, { yLeftSplit, yRightSplit }) { if (yLeftSplit && yLeftSplit.series.length > 0) - applyChartYAxis( - parent, - settings, - yLeftSplit.series, - yLeftSplit.extent, - "left", - ); + applyChartYAxis(parent, yLeftSplit.series, yLeftSplit.extent, "left"); if (yRightSplit && yRightSplit.series.length > 0) - applyChartYAxis( - parent, - settings, - yRightSplit.series, - yRightSplit.extent, - "right", - ); + applyChartYAxis(parent, yRightSplit.series, yRightSplit.extent, "right"); } // TODO - better name @@ -602,15 +590,17 @@ export default function lineAreaBar(element: Element, props: LineAreaBarProps) { checkSeriesIsValid(props); // force histogram to be ordinal axis with zero-filled missing points + settings["graph.x_axis._scale_original"] = settings["graph.x_axis.scale"]; if (isHistogram(settings)) { settings["line.missing"] = "zero"; settings["graph.x_axis.scale"] = "ordinal"; } - const datas = getDatas(props, warn); - const xAxisProps = getXAxisProps(props, datas); + let datas = getDatas(props, warn); + let xAxisProps = getXAxisProps(props, datas); - fillMissingValuesInDatas(props, xAxisProps, datas); + datas = fillMissingValuesInDatas(props, xAxisProps, datas); + xAxisProps = getXAxisProps(props, datas); if (isScalarSeries) xAxisProps.xValues = datas.map(data => data[0][0]); // TODO - what is this for? @@ -628,6 +618,8 @@ export default function lineAreaBar(element: Element, props: LineAreaBarProps) { const parent = dc.compositeChart(element); initChart(parent, element); + parent.settings = settings; + const brushChangeFunctions = makeBrushChangeFunctions(props); const charts = getCharts( @@ -648,18 +640,21 @@ export default function lineAreaBar(element: Element, props: LineAreaBarProps) { parent.compose(charts); - if (groups.length > 1 && !props.isScalarSeries) doGroupedBarStuff(parent); - else if (isHistogramBar(props)) doHistogramBarStuff(parent); + if (groups.length > 1 && !props.isScalarSeries) { + doGroupedBarStuff(parent); + } else if (isHistogramBar(props)) { + doHistogramBarStuff(parent); + } // HACK: compositeChart + ordinal X axis shenanigans. See https://github.com/dc-js/dc.js/issues/678 and https://github.com/dc-js/dc.js/issues/662 parent._rangeBandPadding(chartType === "bar" ? BAR_PADDING_RATIO : 1); // - applyXAxisSettings(props, xAxisProps, parent); + applyXAxisSettings(parent, props.series, xAxisProps); // override tick format for bars. ticks are aligned with beginning of bar, so just show the start value if (isHistogramBar(props)) parent.xAxis().tickFormat(d => formatNumber(d)); - applyYAxisSettings(props, yAxisProps, parent); + applyYAxisSettings(parent, yAxisProps); setupTooltips(props, datas, parent, brushChangeFunctions); @@ -668,14 +663,13 @@ export default function lineAreaBar(element: Element, props: LineAreaBarProps) { // apply any on-rendering functions (this code lives in `LineAreaBarPostRenderer`) lineAndBarOnRender( parent, - settings, onGoalHover, yAxisProps.isSplit, - isStacked(settings, datas), + isStacked(parent.settings, datas), ); // only ordinal axis can display "null" values - if (isOrdinal(settings)) delete warnings[NULL_DIMENSION_WARNING]; + if (isOrdinal(parent.settings)) delete warnings[NULL_DIMENSION_WARNING]; if (onRender) onRender({ diff --git a/frontend/src/metabase/visualizations/lib/RowRenderer.js b/frontend/src/metabase/visualizations/lib/RowRenderer.js index 716f8c5cf42e7874d21059c9347cd65e976d88a8..a9f695aa6b23e9e98b43e00a698cb714965232f7 100644 --- a/frontend/src/metabase/visualizations/lib/RowRenderer.js +++ b/frontend/src/metabase/visualizations/lib/RowRenderer.js @@ -8,6 +8,7 @@ import { formatValue } from "metabase/lib/formatting"; import { initChart, forceSortedGroup, makeIndexMap } from "./renderer_utils"; import { getFriendlyName } from "./utils"; +import { checkXAxisLabelOverlap } from "./LineAreaBarPostRender"; export default function rowRenderer( element, @@ -168,4 +169,9 @@ export default function rowRenderer( chart.margins().left += maxTextWidth; chart.render(); } + + // hide overlapping x-axis labels + if (checkXAxisLabelOverlap(chart, ".axis text")) { + chart.selectAll(".axis").remove(); + } } diff --git a/frontend/src/metabase/visualizations/lib/apply_axis.js b/frontend/src/metabase/visualizations/lib/apply_axis.js index 39678c8ff2e6455614b047267f070a80edda4b45..572f3ea85cff906f41534337a73813bbe2131977 100644 --- a/frontend/src/metabase/visualizations/lib/apply_axis.js +++ b/frontend/src/metabase/visualizations/lib/apply_axis.js @@ -11,6 +11,7 @@ import { parseTimestamp } from "metabase/lib/time"; import { computeTimeseriesTicksInterval } from "./timeseries"; import { getFriendlyName } from "./utils"; +import { isHistogram } from "./renderer_utils"; // label offset (doesn't increase padding) const X_LABEL_PADDING = 10; @@ -25,7 +26,7 @@ function getNumTicks(axis) { /// adjust the number of ticks to display on the y Axis based on its height in pixels. Since y axis ticks /// are all the same height there's no need to do fancy measurement like we do below for the x axis. -function adjustYAxisTicksIfNeeded(axis, axisHeightPixels) { +export function adjustYAxisTicksIfNeeded(axis, axisHeightPixels) { const MIN_PIXELS_PER_TICK = 32; const numTicks = getNumTicks(axis); @@ -77,7 +78,6 @@ function adjustXAxisTicksIfNeeded(axis, chartWidthPixels, xValues) { export function applyChartTimeseriesXAxis( chart, - settings, series, { xValues, xDomain, xInterval }, ) { @@ -98,14 +98,17 @@ export function applyChartTimeseriesXAxis( let dataInterval = xInterval; let tickInterval = dataInterval; - if (settings["graph.x_axis.labels_enabled"]) { + if (chart.settings["graph.x_axis.labels_enabled"]) { chart.xAxisLabel( - settings["graph.x_axis.title_text"] || getFriendlyName(dimensionColumn), + chart.settings["graph.x_axis.title_text"] || + getFriendlyName(dimensionColumn), X_LABEL_PADDING, ); } - if (settings["graph.x_axis.axis_enabled"]) { - chart.renderVerticalGridLines(settings["graph.x_axis.gridLine_enabled"]); + if (chart.settings["graph.x_axis.axis_enabled"]) { + chart.renderVerticalGridLines( + chart.settings["graph.x_axis.gridLine_enabled"], + ); if (dimensionColumn.unit == null) { dimensionColumn = { ...dimensionColumn, unit: dataInterval.interval }; @@ -138,6 +141,7 @@ export function applyChartTimeseriesXAxis( return formatValue(timestampFixed, { column: dimensionColumn, type: "axis", + compact: chart.settings["graph.x_axis.axis_enabled"] === "compact", }); }); @@ -175,7 +179,6 @@ export function applyChartTimeseriesXAxis( export function applyChartQuantitativeXAxis( chart, - settings, series, { xValues, xDomain, xInterval }, ) { @@ -187,26 +190,35 @@ export function applyChartQuantitativeXAxis( ); const dimensionColumn = firstSeries.data.cols[0]; - if (settings["graph.x_axis.labels_enabled"]) { + if (chart.settings["graph.x_axis.labels_enabled"]) { chart.xAxisLabel( - settings["graph.x_axis.title_text"] || getFriendlyName(dimensionColumn), + chart.settings["graph.x_axis.title_text"] || + getFriendlyName(dimensionColumn), X_LABEL_PADDING, ); } - if (settings["graph.x_axis.axis_enabled"]) { - chart.renderVerticalGridLines(settings["graph.x_axis.gridLine_enabled"]); + if (chart.settings["graph.x_axis.axis_enabled"]) { + chart.renderVerticalGridLines( + chart.settings["graph.x_axis.gridLine_enabled"], + ); adjustXAxisTicksIfNeeded(chart.xAxis(), chart.width(), xValues); - chart.xAxis().tickFormat(d => formatValue(d, { column: dimensionColumn })); + chart.xAxis().tickFormat(d => + formatValue(d, { + column: dimensionColumn, + type: "axis", + compact: chart.settings["graph.x_axis.axis_enabled"] === "compact", + }), + ); } else { chart.xAxis().ticks(0); chart.xAxis().tickFormat(""); } let scale; - if (settings["graph.x_axis.scale"] === "pow") { + if (chart.settings["graph.x_axis.scale"] === "pow") { scale = d3.scale.pow().exponent(0.5); - } else if (settings["graph.x_axis.scale"] === "log") { + } else if (chart.settings["graph.x_axis.scale"] === "log") { scale = d3.scale.log().base(Math.E); if ( !( @@ -226,7 +238,7 @@ export function applyChartQuantitativeXAxis( chart.x(scale.domain(xDomain)).xUnits(dc.units.fp.precision(xInterval)); } -export function applyChartOrdinalXAxis(chart, settings, series, { xValues }) { +export function applyChartOrdinalXAxis(chart, series, { xValues }) { // find the first nonempty single series // $FlowFixMe const firstSeries: SingleSeries = _.find( @@ -236,49 +248,55 @@ export function applyChartOrdinalXAxis(chart, settings, series, { xValues }) { const dimensionColumn = firstSeries.data.cols[0]; - if (settings["graph.x_axis.labels_enabled"]) { + if (chart.settings["graph.x_axis.labels_enabled"]) { chart.xAxisLabel( - settings["graph.x_axis.title_text"] || getFriendlyName(dimensionColumn), + chart.settings["graph.x_axis.title_text"] || + getFriendlyName(dimensionColumn), X_LABEL_PADDING, ); } - if (settings["graph.x_axis.axis_enabled"]) { - chart.renderVerticalGridLines(settings["graph.x_axis.gridLine_enabled"]); + if (chart.settings["graph.x_axis.axis_enabled"]) { + chart.renderVerticalGridLines( + chart.settings["graph.x_axis.gridLine_enabled"], + ); chart.xAxis().ticks(xValues.length); adjustXAxisTicksIfNeeded(chart.xAxis(), chart.width(), xValues); - // unfortunately with ordinal axis you can't rely on xAxis.ticks(num) to control the display of labels - // so instead if we want to display fewer ticks than our full set we need to calculate visibleTicks() - const numTicks = getNumTicks(chart.xAxis()); - if (numTicks < xValues.length) { - let keyInterval = Math.round(xValues.length / numTicks); - let visibleKeys = xValues.filter((v, i) => i % keyInterval === 0); - chart.xAxis().tickValues(visibleKeys); - } - chart.xAxis().tickFormat(d => formatValue(d, { column: dimensionColumn })); + chart.xAxis().tickFormat(d => + formatValue(d, { + column: dimensionColumn, + type: "axis", + compact: chart.settings["graph.x_axis.labels_enabled"] === "compact", + }), + ); } else { chart.xAxis().ticks(0); chart.xAxis().tickFormat(""); } + if (isHistogram(chart.settings)) { + // reduces x axis padding. see https://stackoverflow.com/a/44320663/113 + chart._outerRangeBandPadding(0); + } + chart.x(d3.scale.ordinal().domain(xValues)).xUnits(dc.units.ordinal); } -export function applyChartYAxis(chart, settings, series, yExtent, axisName) { +export function applyChartYAxis(chart, series, yExtent, axisName) { let axis; if (axisName !== "right") { axis = { scale: (...args) => chart.y(...args), axis: (...args) => chart.yAxis(...args), label: (...args) => chart.yAxisLabel(...args), - setting: name => settings["graph.y_axis." + name], + setting: name => chart.settings["graph.y_axis." + name], }; } else { axis = { scale: (...args) => chart.rightY(...args), axis: (...args) => chart.rightYAxis(...args), label: (...args) => chart.rightYAxisLabel(...args), - setting: name => settings["graph.y_axis." + name], // TODO: right axis settings + setting: name => chart.settings["graph.y_axis." + name], // TODO: right axis settings }; } @@ -299,7 +317,7 @@ export function applyChartYAxis(chart, settings, series, yExtent, axisName) { // special case for normalized stacked charts // for normalized stacked charts the y-axis is a percentage number. In Javascript, 0.07 * 100.0 = 7.000000000000001 (try it) so we // round that number to get something nice like "7". Then we append "%" to get a nice tick like "7%" - if (settings["stackable.stack_type"] === "normalized") { + if (chart.settings["stackable.stack_type"] === "normalized") { axis.axis().tickFormat(value => Math.round(value * 100) + "%"); } chart.renderHorizontalGridLines(true); diff --git a/frontend/src/metabase/visualizations/lib/fill_data.js b/frontend/src/metabase/visualizations/lib/fill_data.js index b95d52a5007ee24dc7b91819b9ccadf763310928..a061eb0e714edda44fefaef9c4b01c8844b23607 100644 --- a/frontend/src/metabase/visualizations/lib/fill_data.js +++ b/frontend/src/metabase/visualizations/lib/fill_data.js @@ -35,7 +35,7 @@ function fillMissingValues(datas, xValues, fillValue, getKey = v => v) { } }); if (map.size > 0) { - console.warn(t`"xValues missing!`, map, newRows); + console.warn(t`xValues missing!`, map, newRows); } return newRows; }); @@ -99,4 +99,5 @@ export default function fillMissingValuesInDatas( datas = fillMissingValues(datas, xValues, fillValue); } } + return datas; } diff --git a/frontend/src/metabase/visualizations/lib/graph/addons.js b/frontend/src/metabase/visualizations/lib/graph/addons.js index 27a9c7f84e486ca4d30b46c634afbe8b959172a1..4b9e300c5385158dbb9e9d7c7f735b1205e9b43b 100644 --- a/frontend/src/metabase/visualizations/lib/graph/addons.js +++ b/frontend/src/metabase/visualizations/lib/graph/addons.js @@ -5,8 +5,8 @@ import moment from "moment"; export const lineAddons = _chart => { _chart.fadeDeselectedArea = function() { - var dots = _chart.chartBodyG().selectAll(".dot"); - var extent = _chart.brush().extent(); + let dots = _chart.chartBodyG().selectAll(".dot"); + let extent = _chart.brush().extent(); if (_chart.isOrdinal()) { if (_chart.hasFilter()) { @@ -22,8 +22,8 @@ export const lineAddons = _chart => { } } else { if (!_chart.brushIsEmpty(extent)) { - var start = extent[0]; - var end = extent[1]; + let start = extent[0]; + let end = extent[1]; const isSelected = d => { if (moment.isDate(start)) { return !(moment(d.x).isBefore(start) || moment(d.x).isAfter(end)); diff --git a/frontend/src/metabase/visualizations/lib/numeric.js b/frontend/src/metabase/visualizations/lib/numeric.js index f2b7047b0c21f31e34cbb2e523e3cddd9050367a..2644882af2d2ffe075d504608cb6f9ff947c14bc 100644 --- a/frontend/src/metabase/visualizations/lib/numeric.js +++ b/frontend/src/metabase/visualizations/lib/numeric.js @@ -13,7 +13,7 @@ export function precision(a) { if (!a) { return 0; } - var e = 1; + let e = 1; while (Math.round(a / e) !== a / e) { e /= 10; } @@ -25,7 +25,7 @@ export function precision(a) { export function decimalCount(a) { if (!isFinite(a)) return 0; - var e = 1, + let e = 1, p = 0; while (Math.round(a * e) / e !== a) { e *= 10; diff --git a/frontend/src/metabase/visualizations/lib/renderer_utils.js b/frontend/src/metabase/visualizations/lib/renderer_utils.js index ffa77b40ad51dfb42fe19b2d08b84c0767766b34..0901c61f60ea6a6d896ba8ea0974bd23a603096d 100644 --- a/frontend/src/metabase/visualizations/lib/renderer_utils.js +++ b/frontend/src/metabase/visualizations/lib/renderer_utils.js @@ -124,6 +124,7 @@ export const isTimeseries = settings => export const isQuantitative = settings => ["linear", "log", "pow"].indexOf(settings["graph.x_axis.scale"]) >= 0; export const isHistogram = settings => + settings["graph.x_axis._scale_original"] === "histogram" || settings["graph.x_axis.scale"] === "histogram"; export const isOrdinal = settings => !isTimeseries(settings) && !isHistogram(settings); diff --git a/frontend/src/metabase/visualizations/lib/settings.js b/frontend/src/metabase/visualizations/lib/settings.js index 145b8ff75fae30d3b892165cb934cb8886fd4c02..2669174347dce653b836e969374189410172669c 100644 --- a/frontend/src/metabase/visualizations/lib/settings.js +++ b/frontend/src/metabase/visualizations/lib/settings.js @@ -99,17 +99,17 @@ function getDefaultLineAreaBarColumns([{ data: { cols, rows } }]) { } } -export function getDefaultDimensionAndMetric([{ data: { cols, rows } }]) { - const type = getChartTypeFromData(cols, rows, false); +export function getDefaultDimensionAndMetric([{ data }]) { + const type = data && getChartTypeFromData(data.cols, data.rows, false); if (type === DIMENSION_METRIC) { return { - dimension: cols[0].name, - metric: cols[1].name, + dimension: data.cols[0].name, + metric: data.cols[1].name, }; } else if (type === DIMENSION_DIMENSION_METRIC) { return { dimension: null, - metric: cols[2].name, + metric: data.cols[2].name, }; } else { return { @@ -210,7 +210,7 @@ function getSetting(settingDefs, id, vizSettings, series) { return (vizSettings[id] = settingDef.default); } } catch (e) { - console.error("Error getting setting", id, e); + console.warn("Error getting setting", id, e); } return (vizSettings[id] = undefined); } @@ -237,14 +237,8 @@ export function getPersistableDefaultSettings(series) { for (let id in settingsDefs) { const settingDef = settingsDefs[id]; - const seriesForSettingsDef = - settingDef.useRawSeries && series._raw ? series._raw : series; - if (settingDef.persistDefault) { - persistableDefaultSettings[id] = settingDef.getDefault( - seriesForSettingsDef, - completeSettings, - ); + persistableDefaultSettings[id] = completeSettings[id]; } } diff --git a/frontend/src/metabase/visualizations/lib/settings/graph.js b/frontend/src/metabase/visualizations/lib/settings/graph.js index 3e76166b5d836647d8700fa2dd7a3cb4b5ade143..799f1a2f78a740423e6b1ca51004f2c950d04221 100644 --- a/frontend/src/metabase/visualizations/lib/settings/graph.js +++ b/frontend/src/metabase/visualizations/lib/settings/graph.js @@ -264,7 +264,8 @@ export const GRAPH_AXIS_SETTINGS = { }, "graph.x_axis._is_histogram": { getDefault: ([{ data: { cols } }], vizSettings) => - cols[0].binning_info != null, + // matches binned numeric columns, and date extracts like day-of-week, etc + cols[0].binning_info != null || /^\w+-of-\w+$/.test(cols[0].unit), }, "graph.x_axis.scale": { section: "Axes", @@ -315,13 +316,28 @@ export const GRAPH_AXIS_SETTINGS = { "graph.x_axis.axis_enabled": { section: "Axes", title: t`Show x-axis line and marks`, - widget: "toggle", + widget: "select", + props: { + options: [ + { name: t`Hide`, value: false }, + { name: t`Show`, value: true }, + { name: t`Compact`, value: "compact" }, + { name: t`Rotate 45°`, value: "rotate-45" }, + { name: t`Rotate 90°`, value: "rotate-90" }, + ], + }, default: true, }, "graph.y_axis.axis_enabled": { section: "Axes", title: t`Show y-axis line and marks`, - widget: "toggle", + widget: "select", + props: { + options: [ + { name: t`Hide`, value: false }, + { name: t`Show`, value: true }, + ], + }, default: true, }, "graph.y_axis.auto_range": { diff --git a/frontend/src/metabase/visualizations/visualizations/Map.jsx b/frontend/src/metabase/visualizations/visualizations/Map.jsx index 3b0099cab25a4b1c7ce941216513a7f92668bf8c..054240146c320be91f4d4158f4a7bab9d2a3bfba 100644 --- a/frontend/src/metabase/visualizations/visualizations/Map.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Map.jsx @@ -25,9 +25,7 @@ import { isSameSeries } from "metabase/visualizations/lib/utils"; import _ from "underscore"; -// NOTE Atte Keinänen 8/2/17: Heat/grid maps disabled in the first merged version of binning -// const PIN_MAP_TYPES = new Set(["pin", "heat", "grid"]); -const PIN_MAP_TYPES = new Set(["pin"]); +const PIN_MAP_TYPES = new Set(["pin", "heat", "grid"]); export default class Map extends Component { static uiName = t`Map`; @@ -50,9 +48,9 @@ export default class Map extends Component { options: [ { name: t`Region map`, value: "region" }, { name: t`Pin map`, value: "pin" }, - // NOTE Atte Keinänen 8/2/17: Heat/grid maps disabled in the first merged version of binning + // NOTE tlrobinson 4/13/18: Heat maps disabled until we can compute leaflet-heat options better // { name: "Heat map", value: "heat" }, - // { name: "Grid map", value: "grid" } + { name: "Grid map", value: "grid" }, ], }, getDefault: ([{ card, data: { cols } }], settings) => { @@ -63,19 +61,26 @@ export default class Map extends Component { case "pin_map": return "pin"; default: - // NOTE Atte Keinänen 8/2/17: Heat/grid maps disabled in the first merged version of binning if (hasLatitudeAndLongitudeColumns(cols)) { - // const latitudeColumn = _.findWhere(cols, { name: settings["map.latitude_column"] }); - // const longitudeColumn = _.findWhere(cols, { name: settings["map.longitude_column"] }); - // if (latitudeColumn && longitudeColumn && latitudeColumn.binning_info && longitudeColumn.binning_info) { - // // lat/lon columns are binned, use grid by default - // return "grid"; - // } else if (settings["map.metric_column"]) { - // // - // return "heat"; - // } else { - return "pin"; - // } + const latitudeColumn = _.findWhere(cols, { + name: settings["map.latitude_column"], + }); + const longitudeColumn = _.findWhere(cols, { + name: settings["map.longitude_column"], + }); + if ( + latitudeColumn && + longitudeColumn && + latitudeColumn.binning_info && + longitudeColumn.binning_info + ) { + return "grid"; + // NOTE tlrobinson 4/13/18: Heat maps disabled until we can compute leaflet-heat options better + // } else if (settings["map.metric_column"]) { + // return "heat"; + } else { + return "pin"; + } } else { return "region"; } @@ -95,9 +100,9 @@ export default class Map extends Component { options: [ { name: t`Tiles`, value: "tiles" }, { name: t`Markers`, value: "markers" }, - // NOTE Atte Keinänen 8/2/17: Heat/grid maps disabled in the first merged version of binning + // NOTE tlrobinson 4/13/18: Heat maps disabled until we can compute leaflet-heat options better // { name: "Heat", value: "heat" }, - // { name: "Grid", value: "grid" } + { name: "Grid", value: "grid" }, ], }, getDefault: (series, vizSettings) => diff --git a/frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx b/frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx index 4abaeff15294bcca60321eb2e86461ccb15066a1..d38f38e1f727641342d3d1d028908741e4c42db1 100644 --- a/frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx +++ b/frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx @@ -103,7 +103,11 @@ export class ObjectDetail extends Component { let formattedJson = JSON.stringify(value, null, 2); cellValue = <pre className="ObjectJSON">{formattedJson}</pre>; } else { - cellValue = formatValue(value, { column: column, jsx: true }); + cellValue = formatValue(value, { + column: column, + jsx: true, + rich: true, + }); if (typeof cellValue === "string") { cellValue = <ExpandableString str={cellValue} length={140} />; } diff --git a/frontend/src/metabase/visualizations/visualizations/PieChart.jsx b/frontend/src/metabase/visualizations/visualizations/PieChart.jsx index 27c21b882adc8466cd65f8f23a69696cf955a413..5561ed83238a06dfdb49b976380209feb93bbb8b 100644 --- a/frontend/src/metabase/visualizations/visualizations/PieChart.jsx +++ b/frontend/src/metabase/visualizations/visualizations/PieChart.jsx @@ -27,7 +27,7 @@ const OUTER_RADIUS = 50; // within 100px canvas const INNER_RADIUS_RATIO = 3 / 5; const PAD_ANGLE = Math.PI / 180 * 1; // 1 degree in radians -const SLICE_THRESHOLD = 1 / 360; // 1 degree in percentage +const SLICE_THRESHOLD = 0.025; // approx 1 degree in percentage const OTHER_SLICE_MIN_PERCENTAGE = 0.003; const PERCENT_REGEX = /percent/i; @@ -82,6 +82,7 @@ export default class PieChart extends Component { section: "Display", title: t`Minimum slice percentage`, widget: "number", + default: SLICE_THRESHOLD * 100, }, }; @@ -161,7 +162,7 @@ export default class PieChart extends Component { key: "Other", value: otherTotal, percentage: otherTotal / total, - color: "gray", + color: colors.normal.grey1, }; slices.push(otherSlice); } @@ -170,6 +171,7 @@ export default class PieChart extends Component { } // increase "other" slice so it's barely visible + // $FlowFixMe if (otherSlice && otherSlice.percentage < OTHER_SLICE_MIN_PERCENTAGE) { otherSlice.value = total * OTHER_SLICE_MIN_PERCENTAGE; } @@ -182,6 +184,16 @@ export default class PieChart extends Component { ]); let legendColors = slices.map(slice => slice.color); + // no non-zero slices + if (slices.length === 0) { + otherSlice = { + value: 1, + color: colors.normal.grey1, + noHover: true, + }; + slices.push(otherSlice); + } + const pie = d3.layout .pie() .sort(null) @@ -192,35 +204,45 @@ export default class PieChart extends Component { .outerRadius(OUTER_RADIUS) .innerRadius(OUTER_RADIUS * INNER_RADIUS_RATIO); - const hoverForIndex = (index, event) => ({ - index, - event: event && event.nativeEvent, - data: - slices[index] === otherSlice - ? others.map(o => ({ - key: formatDimension(o.key, false), - value: formatMetric(o.value, false), - })) - : [ - { - key: getFriendlyName(cols[dimensionIndex]), - value: formatDimension(slices[index].key), - }, - { - key: getFriendlyName(cols[metricIndex]), - value: formatMetric(slices[index].value), - }, - ].concat( - showPercentInTooltip - ? [ - { - key: "Percentage", - value: formatPercent(slices[index].percentage), - }, - ] - : [], - ), - }); + function hoverForIndex(index, event) { + const slice = slices[index]; + if (!slice || slice.noHover) { + return null; + } else if (slice === otherSlice) { + return { + index, + event: event && event.nativeEvent, + data: others.map(o => ({ + key: formatDimension(o.key, false), + value: formatMetric(o.value, false), + })), + }; + } else { + return { + index, + event: event && event.nativeEvent, + data: [ + { + key: getFriendlyName(cols[dimensionIndex]), + value: formatDimension(slice.key), + }, + { + key: getFriendlyName(cols[metricIndex]), + value: formatMetric(slice.value), + }, + ].concat( + showPercentInTooltip && slice.percentage != null + ? [ + { + key: "Percentage", + value: formatPercent(slice.percentage), + }, + ] + : [], + ), + }; + } + } let value, title; if ( diff --git a/frontend/src/metabase/visualizations/visualizations/Progress.jsx b/frontend/src/metabase/visualizations/visualizations/Progress.jsx index ee312a55d51a06f9009e38db1c4229ffa5e37cb7..ee0a439cd41446cb30b8609c3180234c293c3656 100644 --- a/frontend/src/metabase/visualizations/visualizations/Progress.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Progress.jsx @@ -110,7 +110,8 @@ export default class Progress extends Component { onVisualizationClick, visualizationIsClickable, } = this.props; - const value: number = rows[0][0]; + const value: number = + rows[0] && typeof rows[0][0] === "number" ? rows[0][0] : 0; const goal = settings["progress.goal"] || 0; const mainColor = settings["progress.color"]; diff --git a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx index 44a37ce3666faf8268b6a2d2ae279a60444a14ef..3547e6e98977e56e7a75b8d25ef52f700799e3ba 100644 --- a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx @@ -127,12 +127,10 @@ export default class Scalar extends Component { // TODO: some or all of these options should be part of formatValue if (typeof scalarValue === "number" && isNumber(column)) { - let number = scalarValue; - // scale const scale = parseFloat(settings["scalar.scale"]); if (!isNaN(scale)) { - number *= scale; + scalarValue *= scale; } const localeStringOptions = {}; @@ -140,10 +138,12 @@ export default class Scalar extends Component { // decimals let decimals = parseFloat(settings["scalar.decimals"]); if (!isNaN(decimals)) { - number = d3.round(number, decimals); + scalarValue = d3.round(scalarValue, decimals); localeStringOptions.minimumFractionDigits = decimals; } + let number = scalarValue; + // currency if (settings["scalar.currency"] != null) { localeStringOptions.style = "currency"; diff --git a/frontend/src/metabase/visualizations/visualizations/Text.css b/frontend/src/metabase/visualizations/visualizations/Text.css index 4baca99c897a67e6add07950719c7f35882ef375..9de3a09bb365503013822517f5bf8582c31202e9 100644 --- a/frontend/src/metabase/visualizations/visualizations/Text.css +++ b/frontend/src/metabase/visualizations/visualizations/Text.css @@ -61,7 +61,6 @@ line-height: 1.602em; padding: 0; margin: 0 1.5em 0.5em 0; - max-width: 620px; } :local .text-card-markdown ul { diff --git a/frontend/src/metabase/visualizations/visualizations/Text.jsx b/frontend/src/metabase/visualizations/visualizations/Text.jsx index 03f3abf7708382789ae91b62ab891f72e2def16d..36491ff7ab759355f9829cd6eda267cc14dc5133 100644 --- a/frontend/src/metabase/visualizations/visualizations/Text.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Text.jsx @@ -7,6 +7,7 @@ import styles from "./Text.css"; import Icon from "metabase/components/Icon.jsx"; import cx from "classnames"; +import { t } from "c-3po"; import type { VisualizationProps } from "metabase/meta/types/Visualization"; @@ -21,6 +22,13 @@ type State = { text: string, }; +const getSettingsStyle = settings => ({ + "align-center": settings["text.align_horizontal"] === "center", + "align-end": settings["text.align_horizontal"] === "right", + "justify-center": settings["text.align_vertical"] === "middle", + "justify-end": settings["text.align_vertical"] === "bottom", +}); + export default class Text extends Component { props: VisualizationProps; state: State; @@ -38,22 +46,61 @@ export default class Text extends Component { static identifier = "text"; static iconName = "text"; - static disableSettingsConfig = true; + static disableSettingsConfig = false; static noHeader = true; static supportsSeries = false; static hidden = true; - static minSize = { width: 4, height: 2 }; + static minSize = { width: 4, height: 1 }; static checkRenderable() { // text can always be rendered, nothing needed here } static settings = { + "card.title": { + dashboard: false, + }, + "card.description": { + dashboard: false, + }, text: { value: "", default: "", }, + "text.align_vertical": { + section: "Display", + title: t`Vertical Alignment`, + widget: "select", + props: { + options: [ + { name: t`Top`, value: "top" }, + { name: t`Middle`, value: "middle" }, + { name: t`Bottom`, value: "bottom" }, + ], + }, + default: "top", + }, + "text.align_horizontal": { + section: "Display", + title: t`Horizontal Alignment`, + widget: "select", + props: { + options: [ + { name: t`Left`, value: "left" }, + { name: t`Center`, value: "center" }, + { name: t`Right`, value: "right" }, + ], + }, + default: "left", + }, + "dashcard.background": { + section: "Display", + title: t`Show background`, + dashboard: true, + widget: "toggle", + default: true, + }, }; componentWillReceiveProps(newProps: VisualizationProps) { @@ -106,6 +153,7 @@ export default class Text extends Component { className={cx( "full flex-full flex flex-column text-card-markdown", styles["text-card-markdown"], + getSettingsStyle(settings), )} source={settings.text} /> @@ -116,7 +164,7 @@ export default class Text extends Component { styles["text-card-textarea"], )} name="text" - placeholder="Write here, and use Markdown if you'd like" + placeholder={t`Write here, and use Markdown if you''d like`} value={settings.text} onChange={e => this.handleTextChange(e.target.value)} /> @@ -130,12 +178,16 @@ export default class Text extends Component { className, styles.Text, styles[isSmall ? "small" : "large"], + /* if the card is not showing a background we should adjust the left + * padding to help align the titles with the wrapper */ + { pl0: !settings["dashcard.background"] }, )} > <ReactMarkdown className={cx( "full flex-full flex flex-column text-card-markdown", styles["text-card-markdown"], + getSettingsStyle(settings), )} source={settings.text} /> diff --git a/frontend/src/metabase/xray/Histogram.jsx b/frontend/src/metabase/xray/Histogram.jsx index e7936ad2ebf809d18f20c6aa842d5f8d58ce1f65..f60fc1ee42cc4746aa4ae15c66e90b4637e0a38e 100644 --- a/frontend/src/metabase/xray/Histogram.jsx +++ b/frontend/src/metabase/xray/Histogram.jsx @@ -5,7 +5,6 @@ import { normal } from "metabase/lib/colors"; const Histogram = ({ histogram, color, showAxis }) => ( <Visualization - className="full-height" rawSeries={[ { card: { diff --git a/frontend/src/metabase/xray/components/InsightCard.jsx b/frontend/src/metabase/xray/components/InsightCard.jsx index 3832837e9939a511156b462e5e1607a0c2c687f2..57d360ae1a1c95f990477f6a3770bb9aa26af830 100644 --- a/frontend/src/metabase/xray/components/InsightCard.jsx +++ b/frontend/src/metabase/xray/components/InsightCard.jsx @@ -175,17 +175,21 @@ export class VariationTrendInsight extends Component { render() { const { mode } = this.props; + const MODE_ADVERB_STRINGS = { + increasing: t`increasingly`, + decreasing: t`decreasingly`, + }; return ( <InsightText> - It looks like this data has grown {mode}ly{" "} + {t`It looks like this data has grown ${MODE_ADVERB_STRINGS[mode]}`}{" "} <TermWithDefinition definition={variationTrendDefinition} link={varianceLink} > - varied + {t`varied`} </TermWithDefinition>{" "} - over time. + {t`over time.`} </InsightText> ); } diff --git a/frontend/src/metabase/xray/components/PreviewBanner.jsx b/frontend/src/metabase/xray/components/PreviewBanner.jsx index 0c6a6d88d0f68dda1cce95926876ab8615975dfa..f6484916d996a75b31c5ed946a7c57fe496d59c5 100644 --- a/frontend/src/metabase/xray/components/PreviewBanner.jsx +++ b/frontend/src/metabase/xray/components/PreviewBanner.jsx @@ -1,6 +1,6 @@ import React from "react"; import Icon from "metabase/components/Icon"; -import { jt } from "c-3po"; +import { t, jt } from "c-3po"; const SURVEY_LINK = "https://docs.google.com/forms/d/e/1FAIpQLSc92WzF76ViiT8l4646lvFSWejNUhh4lhCSMXdZECILVwJG2A/viewform?usp=sf_link"; @@ -14,7 +14,7 @@ const PreviewBanner = () => ( /> <span>{jt`Welcome to the x-ray preview! We'd love ${( <a className="link" href={SURVEY_LINK} target="_blank"> - your feedback + {t`your feedback`} </a> )}`}</span> </div> diff --git a/frontend/src/metabase/xray/components/XRayComparison.jsx b/frontend/src/metabase/xray/components/XRayComparison.jsx index 68ca5a91598c43c4fc260d7d3e62cb4d8b2b98bc..fae7435f3910d5cce521002912a4ca951e410fa3 100644 --- a/frontend/src/metabase/xray/components/XRayComparison.jsx +++ b/frontend/src/metabase/xray/components/XRayComparison.jsx @@ -111,7 +111,6 @@ const CompareHistograms = ({ <div className="flex" style={{ height }}> <div className="flex-full"> <Visualization - className="full-height" rawSeries={[ { card: { diff --git a/frontend/src/metabase/xray/containers/CardXRay.jsx b/frontend/src/metabase/xray/containers/CardXRay.jsx index c8cd51bf6bfa874b1ae4a7296e2b503c1d786623..01cad7b5e88c19e1d37055b0818f9c0b19d013d0 100644 --- a/frontend/src/metabase/xray/containers/CardXRay.jsx +++ b/frontend/src/metabase/xray/containers/CardXRay.jsx @@ -137,7 +137,6 @@ class CardXRay extends Component { data: xray.features["linear-regression"].value, }, ]} - className="full-height" /> </div> </div> @@ -166,7 +165,6 @@ class CardXRay extends Component { }, }, ]} - className="full-height" /> </div> </div> @@ -210,7 +208,6 @@ class CardXRay extends Component { xray.features["seasonal-decomposition"].value.residual, }, ]} - className="full-height" /> </div> </div> diff --git a/frontend/src/metabase/xray/containers/TableXRay.jsx b/frontend/src/metabase/xray/containers/TableXRay.jsx index cb4b2992eed500aaeba15427a07c50ab48e590c6..7b70b2c9ac1fb47510c308d07e2c4a92f9a29bee 100644 --- a/frontend/src/metabase/xray/containers/TableXRay.jsx +++ b/frontend/src/metabase/xray/containers/TableXRay.jsx @@ -123,7 +123,7 @@ class TableXRay extends Component { </p> </div> <div className="ml-auto flex align-center"> - <h3 className="mr2">{t`Fidelity:`}</h3> + <h3 className="mr2">{t`Fidelity`}:</h3> <CostSelect xrayType="table" currentCost={params.cost} diff --git a/frontend/test/__runner__/empty.db.mv.db b/frontend/test/__runner__/empty.db.mv.db new file mode 100644 index 0000000000000000000000000000000000000000..fafd869b60b0f28c5c0f9368641986807a486e1c Binary files /dev/null and b/frontend/test/__runner__/empty.db.mv.db differ diff --git a/frontend/test/__runner__/test_db_fixture.db.h2.db b/frontend/test/__runner__/test_db_fixture.db.h2.db index 0f5a62a12ae1abc3e7ddb10c3fff7d325b500177..8d4ce44c652e9dc280a41579ea97aa0320de59bd 100644 Binary files a/frontend/test/__runner__/test_db_fixture.db.h2.db and b/frontend/test/__runner__/test_db_fixture.db.h2.db differ diff --git a/frontend/test/__support__/integrated_tests.js b/frontend/test/__support__/integrated_tests.js index 5514281e3f13fcd7c7d646923ab19a3dabff0a64..eed5b95f8e57b3d047ad97e27e37c80f33489489 100644 --- a/frontend/test/__support__/integrated_tests.js +++ b/frontend/test/__support__/integrated_tests.js @@ -10,7 +10,14 @@ import "./mocks"; import { format as urlFormat } from "url"; import api from "metabase/lib/api"; -import { DashboardApi, SessionApi } from "metabase/services"; +import { defer, delay } from "metabase/lib/promise"; +import { + DashboardApi, + SessionApi, + CardApi, + MetricApi, + SegmentApi, +} from "metabase/services"; import { METABASE_SESSION_COOKIE } from "metabase/lib/cookies"; import normalReducers from "metabase/reducers-main"; import publicReducers from "metabase/reducers-public"; @@ -21,8 +28,13 @@ import { Provider } from "react-redux"; import { createMemoryHistory } from "history"; import { getStore } from "metabase/store"; import { createRoutes, Router, useRouterHistory } from "react-router"; + import _ from "underscore"; import chalk from "chalk"; +import moment from "moment"; + +import EventEmitter from "events"; +const events = new EventEmitter(); // Importing isomorphic-fetch sets the global `fetch` and `Headers` objects that are used here import fetch from "isomorphic-fetch"; @@ -33,8 +45,6 @@ import { getRoutes as getNormalRoutes } from "metabase/routes"; import { getRoutes as getPublicRoutes } from "metabase/routes-public"; import { getRoutes as getEmbedRoutes } from "metabase/routes-embed"; -import moment from "moment"; - let hasStartedCreatingStore = false; let hasFinishedCreatingStore = false; let loginSession = null; // Stores the current login session @@ -202,6 +212,8 @@ const testStoreEnhancer = (createStore, history, getRoutes) => { * Redux dispatch method middleware that records all dispatched actions */ dispatch: action => { + events.emit("action", action); + const result = store._originalDispatch(action); const actionWithTimestamp = [ @@ -438,6 +450,17 @@ export const waitForRequestToComplete = ( }); }; +export const waitForAllRequestsToComplete = () => { + if (pendingRequests > 0) { + if (!pendingRequestsDeferred) { + pendingRequestsDeferred = defer(); + } + return pendingRequestsDeferred.promise; + } else { + return Promise.resolve(); + } +}; + /** * Lets you replace given API endpoints with mocked implementations for the lifetime of a test */ @@ -475,68 +498,167 @@ export async function withApiMocks(mocks, test) { } } +// async function that tries running an assertion multiple times until it succeeds +// useful for reducing race conditions in tests +// TODO: log API calls and Redux actions that occurred in the meantime +export const eventually = async (assertion, timeout = 5000, period = 250) => { + const start = Date.now(); + + const errors = []; + const actions = []; + const requests = []; + const addAction = a => actions.push(a); + const addRequest = r => requests.push(r); + events.addListener("action", addAction); + events.addListener("request", addRequest); + const cleanup = () => { + events.removeListener("action", addAction); + events.removeListener("request", addRequest); + }; + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + await assertion(); + if (errors.length > 0) { + console.warn( + "eventually asserted after " + (Date.now() - start) + " ms", + "\n + error:\n", + errors[errors.length - 1], + "\n + actions:\n ", + actions.map(a => a && a.type).join("\n "), + "\n + requests:\n ", + requests.map(r => r && r.url).join("\n "), + ); + } + cleanup(); + return; + } catch (e) { + if (Date.now() - start >= timeout) { + cleanup(); + throw e; + } + errors.push(e); + } + await delay(period); + } +}; + +// to help tests cleanup after themselves, since integration tests don't use +// isolated environments, e.x. +// +// beforeAll(async () => { +// cleanup.metric(await MetricApi.create({ ... })) +// }) +// afterAll(cleanup); +// +export const cleanup = () => { + useSharedAdminLogin(); + Promise.all( + cleanup.actions.splice(0, cleanup.actions.length).map(action => action()), + ); +}; +cleanup.actions = []; +cleanup.fn = action => cleanup.actions.push(action); +cleanup.metric = metric => cleanup.fn(() => deleteMetric(metric)); +cleanup.segment = segment => cleanup.fn(() => deleteSegment(segment)); +cleanup.question = question => cleanup.fn(() => deleteQuestion(question)); + +export const deleteQuestion = question => + CardApi.delete({ cardId: getId(question) }); +export const deleteSegment = segment => + SegmentApi.delete({ segmentId: getId(segment), revision_message: "Please" }); +export const deleteMetric = metric => + MetricApi.delete({ metricId: getId(metric), revision_message: "Please" }); + +const getId = o => + typeof o === "object" && o != null + ? typeof o.id === "function" ? o.id() : o.id + : o; + +export const deleteAllSegments = async () => + Promise.all((await SegmentApi.list()).map(deleteSegment)); +export const deleteAllMetrics = async () => + Promise.all((await MetricApi.list()).map(deleteMetric)); + +let pendingRequests = 0; +let pendingRequestsDeferred = null; + // Patches the metabase/lib/api module so that all API queries contain the login credential cookie. // Needed because we are not in a real web browser environment. api._makeRequest = async (method, url, headers, requestBody, data, options) => { - const headersWithSessionCookie = { - ...headers, - ...(loginSession - ? { Cookie: `${METABASE_SESSION_COOKIE}=${loginSession.id}` } - : {}), - }; + pendingRequests++; + try { + const headersWithSessionCookie = { + ...headers, + ...(loginSession + ? { Cookie: `${METABASE_SESSION_COOKIE}=${loginSession.id}` } + : {}), + }; - const fetchOptions = { - credentials: "include", - method, - headers: new Headers(headersWithSessionCookie), - ...(requestBody ? { body: requestBody } : {}), - }; + const fetchOptions = { + credentials: "include", + method, + headers: new Headers(headersWithSessionCookie), + ...(requestBody ? { body: requestBody } : {}), + }; - let isCancelled = false; - if (options.cancelled) { - options.cancelled.then(() => { - isCancelled = true; - }); - } - const result = simulateOfflineMode - ? { status: 0, responseText: "" } - : await fetch(api.basename + url, fetchOptions); + let isCancelled = false; + if (options.cancelled) { + options.cancelled.then(() => { + isCancelled = true; + }); + } + const result = simulateOfflineMode + ? { status: 0, responseText: "" } + : await fetch(api.basename + url, fetchOptions); - if (isCancelled) { - throw { status: 0, data: "", isCancelled: true }; - } + if (isCancelled) { + throw { status: 0, data: "", isCancelled: true }; + } - let resultBody = null; - try { - resultBody = await result.text(); - // Even if the result conversion to JSON fails, we still return the original text - // This is 1-to-1 with the real _makeRequest implementation - resultBody = JSON.parse(resultBody); - } catch (e) {} - - apiRequestCompletedCallback && - setTimeout(() => apiRequestCompletedCallback(method, url), 0); - - if (result.status >= 200 && result.status <= 299) { - if (options.transformResponse) { - return options.transformResponse(resultBody, { data }); + let resultBody = null; + try { + resultBody = await result.text(); + // Even if the result conversion to JSON fails, we still return the original text + // This is 1-to-1 with the real _makeRequest implementation + resultBody = JSON.parse(resultBody); + } catch (e) {} + + apiRequestCompletedCallback && + setTimeout(() => apiRequestCompletedCallback(method, url), 0); + + events.emit("request", { method, url }); + + if (result.status >= 200 && result.status <= 299) { + if (options.transformResponse) { + return options.transformResponse(resultBody, { data }); + } else { + return resultBody; + } } else { - return resultBody; + const error = { + status: result.status, + data: resultBody, + isCancelled: false, + }; + if (!simulateOfflineMode) { + console.log( + "A request made in a test failed with the following error:", + ); + console.log(error, { depth: null }); + console.log(`The original request: ${method} ${url}`); + if (requestBody) console.log(`Original payload: ${requestBody}`); + } + + throw error; } - } else { - const error = { - status: result.status, - data: resultBody, - isCancelled: false, - }; - if (!simulateOfflineMode) { - console.log("A request made in a test failed with the following error:"); - console.log(error, { depth: null }); - console.log(`The original request: ${method} ${url}`); - if (requestBody) console.log(`Original payload: ${requestBody}`); + } finally { + pendingRequests--; + if (pendingRequests === 0 && pendingRequestsDeferred) { + process.nextTick(pendingRequestsDeferred.resolve); + pendingRequestsDeferred = null; } - - throw error; } }; diff --git a/frontend/test/__support__/mocks.js b/frontend/test/__support__/mocks.js index 9dbe9c99289643f62be3e0e4afb1609244f50af9..e79230113f75503891ae2771ba05e0dd3536b918 100644 --- a/frontend/test/__support__/mocks.js +++ b/frontend/test/__support__/mocks.js @@ -46,8 +46,6 @@ bodyComponent.default = bodyComponent.TestBodyComponent; import * as table from "metabase/visualizations/visualizations/Table"; table.default = table.TestTable; -jest.mock("metabase/hoc/Remapped"); - // Replace addEventListener with a test implementation which collects all event listeners to `eventListeners` map export let eventListeners = {}; const testAddEventListener = jest.fn((event, listener) => { diff --git a/frontend/test/__support__/sample_dataset_fixture.js b/frontend/test/__support__/sample_dataset_fixture.js index 32295c1284211896e268b2071c0ac5e1e7b5160a..7b80db62bddaed590757c6e978a6339daaab700a 100644 --- a/frontend/test/__support__/sample_dataset_fixture.js +++ b/frontend/test/__support__/sample_dataset_fixture.js @@ -1479,6 +1479,25 @@ export const clickedFKValue = { value: 43, }; +export const clickedDateTimeValue = { + column: { + ...metadata.fields[ORDERS_CREATED_DATE_FIELD_ID], + source: "fields", + }, + value: "2018-01-01T00:00:00Z", +}; + +export const clickedMetric = { + column: { + name: "count", + display_name: "count", + base_type: "type/Integer", + special_type: "type/Number", + source: "aggregation", + }, + value: 42, +}; + export const tableMetadata = metadata.tables[ORDERS_TABLE_ID]; export function makeQuestion(fn = (card, state) => ({ card, state })) { diff --git a/frontend/test/admin/datamodel/FieldApp.integ.spec.js b/frontend/test/admin/datamodel/FieldApp.integ.spec.js index 313b844329eaabdcdf71895fdaf3fa1bae2ac24e..043eccdabe05041248b8fa59bffefc7029b5ce81 100644 --- a/frontend/test/admin/datamodel/FieldApp.integ.spec.js +++ b/frontend/test/admin/datamodel/FieldApp.integ.spec.js @@ -1,6 +1,7 @@ import { useSharedAdminLogin, createTestStore, + eventually, } from "__support__/integrated_tests"; import { @@ -189,6 +190,7 @@ describe("FieldApp", () => { const { store, fieldApp } = await initFieldApp({ fieldId: CREATED_AT_ID, }); + const picker = fieldApp.find(SpecialTypeAndTargetPicker); const typeSelect = picker.find(Select).at(0); click(typeSelect); @@ -269,7 +271,7 @@ describe("FieldApp", () => { await store.dispatch( updateField({ ...createdAtField, - special_type: null, + special_type: "type/CreationTimestamp", fk_target_field_id: null, }), ); @@ -306,12 +308,14 @@ describe("FieldApp", () => { .first(); click(useFKButton); store.waitForActions([UPDATE_FIELD_DIMENSION, FETCH_TABLE_METADATA]); - // TODO: Figure out a way to avoid using delay – the use of delays may lead to occasional CI failures - await delay(500); - const fkFieldSelect = section.find(SelectButton); + let fkFieldSelect; + + await eventually(() => { + fkFieldSelect = section.find(SelectButton); + expect(fkFieldSelect.text()).toBe("Name"); + }); - expect(fkFieldSelect.text()).toBe("Name"); click(fkFieldSelect); const sourceField = fkFieldSelect @@ -325,9 +329,11 @@ describe("FieldApp", () => { click(sourceField); store.waitForActions([FETCH_TABLE_METADATA]); - // TODO: Figure out a way to avoid using delay – the use of delays may lead to occasional CI failures - await delay(500); - expect(fkFieldSelect.text()).toBe("Source"); + + await eventually(() => { + fkFieldSelect = section.find(SelectButton); + expect(fkFieldSelect.text()).toBe("Source"); + }); }); it("doesn't show date fields in fk options", async () => { @@ -401,7 +407,7 @@ describe("FieldApp", () => { e: { target: document.documentElement }, }); await delay(300); // delay needed because of setState in FieldApp; app.update() does not work for whatever reason - expect(section.find(".text-danger").length).toBe(1); // warning that you should choose a column + expect(section.find(".text-error").length).toBe(1); // warning that you should choose a column }); it("doesn't let you enter custom remappings for a field with string values", async () => { diff --git a/frontend/test/admin/datamodel/datamodel.integ.spec.js b/frontend/test/admin/datamodel/datamodel.integ.spec.js index e030d5cc3ddb7cc1456c61b44420f99ae4b08757..6bdbaf11694cbfb4b6e7813c7fef5ad8b06d704a 100644 --- a/frontend/test/admin/datamodel/datamodel.integ.spec.js +++ b/frontend/test/admin/datamodel/datamodel.integ.spec.js @@ -2,6 +2,8 @@ import { useSharedAdminLogin, createTestStore, + deleteAllSegments, + deleteAllMetrics, } from "__support__/integrated_tests"; import { click, clickButton, setInputValue } from "__support__/enzyme_utils"; import { mount } from "enzyme"; @@ -199,14 +201,18 @@ describe("admin/datamodel", () => { ).toEqual("User countCount"); }); - afterAll(async () => { - await MetabaseApi.table_update({ id: 1, visibility_type: null }); // Sample Dataset - await MetabaseApi.field_update({ - id: 8, - visibility_type: "normal", - special_type: null, - }); // Address - await MetabaseApi.field_update({ id: 9, visibility_type: "normal" }); // Address - }); + afterAll(() => + Promise.all([ + MetabaseApi.table_update({ id: 1, visibility_type: null }), // Sample Dataset + MetabaseApi.field_update({ + id: 8, + visibility_type: "normal", + special_type: null, + }), // Address + MetabaseApi.field_update({ id: 9, visibility_type: "normal" }), // Address + deleteAllSegments(), + deleteAllMetrics(), + ]), + ); }); }); diff --git a/frontend/test/admin/permissions/selectors.unit.spec.js b/frontend/test/admin/permissions/selectors.unit.spec.js index 8e34bd81d096e4545ca65082bb0dc9b2de274d31..ba65bfb9e44a401e0d3ddb99648abfd60e453612 100644 --- a/frontend/test/admin/permissions/selectors.unit.spec.js +++ b/frontend/test/admin/permissions/selectors.unit.spec.js @@ -80,7 +80,7 @@ const initialState = { metadata: normalizedMetadata, }; -var state = initialState; +let state = initialState; const resetState = () => { state = initialState; }; diff --git a/frontend/test/containers/AdHocQuestionLoader.unit.spec.js b/frontend/test/containers/AdHocQuestionLoader.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f9cc2d0e5c783d8317cbb51c82385e14c20c89a3 --- /dev/null +++ b/frontend/test/containers/AdHocQuestionLoader.unit.spec.js @@ -0,0 +1,76 @@ +import React from "react"; +import { shallow, mount } from "enzyme"; + +import Question from "metabase-lib/lib/Question"; +import { delay } from "metabase/lib/promise"; + +// import the un-connected component so we can test its internal logic sans +// redux +import { AdHocQuestionLoader } from "metabase/containers/AdHocQuestionLoader"; + +describe("AdHocQuestionLoader", () => { + let loadQuestionSpy, loadMetadataSpy, mockChild; + beforeEach(() => { + // reset mocks between tests so we have fresh spies, etc + jest.resetAllMocks(); + mockChild = jest.fn().mockReturnValue(<div />); + loadMetadataSpy = jest.fn(); + loadQuestionSpy = jest.spyOn( + AdHocQuestionLoader.prototype, + "_loadQuestion", + ); + }); + + it("should load a question given a questionHash", async () => { + const q = new Question.create({ databaseId: 1, tableId: 2 }); + const questionHash = q.getUrl().match(/(#.*)/)[1]; + + const wrapper = mount( + <AdHocQuestionLoader + questionHash={questionHash} + loadMetadataForCard={loadMetadataSpy} + children={mockChild} + />, + ); + expect(mockChild.mock.calls[0][0].loading).toEqual(true); + expect(mockChild.mock.calls[0][0].error).toEqual(null); + + // stuff happens asynchronously + wrapper.update(); + await delay(0); + + expect(loadMetadataSpy.mock.calls[0][0]).toEqual(q.card()); + + const calls = mockChild.mock.calls; + const { question, loading, error } = calls[calls.length - 1][0]; + expect(question.card()).toEqual(q.card()); + expect(loading).toEqual(false); + expect(error).toEqual(null); + }); + + it("should load a new question if the question hash changes", () => { + // create some junk strigs, real question hashes are more ludicrous but this + // is easier to verify + const originalQuestionHash = "#abc123"; + const newQuestionHash = "#def456"; + + const wrapper = shallow( + <AdHocQuestionLoader + questionHash={originalQuestionHash} + loadMetadataForCard={loadMetadataSpy} + children={mockChild} + />, + ); + + expect(loadQuestionSpy).toHaveBeenCalledWith(originalQuestionHash); + + // update the question hash, a new location.hash in the url would most + // likely do this + wrapper.setProps({ questionHash: newQuestionHash }); + + // question loading should begin with the new ID + expect(loadQuestionSpy).toHaveBeenCalledWith(newQuestionHash); + + expect(mockChild).toHaveBeenCalled(); + }); +}); diff --git a/frontend/test/containers/QuestionAndResultLoader.unit.spec.js b/frontend/test/containers/QuestionAndResultLoader.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..421df4519b55f7430c70641f5755f9540b49c095 --- /dev/null +++ b/frontend/test/containers/QuestionAndResultLoader.unit.spec.js @@ -0,0 +1,10 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import QuestionAndResultLoader from "metabase/containers/QuestionAndResultLoader"; + +describe("QuestionAndResultLoader", () => { + it("should load a question and a result", () => { + shallow(<QuestionAndResultLoader>{() => <div />}</QuestionAndResultLoader>); + }); +}); diff --git a/frontend/test/containers/QuestionLoader.unit.spec.js b/frontend/test/containers/QuestionLoader.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0a548971c6325acfddbc88e0259fa3255f218c03 --- /dev/null +++ b/frontend/test/containers/QuestionLoader.unit.spec.js @@ -0,0 +1,46 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import QuestionLoader from "metabase/containers/QuestionLoader"; + +import AdHocQuestionLoader from "metabase/containers/AdHocQuestionLoader"; +import SavedQuestionLoader from "metabase/containers/SavedQuestionLoader"; + +describe("QuestionLoader", () => { + describe("initial load", () => { + it("should use SavedQuestionLoader if there is a saved question", () => { + const wrapper = shallow( + <QuestionLoader questionId={1}>{() => <div />}</QuestionLoader>, + ); + + expect(wrapper.find(SavedQuestionLoader).length).toBe(1); + }); + + it("should use AdHocQuestionLoader if there is an ad-hoc question", () => { + const wrapper = shallow( + <QuestionLoader questionHash={"#abc123"}> + {() => <div />} + </QuestionLoader>, + ); + + expect(wrapper.find(AdHocQuestionLoader).length).toBe(1); + }); + }); + describe("subsequent movement", () => { + it("should transition between loaders when props change", () => { + // start with a quesitonId + const wrapper = shallow( + <QuestionLoader questionId={4}>{() => <div />}</QuestionLoader>, + ); + + expect(wrapper.find(SavedQuestionLoader).length).toBe(1); + + wrapper.setProps({ + questionId: undefined, + questionHash: "#abc123", + }); + + expect(wrapper.find(AdHocQuestionLoader).length).toBe(1); + }); + }); +}); diff --git a/frontend/test/containers/QuestionResultLoader.unit.spec.js b/frontend/test/containers/QuestionResultLoader.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..020d93dbe86567e79df54d2c665aa33ce934c018 --- /dev/null +++ b/frontend/test/containers/QuestionResultLoader.unit.spec.js @@ -0,0 +1,22 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import { QuestionResultLoader } from "metabase/containers/QuestionResultLoader"; + +describe("QuestionResultLoader", () => { + it("should load a result given a question", () => { + const question = { + id: 1, + }; + + const loadSpy = jest.spyOn(QuestionResultLoader.prototype, "_loadResult"); + + shallow( + <QuestionResultLoader question={question}> + {() => <div />} + </QuestionResultLoader>, + ); + + expect(loadSpy).toHaveBeenCalledWith(question); + }); +}); diff --git a/frontend/test/containers/SavedQuestionLoader.unit.spec.js b/frontend/test/containers/SavedQuestionLoader.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..d41f8d0d9c1747c9f8e8c1693d41ccd7dfb2c376 --- /dev/null +++ b/frontend/test/containers/SavedQuestionLoader.unit.spec.js @@ -0,0 +1,76 @@ +import React from "react"; +import { shallow, mount } from "enzyme"; + +import Question from "metabase-lib/lib/Question"; +import { delay } from "metabase/lib/promise"; +import { CardApi } from "metabase/services"; + +// import the un-connected component so we can test its internal logic sans +// redux +import { SavedQuestionLoader } from "metabase/containers/SavedQuestionLoader"; + +// we need to mock the things that try and actually load the question +jest.mock("metabase/services"); + +describe("SavedQuestionLoader", () => { + let loadQuestionSpy, loadMetadataSpy, mockChild; + beforeEach(() => { + // reset mocks between tests so we have fresh spies, etc + jest.resetAllMocks(); + mockChild = jest.fn().mockReturnValue(<div />); + loadMetadataSpy = jest.fn(); + loadQuestionSpy = jest.spyOn( + SavedQuestionLoader.prototype, + "_loadQuestion", + ); + }); + + it("should load a question given a questionId", async () => { + const questionId = 1; + const q = new Question.create({ databaseId: 1, tableId: 2 }); + jest.spyOn(CardApi, "get").mockReturnValue(q.card()); + + const wrapper = mount( + <SavedQuestionLoader + questionId={questionId} + loadMetadataForCard={loadMetadataSpy} + children={mockChild} + />, + ); + expect(mockChild.mock.calls[0][0].loading).toEqual(true); + expect(mockChild.mock.calls[0][0].error).toEqual(null); + + // stuff happens asynchronously + wrapper.update(); + await delay(0); + + expect(loadQuestionSpy).toHaveBeenCalledWith(questionId); + + const calls = mockChild.mock.calls; + const { question, loading, error } = calls[calls.length - 1][0]; + expect(question.card()).toEqual(q.card()); + expect(loading).toEqual(false); + expect(error).toEqual(null); + }); + + it("should load a new question if the question ID changes", () => { + const originalQuestionId = 1; + const newQuestionId = 2; + + const wrapper = shallow( + <SavedQuestionLoader + questionId={originalQuestionId} + loadMetadataForCard={loadMetadataSpy} + children={mockChild} + />, + ); + + expect(loadQuestionSpy).toHaveBeenCalledWith(originalQuestionId); + + // update the question ID, a new question id param in the url would do this + wrapper.setProps({ questionId: newQuestionId }); + + // question loading should begin with the new ID + expect(loadQuestionSpy).toHaveBeenCalledWith(newQuestionId); + }); +}); diff --git a/frontend/test/dashboard/__snapshots__/DashCard.unit.spec.js.snap b/frontend/test/dashboard/__snapshots__/DashCard.unit.spec.js.snap index 19a455b1d1ef648fcf0ef54dc07f1011daad10ec..b6fe100cf471b5b17c798d940f90bc9b7d7e667c 100644 --- a/frontend/test/dashboard/__snapshots__/DashCard.unit.spec.js.snap +++ b/frontend/test/dashboard/__snapshots__/DashCard.unit.spec.js.snap @@ -3,5 +3,6 @@ exports[`DashCard should render with no special classNames 1`] = ` <div className="Card bordered rounded flex flex-column hover-parent hover--visibility" + style={null} /> `; diff --git a/frontend/test/home/HomepageApp.integ.spec.js b/frontend/test/home/HomepageApp.integ.spec.js index 17248a700d6db02682f8d31d0d9ace920b633f32..71f5658e673e05db685549e246dac7a940c9cc35 100644 --- a/frontend/test/home/HomepageApp.integ.spec.js +++ b/frontend/test/home/HomepageApp.integ.spec.js @@ -2,6 +2,7 @@ import { useSharedAdminLogin, createTestStore, createSavedQuestion, + cleanup, } from "__support__/integrated_tests"; import { click } from "__support__/enzyme_utils"; @@ -22,38 +23,24 @@ import Activity from "metabase/home/components/Activity"; import ActivityItem from "metabase/home/components/ActivityItem"; import ActivityStory from "metabase/home/components/ActivityStory"; import Scalar from "metabase/visualizations/visualizations/Scalar"; -import { CardApi, MetricApi, SegmentApi } from "metabase/services"; +import { MetricApi, SegmentApi } from "metabase/services"; describe("HomepageApp", () => { - let questionId = null; - let segmentId = null; - let metricId = null; - beforeAll(async () => { useSharedAdminLogin(); // Create some entities that will show up in the top of activity feed // This test doesn't care if there already are existing items in the feed or not // Delays are required for having separable creation times for each entity - questionId = (await createSavedQuestion(unsavedOrderCountQuestion)).id(); + cleanup.question(await createSavedQuestion(unsavedOrderCountQuestion)); await delay(100); - segmentId = (await SegmentApi.create(orders_past_300_days_segment)).id; + cleanup.segment(await SegmentApi.create(orders_past_300_days_segment)); await delay(100); - metricId = (await MetricApi.create(vendor_count_metric)).id; + cleanup.metric(await MetricApi.create(vendor_count_metric)); await delay(100); }); - afterAll(async () => { - await MetricApi.delete({ - metricId, - revision_message: "Let's exterminate this metric", - }); - await SegmentApi.delete({ - segmentId, - revision_message: "Let's exterminate this segment", - }); - await CardApi.delete({ cardId: questionId }); - }); + afterAll(cleanup); describe("activity feed", async () => { it("shows the expected list of activity", async () => { diff --git a/frontend/test/internal/__snapshots__/components.unit.spec.js.snap b/frontend/test/internal/__snapshots__/components.unit.spec.js.snap index 819163c5566ee713b2f37cb1653b336078907676..2106e4e76d6aa0df4c171ba28cb14749ae181fa5 100644 --- a/frontend/test/internal/__snapshots__/components.unit.spec.js.snap +++ b/frontend/test/internal/__snapshots__/components.unit.spec.js.snap @@ -275,7 +275,7 @@ exports[`EntityMenu should render "Edit menu" correctly 1`] = ` className="relative" > <div - className="x0 x1 xl xm xn xo x2 xp xq xr xa xs xt" + className="x0 x1 xk xl xm xn x2 xo xp xq xa xr xs" onClick={[Function]} > <svg @@ -307,7 +307,7 @@ exports[`EntityMenu should render "More menu" correctly 1`] = ` className="relative" > <div - className="x0 x1 xl xm xn xo x2 xp xq xr xa xs xt" + className="x0 x1 xk xl xm xn x2 xo xp xq xa xr xs" onClick={[Function]} > <svg @@ -339,7 +339,7 @@ exports[`EntityMenu should render "Multiple menus" correctly 1`] = ` className="relative" > <div - className="x0 x1 xl xm xn xo x2 xp xq xr xa xs xt" + className="x0 x1 xk xl xm xn x2 xo xp xq xa xr xs" onClick={[Function]} > <svg @@ -360,7 +360,7 @@ exports[`EntityMenu should render "Multiple menus" correctly 1`] = ` className="relative" > <div - className="x0 x1 xl xm xn xo x2 xp xq xr xa xs xt" + className="x0 x1 xk xl xm xn x2 xo xp xq xa xr xs" onClick={[Function]} > <svg @@ -382,7 +382,7 @@ exports[`EntityMenu should render "Multiple menus" correctly 1`] = ` className="relative" > <div - className="x0 x1 xl xm xn xo x2 xp xq xr xa xs xt" + className="x0 x1 xk xl xm xn x2 xo xp xq xa xr xs" onClick={[Function]} > <svg @@ -414,7 +414,7 @@ exports[`EntityMenu should render "Share menu" correctly 1`] = ` className="relative" > <div - className="x0 x1 xl xm xn xo x2 xp xq xr xa xs xt" + className="x0 x1 xk xl xm xn x2 xo xp xq xa xr xs" onClick={[Function]} > <svg @@ -436,6 +436,26 @@ exports[`EntityMenu should render "Share menu" correctly 1`] = ` </div> `; +exports[`ProgressBar should render "Animated" correctly 1`] = ` +<div + className="xt xu xv xn" +> + <div + className="xw xx xt xy xz x10 x11 x12 x13 x1d x1e x16 x17 x18 x1f x1a x1b x1g" + /> +</div> +`; + +exports[`ProgressBar should render "Default" correctly 1`] = ` +<div + className="xt xu xv xn" +> + <div + className="xw xx xt xy xz x10 x11 x12 x13 x14 x15 x16 x17 x18 x19 x1a x1b x1c" + /> +</div> +`; + exports[`Select should render "Default" correctly 1`] = ` <a className="no-decoration" @@ -759,7 +779,7 @@ exports[`Toggle should render "on" correctly 1`] = ` exports[`TokenField should render "" correctly 1`] = ` <div> <ul - className="m1 p0 pb1 bordered rounded flex flex-wrap bg-white scroll-x scroll-y xj xk" + className="border-bottom p1 pb2 flex flex-wrap bg-white scroll-x scroll-y xj" onMouseDownCapture={[Function]} style={undefined} > @@ -781,7 +801,7 @@ exports[`TokenField should render "" correctly 1`] = ` </li> </ul> <ul - className="ml1 scroll-y scroll-show" + className="pl1 py1 scroll-y scroll-show border-bottom" onMouseEnter={[Function]} onMouseLeave={[Function]} style={ @@ -790,7 +810,9 @@ exports[`TokenField should render "" correctly 1`] = ` } } > - <li> + <li + className="mr1" + > <div className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover" onClick={[Function]} @@ -801,7 +823,9 @@ exports[`TokenField should render "" correctly 1`] = ` </span> </div> </li> - <li> + <li + className="mr1" + > <div className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover" onClick={[Function]} @@ -812,7 +836,9 @@ exports[`TokenField should render "" correctly 1`] = ` </span> </div> </li> - <li> + <li + className="mr1" + > <div className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover" onClick={[Function]} @@ -823,7 +849,9 @@ exports[`TokenField should render "" correctly 1`] = ` </span> </div> </li> - <li> + <li + className="mr1" + > <div className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover" onClick={[Function]} @@ -841,7 +869,7 @@ exports[`TokenField should render "" correctly 1`] = ` exports[`TokenField should render "updateOnInputChange" correctly 1`] = ` <div> <ul - className="m1 p0 pb1 bordered rounded flex flex-wrap bg-white scroll-x scroll-y xj xk" + className="border-bottom p1 pb2 flex flex-wrap bg-white scroll-x scroll-y xj" onMouseDownCapture={[Function]} style={undefined} > @@ -863,7 +891,7 @@ exports[`TokenField should render "updateOnInputChange" correctly 1`] = ` </li> </ul> <ul - className="ml1 scroll-y scroll-show" + className="pl1 py1 scroll-y scroll-show border-bottom" onMouseEnter={[Function]} onMouseLeave={[Function]} style={ @@ -872,7 +900,9 @@ exports[`TokenField should render "updateOnInputChange" correctly 1`] = ` } } > - <li> + <li + className="mr1" + > <div className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover" onClick={[Function]} @@ -883,7 +913,9 @@ exports[`TokenField should render "updateOnInputChange" correctly 1`] = ` </span> </div> </li> - <li> + <li + className="mr1" + > <div className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover" onClick={[Function]} @@ -894,7 +926,9 @@ exports[`TokenField should render "updateOnInputChange" correctly 1`] = ` </span> </div> </li> - <li> + <li + className="mr1" + > <div className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover" onClick={[Function]} @@ -905,7 +939,9 @@ exports[`TokenField should render "updateOnInputChange" correctly 1`] = ` </span> </div> </li> - <li> + <li + className="mr1" + > <div className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover" onClick={[Function]} diff --git a/frontend/test/jest-setup.js b/frontend/test/jest-setup.js new file mode 100644 index 0000000000000000000000000000000000000000..6ebd47696738c7d55b3b8e6e0abd1db2d76cc700 --- /dev/null +++ b/frontend/test/jest-setup.js @@ -0,0 +1 @@ +import "raf/polyfill"; diff --git a/frontend/test/karma.conf.js b/frontend/test/karma.conf.js index 39351f61eac327500a14eb74e0081c40665975cc..07d47226eae68cebbcd2e56a9ae3a0103bd1cb36 100644 --- a/frontend/test/karma.conf.js +++ b/frontend/test/karma.conf.js @@ -1,4 +1,4 @@ -var webpackConfig = require("../../webpack.config"); +let webpackConfig = require("../../webpack.config"); console.dir(webpackConfig.module.rules, { depth: null }); webpackConfig.module.rules.forEach(function(loader) { loader.use = loader.use.filter( diff --git a/frontend/test/lib/formatting.unit.spec.js b/frontend/test/lib/formatting.unit.spec.js index de2a443a3915d2c0f67fc5993962f1bdd4189311..8c117322293e3a8e5cf8a170b8b1fa556f2644cd 100644 --- a/frontend/test/lib/formatting.unit.spec.js +++ b/frontend/test/lib/formatting.unit.spec.js @@ -72,18 +72,18 @@ describe("formatting", () => { }), ).toEqual("122.41940000° W"); }); - it("should return a component for links in jsx mode", () => { + it("should return a component for links in jsx + rich mode", () => { expect( isElementOfType( - formatValue("http://metabase.com/", { jsx: true }), + formatValue("http://metabase.com/", { jsx: true, rich: true }), ExternalLink, ), ).toEqual(true); }); - it("should return a component for email addresses in jsx mode", () => { + it("should return a component for email addresses in jsx + rich mode", () => { expect( isElementOfType( - formatValue("tom@metabase.com", { jsx: true }), + formatValue("tom@metabase.com", { jsx: true, rich: true }), ExternalLink, ), ).toEqual(true); @@ -97,19 +97,19 @@ describe("formatting", () => { it("should return a component for http:, https:, and mailto: links in jsx mode", () => { expect( isElementOfType( - formatUrl("http://metabase.com/", { jsx: true }), + formatUrl("http://metabase.com/", { jsx: true, rich: true }), ExternalLink, ), ).toEqual(true); expect( isElementOfType( - formatUrl("https://metabase.com/", { jsx: true }), + formatUrl("https://metabase.com/", { jsx: true, rich: true }), ExternalLink, ), ).toEqual(true); expect( isElementOfType( - formatUrl("mailto:tom@metabase.com", { jsx: true }), + formatUrl("mailto:tom@metabase.com", { jsx: true, rich: true }), ExternalLink, ), ).toEqual(true); @@ -117,20 +117,26 @@ describe("formatting", () => { it("should not return a link component for unrecognized links in jsx mode", () => { expect( isElementOfType( - formatUrl("nonexistent://metabase.com/", { jsx: true }), + formatUrl("nonexistent://metabase.com/", { jsx: true, rich: true }), ExternalLink, ), ).toEqual(false); expect( - isElementOfType(formatUrl("metabase.com", { jsx: true }), ExternalLink), + isElementOfType( + formatUrl("metabase.com", { jsx: true, rich: true }), + ExternalLink, + ), ).toEqual(false); }); it("should return a string for javascript:, data:, and other links in jsx mode", () => { - expect(formatUrl("javascript:alert('pwnd')", { jsx: true })).toEqual( - "javascript:alert('pwnd')", - ); expect( - formatUrl("data:text/plain;charset=utf-8,hello%20world", { jsx: true }), + formatUrl("javascript:alert('pwnd')", { jsx: true, rich: true }), + ).toEqual("javascript:alert('pwnd')"); + expect( + formatUrl("data:text/plain;charset=utf-8,hello%20world", { + jsx: true, + rich: true, + }), ).toEqual("data:text/plain;charset=utf-8,hello%20world"); }); }); diff --git a/frontend/test/metabase-bootstrap.js b/frontend/test/metabase-bootstrap.js index fad39e0091cb16aba9fcc8ca56097b3224319073..f1716c1a11cf0ed445f3ad777f0a7ebf6246cf5f 100644 --- a/frontend/test/metabase-bootstrap.js +++ b/frontend/test/metabase-bootstrap.js @@ -17,43 +17,67 @@ window.MetabaseBootstrap = { ], available_locales: [["en", "English"]], types: { - "type/Address": ["type/*"], - "type/Array": ["type/Collection"], - "type/AvatarURL": ["type/URL"], + "type/DruidHyperUnique": ["type/*"], + "type/Longitude": ["type/Coordinate"], + "type/IPAddress": ["type/TextLike"], + "type/URL": ["type/Text"], "type/BigInteger": ["type/Integer"], - "type/Boolean": ["type/*"], "type/Category": ["type/Special"], - "type/City": ["type/Category", "type/Address", "type/Text"], - "type/Collection": ["type/*"], - "type/Coordinate": ["type/Float"], - "type/Country": ["type/Category", "type/Address", "type/Text"], - "type/Date": ["type/DateTime"], - "type/DateTime": ["type/*"], - "type/Decimal": ["type/Float"], - "type/Description": ["type/Text"], - "type/Dictionary": ["type/Collection"], - "type/Email": ["type/Text"], - "type/FK": ["type/Special"], - "type/Float": ["type/Number"], - "type/IPAddress": ["type/TextLike"], - "type/ImageURL": ["type/URL"], + "type/Owner": ["type/User"], + "type/TextLike": ["type/*"], + "type/Discount": ["type/Number"], + "type/UNIXTimestampSeconds": ["type/UNIXTimestamp"], + "type/PostgresEnum": ["type/Text"], + "type/Time": ["type/DateTime"], "type/Integer": ["type/Number"], - "type/Latitude": ["type/Coordinate"], - "type/Longitude": ["type/Coordinate"], - "type/Name": ["type/Category", "type/Text"], + "type/Author": ["type/User"], + "type/Cost": ["type/Number"], + "type/Quantity": ["type/Integer"], "type/Number": ["type/*"], - "type/PK": ["type/Special"], - "type/SerializedJSON": ["type/Text", "type/Collection"], - "type/Special": ["type/*"], + "type/JoinTimestamp": ["type/DateTime"], + "type/Subscription": ["type/Category"], "type/State": ["type/Category", "type/Address", "type/Text"], + "type/Address": ["type/*"], + "type/Source": ["type/Category"], + "type/Name": ["type/Category", "type/Text"], + "type/Decimal": ["type/Float"], + "type/Date": ["type/DateTime"], "type/Text": ["type/*"], - "type/TextLike": ["type/*"], - "type/Time": ["type/DateTime"], - "type/UNIXTimestamp": ["type/Integer", "type/DateTime"], - "type/UNIXTimestampMilliseconds": ["type/UNIXTimestamp"], - "type/UNIXTimestampSeconds": ["type/UNIXTimestamp"], - "type/URL": ["type/Text"], + "type/FK": ["type/Special"], + "type/SerializedJSON": ["type/Text", "type/Collection"], + "type/MongoBSONID": ["type/TextLike"], + "type/Duration": ["type/Number"], + "type/Float": ["type/Number"], + "type/CreationTimestamp": ["type/DateTime"], + "type/Email": ["type/Text"], + "type/City": ["type/Category", "type/Address", "type/Text"], + "type/Title": ["type/Category", "type/Text"], + "type/Special": ["type/*"], + "type/Dictionary": ["type/Collection"], + "type/Description": ["type/Text"], + "type/Company": ["type/Category"], + "type/PK": ["type/Special"], + "type/Latitude": ["type/Coordinate"], + "type/Coordinate": ["type/Float"], "type/UUID": ["type/Text"], + "type/Country": ["type/Category", "type/Address", "type/Text"], + "type/Boolean": ["type/Category", "type/*"], + "type/GrossMargin": ["type/Number"], + "type/AvatarURL": ["type/URL"], + "type/Share": ["type/Float"], + "type/Product": ["type/Category"], + "type/ImageURL": ["type/URL"], + "type/Price": ["type/Number"], + "type/UNIXTimestampMilliseconds": ["type/UNIXTimestamp"], + "type/Collection": ["type/*"], + "type/User": ["type/*"], + "type/Array": ["type/Collection"], + "type/Income": ["type/Number"], + "type/Comment": ["type/Text"], + "type/Score": ["type/Number"], "type/ZipCode": ["type/Integer", "type/Address"], + "type/DateTime": ["type/*"], + "type/UNIXTimestamp": ["type/Integer", "type/DateTime"], + "type/Enum": ["type/Category", "type/*"], }, }; diff --git a/frontend/test/metabase-lib/Question.integ.spec.js b/frontend/test/metabase-lib/Question.integ.spec.js index 4bbb57452fd063a1f105d9cfee2b9c327d2ef7fc..f8e37bfaa85508d764bb6baa960aa2f921cfd1b1 100644 --- a/frontend/test/metabase-lib/Question.integ.spec.js +++ b/frontend/test/metabase-lib/Question.integ.spec.js @@ -46,7 +46,7 @@ describe("Question", () => { question._parameterValues = { [templateTagId]: "5" }; const results2 = await question.apiGetResults({ ignoreCache: true }); expect(results2[0]).toBeDefined(); - expect(results2[0].data.rows[0][0]).toEqual(116.35497575401975); + expect(results2[0].data.rows[0][0]).toEqual(127.88197029833711); }); it("should return correct result with an optional template tag clause", async () => { @@ -79,7 +79,7 @@ describe("Question", () => { question._parameterValues = { [templateTagId]: "5" }; const results2 = await question.apiGetResults({ ignoreCache: true }); expect(results2[0]).toBeDefined(); - expect(results2[0].data.rows[0][0]).toEqual(116.35497575401975); + expect(results2[0].data.rows[0][0]).toEqual(127.88197029833711); }); }); }); diff --git a/frontend/test/metabase-lib/queries/StructuredQuery.unit.spec.js b/frontend/test/metabase-lib/queries/StructuredQuery.unit.spec.js index f44f1434b1e19463a35d2f76c88ad9b1eb5483f8..2364743f8f839c5961344f0fb369f9445498791f 100644 --- a/frontend/test/metabase-lib/queries/StructuredQuery.unit.spec.js +++ b/frontend/test/metabase-lib/queries/StructuredQuery.unit.spec.js @@ -14,6 +14,7 @@ import { PRODUCT_TILE_FIELD_ID, } from "__support__/sample_dataset_fixture"; +import Segment from "metabase-lib/lib/metadata/Segment"; import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; function makeDatasetQuery(query) { @@ -380,6 +381,16 @@ describe("StructuredQuery unit tests", () => { pending(); }); + describe("segments", () => { + it("should list any applied segments that are currently active filters", () => { + const queryWithSegmentFilter = query.addFilter(["SEGMENT", 1]); + // expect there to be segments + expect(queryWithSegmentFilter.segments().length).toBe(1); + // and they should actually be segments + expect(queryWithSegmentFilter.segments()[0]).toBeInstanceOf(Segment); + }); + }); + describe("canAddFilter", () => { pending(); }); diff --git a/frontend/test/modes/drills/SummarizeColumnByTimeDrill.unit.spec.js b/frontend/test/modes/drills/SummarizeColumnByTimeDrill.unit.spec.js index e34dc114f51dd52cbf1da64dbc054063a34d4d45..6aed3b46d865ad5a2304141b31c145336e93feca 100644 --- a/frontend/test/modes/drills/SummarizeColumnByTimeDrill.unit.spec.js +++ b/frontend/test/modes/drills/SummarizeColumnByTimeDrill.unit.spec.js @@ -34,12 +34,7 @@ describe("SummarizeColumnByTimeDrill", () => { source_table: ORDERS_TABLE_ID, aggregation: [["sum", ["field-id", ORDERS_TOTAL_FIELD_ID]]], breakout: [ - [ - "datetime-field", - ["field-id", ORDERS_CREATED_DATE_FIELD_ID], - "as", - "day", - ], + ["datetime-field", ["field-id", ORDERS_CREATED_DATE_FIELD_ID], "day"], ], }); expect(newCard.display).toEqual("line"); diff --git a/frontend/test/modes/drills/UnderlyingRecordsDrill.unit.spec.js b/frontend/test/modes/drills/UnderlyingRecordsDrill.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..11ee419aea2a93b79a28ebb7e28d84e590387aa9 --- /dev/null +++ b/frontend/test/modes/drills/UnderlyingRecordsDrill.unit.spec.js @@ -0,0 +1,104 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ + +import { + question, + clickedMetric, + clickedDateTimeValue, + ORDERS_TABLE_ID, + ORDERS_CREATED_DATE_FIELD_ID, +} from "__support__/sample_dataset_fixture"; + +import { assocIn, chain } from "icepick"; +import moment from "moment"; + +import UnderlyingRecordsDrill from "metabase/qb/components/drill/UnderlyingRecordsDrill"; + +function getActionPropsForTimeseriesClick(unit, value) { + return { + question: question + .query() + .setQuery({ + source_table: ORDERS_TABLE_ID, + aggregation: [["count"]], + breakout: [ + [ + "datetime-field", + ["field-id", ORDERS_CREATED_DATE_FIELD_ID], + "as", + unit, + ], + ], + }) + .question(), + clicked: { + ...clickedMetric, + dimensions: [ + chain(clickedDateTimeValue) + .assocIn(["column", "unit"], unit) + .assocIn(["value"], value) + .value(), + ], + }, + }; +} + +describe("UnderlyingRecordsDrill", () => { + it("should not be valid for top level actions", () => { + expect(UnderlyingRecordsDrill({ question })).toHaveLength(0); + }); + it("should be return correct new card for breakout by month", () => { + const value = "2018-01-01T00:00:00Z"; + const actions = UnderlyingRecordsDrill( + getActionPropsForTimeseriesClick("month", value), + ); + expect(actions).toHaveLength(1); + const q = actions[0].question(); + expect(q.query().query()).toEqual({ + source_table: ORDERS_TABLE_ID, + filter: [ + "=", + [ + "datetime-field", + ["field-id", ORDERS_CREATED_DATE_FIELD_ID], + "as", + "month", + ], + value, + ], + }); + expect(q.display()).toEqual("table"); + }); + it("should be return correct new card for breakout by day-of-week", () => { + const value = 4; // corresponds to Wednesday + const actions = UnderlyingRecordsDrill( + getActionPropsForTimeseriesClick("day-of-week", value), + ); + expect(actions).toHaveLength(1); + const q = actions[0].question(); + + // check that the filter value is a Wednesday + const filterValue = q.query().query().filter[2]; + expect(moment(filterValue).format("dddd")).toEqual("Wednesday"); + + // check that the rest of the query is correct + const queryWithoutFilterValue = assocIn( + q.query().query(), + ["filter", 2], + null, + ); + expect(queryWithoutFilterValue).toEqual({ + source_table: ORDERS_TABLE_ID, + filter: [ + "=", + [ + "datetime-field", + ["field-id", ORDERS_CREATED_DATE_FIELD_ID], + "as", + "day-of-week", + ], + null, + ], + }); + expect(q.display()).toEqual("table"); + }); +}); diff --git a/frontend/test/modes/drills/ZoomDrill.unit.spec.js b/frontend/test/modes/drills/ZoomDrill.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e8686b435ee144d9f2bdeb634ecfed68b29b63ae --- /dev/null +++ b/frontend/test/modes/drills/ZoomDrill.unit.spec.js @@ -0,0 +1,71 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ + +import { + question, + clickedMetric, + clickedDateTimeValue, + ORDERS_TABLE_ID, + ORDERS_CREATED_DATE_FIELD_ID, +} from "__support__/sample_dataset_fixture"; + +import { chain } from "icepick"; + +import ZoomDrill from "metabase/qb/components/drill/ZoomDrill"; + +describe("ZoomDrill", () => { + it("should not be valid for top level actions", () => { + expect(ZoomDrill({ question })).toHaveLength(0); + }); + it("should be return correct new for month -> week", () => { + const actions = ZoomDrill({ + question: question + .query() + .setQuery({ + source_table: ORDERS_TABLE_ID, + aggregation: [["count"]], + breakout: [ + [ + "datetime-field", + ["field-id", ORDERS_CREATED_DATE_FIELD_ID], + "as", + "month", + ], + ], + }) + .question(), + clicked: { + ...clickedMetric, + dimensions: [ + chain(clickedDateTimeValue) + .assocIn(["column", "unit"], "month") + .value(), + ], + }, + }); + expect(actions).toHaveLength(1); + const newCard = actions[0].question().card(); + expect(newCard.dataset_query.query).toEqual({ + source_table: ORDERS_TABLE_ID, + aggregation: [["count"]], + filter: [ + "=", + [ + "datetime-field", + ["field-id", ORDERS_CREATED_DATE_FIELD_ID], + "as", + "month", + ], + clickedDateTimeValue.value, + ], + breakout: [ + [ + "datetime-field", + ["field-id", ORDERS_CREATED_DATE_FIELD_ID], + // "as", + "week", + ], + ], + }); + expect(newCard.display).toEqual("line"); + }); +}); diff --git a/frontend/test/modes/lib/actions.unit.spec.js b/frontend/test/modes/lib/actions.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..cbf0c0f81d5e2b05df4b7e7bd6c89d5cc59587ac --- /dev/null +++ b/frontend/test/modes/lib/actions.unit.spec.js @@ -0,0 +1,31 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ + +import { drillFilter } from "metabase/qb/lib/actions"; + +describe("actions", () => { + describe("drillFilter", () => { + it("should add the filter with the same timezone", () => { + const newCard = drillFilter( + { + dataset_query: { + type: "query", + query: {}, + }, + }, + "2018-04-27T00:00:00.000+02:00", + { + base_type: "type/DateTime", + id: 123, + unit: "day", + }, + ); + expect(newCard.dataset_query.query).toEqual({ + filter: [ + "=", + ["datetime-field", ["field-id", 123], "as", "day"], + "2018-04-27T00:00:00+02:00", + ], + }); + }); + }); +}); diff --git a/frontend/test/parameters/components/widgets/DateRangeWidget.unit.spec.js b/frontend/test/parameters/components/widgets/DateRangeWidget.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..5e253a68aa712d2b73ab78cf52bdd726e0099adb --- /dev/null +++ b/frontend/test/parameters/components/widgets/DateRangeWidget.unit.spec.js @@ -0,0 +1,22 @@ +import React from "react"; +import { mount } from "enzyme"; +import DateRangeWidget from "metabase/parameters/components/widgets/DateRangeWidget"; + +describe("DateRangeWidget", () => { + it("should allow selections spanning years", () => { + const setValue = jest.fn(); + let picker = mount( + <DateRangeWidget value={"2018-12-01~2018-12-01"} setValue={setValue} />, + ); + picker + .find(".Calendar-day.Calendar-day--this-month") + .first() + .simulate("click"); + picker.find(".Icon-chevronright").simulate("click"); + picker + .find(".Calendar-day.Calendar-day--this-month") + .first() + .simulate("click"); + expect(setValue).toHaveBeenCalledWith("2018-12-01~2019-01-01"); + }); +}); diff --git a/frontend/test/parameters/parameters.integ.spec.js b/frontend/test/parameters/parameters.integ.spec.js index fc6c91fbac86169604884f213e5f91fefae6c5b3..e2345ee3162dbbdbdf59cad22faa23bf5a90d3c7 100644 --- a/frontend/test/parameters/parameters.integ.spec.js +++ b/frontend/test/parameters/parameters.integ.spec.js @@ -1,370 +1,505 @@ -// Converted from an old Selenium E2E test +jest.mock("metabase/query_builder/components/NativeQueryEditor"); + +import { mount } from "enzyme"; + import { + createSavedQuestion, + createDashboard, + createTestStore, useSharedAdminLogin, logout, - createTestStore, - restorePreviousLogin, waitForRequestToComplete, + waitForAllRequestsToComplete, + cleanup, } from "__support__/integrated_tests"; -import { click, clickButton, setInputValue } from "__support__/enzyme_utils"; -import { mount } from "enzyme"; +import jwt from "jsonwebtoken"; -import { LOAD_CURRENT_USER } from "metabase/redux/user"; -import { - INITIALIZE_SETTINGS, - UPDATE_SETTING, - updateSetting, -} from "metabase/admin/settings/settings"; -import SettingToggle from "metabase/admin/settings/components/widgets/SettingToggle"; -import Toggle from "metabase/components/Toggle"; -import EmbeddingLegalese from "metabase/admin/settings/components/widgets/EmbeddingLegalese"; -import { - CREATE_PUBLIC_LINK, - INITIALIZE_QB, - API_CREATE_QUESTION, - QUERY_COMPLETED, - RUN_QUERY, - SET_QUERY_MODE, - setDatasetQuery, - UPDATE_EMBEDDING_PARAMS, - UPDATE_ENABLE_EMBEDDING, - UPDATE_TEMPLATE_TAG, -} from "metabase/query_builder/actions"; -import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor"; -import { delay } from "metabase/lib/promise"; -import TagEditorSidebar from "metabase/query_builder/components/template_tags/TagEditorSidebar"; -import { getQuery } from "metabase/query_builder/selectors"; -import { - ADD_PARAM_VALUES, - FETCH_TABLE_METADATA, -} from "metabase/redux/metadata"; -import RunButton from "metabase/query_builder/components/RunButton"; -import Scalar from "metabase/visualizations/visualizations/Scalar"; -import Parameters from "metabase/parameters/components/Parameters"; -import CategoryWidget from "metabase/parameters/components/widgets/CategoryWidget"; +import { FETCH_DASHBOARD } from "metabase/dashboard/dashboard"; +import { fetchTableMetadata } from "metabase/redux/metadata"; +import { getMetadata } from "metabase/selectors/metadata"; + +import ParameterWidget from "metabase/parameters/components/ParameterWidget"; +import FieldValuesWidget from "metabase/components/FieldValuesWidget"; import ParameterFieldWidget from "metabase/parameters/components/widgets/ParameterFieldWidget"; -import SaveQuestionModal from "metabase/containers/SaveQuestionModal"; -import { LOAD_COLLECTIONS } from "metabase/questions/collections"; -import SharingPane from "metabase/public/components/widgets/SharingPane"; -import { EmbedTitle } from "metabase/public/components/widgets/EmbedModalContent"; -import PreviewPane from "metabase/public/components/widgets/PreviewPane"; -import CopyWidget from "metabase/components/CopyWidget"; -import ListSearchField from "metabase/components/ListSearchField"; -import * as Urls from "metabase/lib/urls"; -import QuestionEmbedWidget from "metabase/query_builder/containers/QuestionEmbedWidget"; -import EmbedWidget from "metabase/public/components/widgets/EmbedWidget"; +import TokenField from "metabase/components/TokenField"; -async function updateQueryText(store, queryText) { - // We don't have Ace editor so we have to trigger the Redux action manually - const newDatasetQuery = getQuery(store.getState()) - .updateQueryText(queryText) - .datasetQuery(); +import * as Urls from "metabase/lib/urls"; +import Question from "metabase-lib/lib/Question"; - return store.dispatch(setDatasetQuery(newDatasetQuery)); -} +import { + CardApi, + DashboardApi, + SettingsApi, + MetabaseApi, +} from "metabase/services"; -const getRelativeUrlWithoutHash = url => - url.replace(/#.*$/, "").replace(/http:\/\/.*?\//, "/"); +const ORDER_USER_ID_FIELD_ID = 7; +const PEOPLE_ID_FIELD_ID = 13; +const PEOPLE_NAME_FIELD_ID = 16; +const PEOPLE_SOURCE_FIELD_ID = 18; -const COUNT_ALL = "200"; -const COUNT_DOOHICKEY = "51"; -const COUNT_GADGET = "47"; +const METABASE_SECRET_KEY = + "24134bd93e081773fb178e8e1abb4e8a973822f7e19c872bd92c8d5a122ef63f"; describe("parameters", () => { - beforeAll(async () => useSharedAdminLogin()); + let question, dashboard; + + beforeAll(async () => { + useSharedAdminLogin(); - describe("questions", () => { - let publicUrl = null; - let embedUrl = null; + // enable public sharing + await SettingsApi.put({ key: "enable-public-sharing", value: true }); + cleanup.fn(() => + SettingsApi.put({ key: "enable-public-sharing", value: false }), + ); - it("should allow users to enable public sharing", async () => { - const store = await createTestStore(); + await SettingsApi.put({ key: "enable-embedding", value: true }); + cleanup.fn(() => + SettingsApi.put({ key: "enable-embedding", value: false }), + ); - // load public sharing settings - store.pushPath("/admin/settings/public_sharing"); - const app = mount(store.getAppContainer()); + await SettingsApi.put({ + key: "embedding-secret-key", + value: METABASE_SECRET_KEY, + }); - await store.waitForActions([LOAD_CURRENT_USER, INITIALIZE_SETTINGS]); + await MetabaseApi.field_dimension_update({ + fieldId: ORDER_USER_ID_FIELD_ID, + type: "external", + name: "User ID", + human_readable_field_id: PEOPLE_NAME_FIELD_ID, + }); + cleanup.fn(() => + MetabaseApi.field_dimension_delete({ + fieldId: ORDER_USER_ID_FIELD_ID, + }), + ); + + // set each of these fields to have "has_field_values" = "search" + for (const fieldId of [ + ORDER_USER_ID_FIELD_ID, + PEOPLE_ID_FIELD_ID, + PEOPLE_NAME_FIELD_ID, + ]) { + const field = await MetabaseApi.field_get({ + fieldId: fieldId, + }); + await MetabaseApi.field_update({ + id: fieldId, + has_field_values: "search", + }); + cleanup.fn(() => MetabaseApi.field_update(field)); + } + + const store = await createTestStore(); + await store.dispatch(fetchTableMetadata(1)); + const metadata = getMetadata(store.getState()); + + let unsavedQuestion = Question.create({ + databaseId: 1, + metadata, + }) + .setDatasetQuery({ + type: "native", + database: 1, + native: { + query: + "SELECT COUNT(*) FROM people WHERE {{id}} AND {{name}} AND {{source}} /* AND {{user_id}} */", + template_tags: { + id: { + id: "1", + name: "id", + display_name: "ID", + type: "dimension", + dimension: ["field-id", PEOPLE_ID_FIELD_ID], + widget_type: "id", + }, + name: { + id: "2", + name: "name", + display_name: "Name", + type: "dimension", + dimension: ["field-id", PEOPLE_NAME_FIELD_ID], + widget_type: "category", + }, + source: { + id: "3", + name: "source", + display_name: "Source", + type: "dimension", + dimension: ["field-id", PEOPLE_SOURCE_FIELD_ID], + widget_type: "category", + }, + user_id: { + id: "4", + name: "user_id", + display_name: "User", + type: "dimension", + dimension: ["field-id", ORDER_USER_ID_FIELD_ID], + widget_type: "id", + }, + }, + }, + parameters: [], + }) + .setDisplay("scalar") + .setDisplayName("Test Question"); + question = await createSavedQuestion(unsavedQuestion); + cleanup.fn(() => + CardApi.update({ + id: question.id(), + archived: true, + }), + ); + + // create a dashboard + dashboard = await createDashboard({ + name: "Test Dashboard", + description: null, + parameters: [ + { name: "ID", slug: "id", id: "1", type: "id" }, + { name: "Name", slug: "name", id: "2", type: "category" }, + { name: "Source", slug: "source", id: "3", type: "category" }, + { name: "User", slug: "user_id", id: "4", type: "id" }, + ], + }); + cleanup.fn(() => + DashboardApi.update({ + id: dashboard.id, + archived: true, + }), + ); + + const dashcard = await DashboardApi.addcard({ + dashId: dashboard.id, + cardId: question.id(), + }); + await DashboardApi.reposition_cards({ + dashId: dashboard.id, + cards: [ + { + id: dashcard.id, + card_id: question.id(), + row: 0, + col: 0, + sizeX: 4, + sizeY: 4, + series: [], + visualization_settings: {}, + parameter_mappings: [ + { + parameter_id: "1", + card_id: question.id(), + target: ["dimension", ["template-tag", "id"]], + }, + { + parameter_id: "2", + card_id: question.id(), + target: ["dimension", ["template-tag", "name"]], + }, + { + parameter_id: "3", + card_id: question.id(), + target: ["dimension", ["template-tag", "source"]], + }, + { + parameter_id: "4", + card_id: question.id(), + target: ["dimension", ["template-tag", "user_id"]], + }, + ], + }, + ], + }); + }); - // // if enabled, disable it so we're in a known state - // // TODO Atte Keinänen 8/9/17: This should be done with a direct API call in afterAll instead - const enabledToggleContainer = app.find(SettingToggle).first(); + describe("private questions", () => { + let app, store; + it("should be possible to view a private question", async () => { + useSharedAdminLogin(); - expect(enabledToggleContainer.text()).toBe("Disabled"); + store = await createTestStore(); + store.pushPath(Urls.question(question.id()) + "?id=1"); + app = mount(store.getAppContainer()); - // toggle it on - click(enabledToggleContainer.find(Toggle)); - await store.waitForActions([UPDATE_SETTING]); + await waitForRequestToComplete("GET", /^\/api\/card\/\d+/); + expect(app.find(".Header-title-name").text()).toEqual("Test Question"); - // make sure it's enabled - expect(enabledToggleContainer.text()).toBe("Enabled"); + // wait for the query to load + await waitForRequestToComplete("POST", /^\/api\/card\/\d+\/query/); }); + sharedParametersTests(() => ({ app, store })); + }); - it("should allow users to enable embedding", async () => { - const store = await createTestStore(); + describe("public questions", () => { + let app, store; + it("should be possible to view a public question", async () => { + useSharedAdminLogin(); + const publicQuestion = await CardApi.createPublicLink({ + id: question.id(), + }); - // load public sharing settings - store.pushPath("/admin/settings/embedding_in_other_applications"); - const app = mount(store.getAppContainer()); + logout(); - await store.waitForActions([LOAD_CURRENT_USER, INITIALIZE_SETTINGS]); + store = await createTestStore({ publicApp: true }); + store.pushPath(Urls.publicQuestion(publicQuestion.uuid) + "?id=1"); + app = mount(store.getAppContainer()); - click(app.find(EmbeddingLegalese).find('button[children="Enable"]')); - await store.waitForActions([UPDATE_SETTING]); + await waitForRequestToComplete("GET", /^\/api\/[^\/]*\/card/); + expect(app.find(".EmbedFrame-header .h4").text()).toEqual( + "Test Question", + ); - expect(app.find(EmbeddingLegalese).length).toBe(0); - const enabledToggleContainer = app.find(SettingToggle).first(); - expect(enabledToggleContainer.text()).toBe("Enabled"); + // wait for the query to load + await waitForRequestToComplete( + "GET", + /^\/api\/public\/card\/[^\/]+\/query/, + ); }); + sharedParametersTests(() => ({ app, store })); + }); - // Note: Test suite is sequential, so individual test cases can't be run individually - it("should allow users to create parameterized SQL questions", async () => { - // Don't render Ace editor in tests because it uses many DOM methods that aren't supported by jsdom - // NOTE Atte Keinänen 8/9/17: Ace provides a MockRenderer class which could be used for pseudo-rendering and - // testing Ace editor in tests, but it doesn't render stuff to DOM so I'm not sure how practical it would be - NativeQueryEditor.prototype.loadAceEditor = () => {}; + describe("embed questions", () => { + let app, store; + it("should be possible to view a embedded question", async () => { + useSharedAdminLogin(); + await CardApi.update({ + id: question.id(), + embedding_params: { + id: "enabled", + name: "enabled", + source: "enabled", + user_id: "enabled", + }, + enable_embedding: true, + }); - const store = await createTestStore(); + logout(); - // load public sharing settings - store.pushPath(Urls.plainQuestion()); - const app = mount(store.getAppContainer()); - await store.waitForActions([INITIALIZE_QB]); + const token = jwt.sign( + { + resource: { question: question.id() }, + params: {}, + }, + METABASE_SECRET_KEY, + ); - click(app.find(".Icon-sql")); - await store.waitForActions([SET_QUERY_MODE]); + store = await createTestStore({ embedApp: true }); + store.pushPath(Urls.embedCard(token) + "?id=1"); + app = mount(store.getAppContainer()); - await updateQueryText( - store, - "select count(*) from products where {{category}}", - ); + await waitForRequestToComplete("GET", /\/card\/[^\/]+/); - const tagEditorSidebar = app.find(TagEditorSidebar); - - const fieldFilterVarType = tagEditorSidebar - .find(".ColumnarSelector-row") - .at(3); - expect(fieldFilterVarType.text()).toBe("Field Filter"); - click(fieldFilterVarType); - - // there's an async error here for some reason - await store.waitForActions([UPDATE_TEMPLATE_TAG]); - - await delay(500); - - const productsRow = tagEditorSidebar - .find(".TestPopoverBody .List-section") - .at(4) - .find("a"); - expect(productsRow.text()).toBe("Products"); - click(productsRow); - - // Table fields should be loaded on-the-fly before showing the field selector - await store.waitForActions(FETCH_TABLE_METADATA); - // Needed due to state update after fetching metadata - await delay(100); - - const searchField = tagEditorSidebar - .find(".TestPopoverBody") - .find(ListSearchField) - .find("input") - .first(); - setInputValue(searchField, "cat"); - - const categoryRow = tagEditorSidebar - .find(".TestPopoverBody .List-section") - .at(2) - .find("a"); - expect(categoryRow.text()).toBe("Category"); - click(categoryRow); - - await store.waitForActions([UPDATE_TEMPLATE_TAG]); - - // close the template variable sidebar - click(tagEditorSidebar.find(".Icon-close")); - - // test without the parameter - click(app.find(RunButton)); - await store.waitForActions([RUN_QUERY, QUERY_COMPLETED]); - expect(app.find(Scalar).text()).toBe(COUNT_ALL); - - // test the parameter - const parameter = app.find(ParameterFieldWidget).first(); - click(parameter.find("div").first()); - click(parameter.find('span[children="Doohickey"]')); - clickButton(parameter.find(".Button")); - click(app.find(RunButton)); - await store.waitForActions([RUN_QUERY, QUERY_COMPLETED]); - expect(app.find(Scalar).text()).toBe(COUNT_DOOHICKEY); - - // save the question, required for public link/embedding - click( - app - .find(".Header-buttonSection a") - .first() - .find("a"), + expect(app.find(".EmbedFrame-header .h4").text()).toEqual( + "Test Question", ); - await store.waitForActions([LOAD_COLLECTIONS]); - setInputValue( - app.find(SaveQuestionModal).find("input[name='name']"), - "sql parametrized", + // wait for the query to load + await waitForRequestToComplete( + "GET", + /^\/api\/embed\/card\/[^\/]+\/query/, ); + }); + sharedParametersTests(() => ({ app, store })); + }); - clickButton( - app - .find(SaveQuestionModal) - .find("button") - .last(), - ); - await store.waitForActions([API_CREATE_QUESTION]); - await delay(100); - - click(app.find('#QuestionSavedModal .Button[children="Not now"]')); - // wait for modal to close :'( - await delay(500); - - // open sharing panel - click(app.find(QuestionEmbedWidget).find(EmbedWidget)); - - // "Embed this question in an application" - click( - app - .find(SharingPane) - .find("h3") - .last(), - ); + describe("private dashboards", () => { + let app, store; + it("should be possible to view a private dashboard", async () => { + useSharedAdminLogin(); - // make the parameter editable - click(app.find(".AdminSelect-content[children='Disabled']")); + store = await createTestStore(); + store.pushPath(Urls.dashboard(dashboard.id) + "?id=1"); + app = mount(store.getAppContainer()); - click(app.find(".TestPopoverBody .Icon-pencil")); + await store.waitForActions([FETCH_DASHBOARD]); + expect(app.find(".DashboardHeader .Entity h2").text()).toEqual( + "Test Dashboard", + ); - await delay(500); + // wait for the query to load + await waitForRequestToComplete("POST", /^\/api\/card\/[^\/]+\/query/); - click(app.find("div[children='Publish']")); - await store.waitForActions([ - UPDATE_ENABLE_EMBEDDING, - UPDATE_EMBEDDING_PARAMS, - ]); + // wait for required field metadata to load + await waitForRequestToComplete("GET", /^\/api\/field\/[^\/]+/); + }); + sharedParametersTests(() => ({ app, store })); + }); - // save the embed url for next tests - embedUrl = getRelativeUrlWithoutHash( - app - .find(PreviewPane) - .find("iframe") - .prop("src"), - ); + describe("public dashboards", () => { + let app, store; + it("should be possible to view a public dashboard", async () => { + useSharedAdminLogin(); + const publicDash = await DashboardApi.createPublicLink({ + id: dashboard.id, + }); + + logout(); - // back to main share panel - click(app.find(EmbedTitle)); + store = await createTestStore({ publicApp: true }); + store.pushPath(Urls.publicDashboard(publicDash.uuid) + "?id=1"); + app = mount(store.getAppContainer()); - // toggle public link on - click(app.find(SharingPane).find(Toggle)); - await store.waitForActions([CREATE_PUBLIC_LINK]); + await store.waitForActions([FETCH_DASHBOARD]); + expect(app.find(".EmbedFrame-header .h4").text()).toEqual( + "Test Dashboard", + ); - // save the public url for next tests - publicUrl = getRelativeUrlWithoutHash( - app - .find(CopyWidget) - .find("input") - .first() - .prop("value"), + // wait for the query to load + await waitForRequestToComplete( + "GET", + /^\/api\/public\/dashboard\/[^\/]+\/card\/[^\/]+/, ); }); + sharedParametersTests(() => ({ app, store })); + }); - describe("as an anonymous user", () => { - beforeAll(() => logout()); - - async function runSharedQuestionTests(store, questionUrl, apiRegex) { - store.pushPath(questionUrl); - const app = mount(store.getAppContainer()); - - await store.waitForActions([ADD_PARAM_VALUES]); - - // Loading the query results is done in PublicQuestion itself so we have to listen to API request instead of Redux action - await waitForRequestToComplete("GET", apiRegex); - // use `update()` because of setState - expect( - app - .update() - .find(Scalar) - .text(), - ).toBe(COUNT_ALL + "sql parametrized"); - - // manually click parameter (sadly the query results loading happens inline again) - click( - app - .find(Parameters) - .find("a") - .first(), - ); - click(app.find(CategoryWidget).find('li h4[children="Doohickey"]')); - clickButton(app.find(CategoryWidget).find(".Button")); - - await waitForRequestToComplete("GET", apiRegex); - expect( - app - .update() - .find(Scalar) - .text(), - ).toBe(COUNT_DOOHICKEY + "sql parametrized"); - - // set parameter via url - store.pushPath("/"); // simulate a page reload by visiting other page - store.pushPath(questionUrl + "?category=Gadget"); - await waitForRequestToComplete("GET", apiRegex); - // use `update()` because of setState - expect( - app - .update() - .find(Scalar) - .text(), - ).toBe(COUNT_GADGET + "sql parametrized"); - } - - it("should allow seeing an embedded question", async () => { - if (!embedUrl) - throw new Error( - "This test fails because previous tests didn't produce an embed url.", - ); - const embedUrlTestStore = await createTestStore({ embedApp: true }); - await runSharedQuestionTests( - embedUrlTestStore, - embedUrl, - new RegExp("/api/embed/card/.*/query"), - ); + describe("embed dashboards", () => { + let app, store; + it("should be possible to view a embed dashboard", async () => { + useSharedAdminLogin(); + await DashboardApi.update({ + id: dashboard.id, + embedding_params: { + id: "enabled", + name: "enabled", + source: "enabled", + user_id: "enabled", + }, + enable_embedding: true, }); - it("should allow seeing a public question", async () => { - if (!publicUrl) - throw new Error( - "This test fails because previous tests didn't produce a public url.", - ); - const publicUrlTestStore = await createTestStore({ publicApp: true }); - await runSharedQuestionTests( - publicUrlTestStore, - publicUrl, - new RegExp("/api/public/card/.*/query"), - ); - }); + logout(); - // I think it's cleanest to restore the login here so that there are no surprises if you want to add tests - // that expect that we're already logged in - afterAll(() => restorePreviousLogin()); - }); + const token = jwt.sign( + { + resource: { dashboard: dashboard.id }, + params: {}, + }, + METABASE_SECRET_KEY, + ); + + store = await createTestStore({ embedApp: true }); + store.pushPath(Urls.embedDashboard(token) + "?id=1"); + app = mount(store.getAppContainer()); - afterAll(async () => { - const store = await createTestStore(); + await store.waitForActions([FETCH_DASHBOARD]); - // Disable public sharing and embedding after running tests - await store.dispatch( - updateSetting({ key: "enable-public-sharing", value: false }), + expect(app.find(".EmbedFrame-header .h4").text()).toEqual( + "Test Dashboard", ); - await store.dispatch( - updateSetting({ key: "enable-embedding", value: false }), + + // wait for the query to load + await waitForRequestToComplete( + "GET", + /^\/api\/embed\/dashboard\/[^\/]+\/dashcard\/\d+\/card\/\d+/, ); }); + sharedParametersTests(() => ({ app, store })); }); + + afterAll(cleanup); }); + +async function sharedParametersTests(getAppAndStore) { + let app; + beforeEach(() => { + const info = getAppAndStore(); + app = info.app; + }); + + it("should have 4 ParameterFieldWidgets", async () => { + await waitForAllRequestsToComplete(); + + expect(app.find(ParameterWidget).length).toEqual(4); + expect(app.find(ParameterFieldWidget).length).toEqual(4); + }); + + it("open 4 FieldValuesWidgets", async () => { + // click each parameter to open the widget + app.find(ParameterFieldWidget).map(widget => widget.simulate("click")); + + const widgets = app.find(FieldValuesWidget); + expect(widgets.length).toEqual(4); + }); + + // it("should have the correct field and searchField", () => { + // const widgets = app.find(FieldValuesWidget); + // expect( + // widgets.map(widget => { + // const { field, searchField } = widget.props(); + // return [field && field.id, searchField && searchField.id]; + // }), + // ).toEqual([ + // [PEOPLE_ID_FIELD_ID, PEOPLE_NAME_FIELD_ID], + // [PEOPLE_NAME_FIELD_ID, PEOPLE_NAME_FIELD_ID], + // [PEOPLE_SOURCE_FIELD_ID, PEOPLE_SOURCE_FIELD_ID], + // [ORDER_USER_ID_FIELD_ID, PEOPLE_NAME_FIELD_ID], + // ]); + // }); + + it("should have the correct values", () => { + const widgets = app.find(FieldValuesWidget); + const values = widgets.map( + widget => + widget + .find("ul") // first ul is options + .at(0) + .find("li") + .map(li => li.text()) + .slice(0, -1), // the last item is the input, remove it + ); + expect(values).toEqual([ + ["Hudson Borer - 1"], // remapped value + [], + [], + [], + ]); + }); + + it("should have the correct placeholders", () => { + const widgets = app.find(FieldValuesWidget); + const placeholders = widgets.map( + widget => widget.find(TokenField).props().placeholder, + ); + expect(placeholders).toEqual([ + "Search by Name or enter an ID", + "Search by Name", + "Search the list", + "Search by Name or enter an ID", + ]); + }); + + it("should allow searching PEOPLE.ID by PEOPLE.NAME", async () => { + const widget = app.find(FieldValuesWidget).at(0); + // tests `search` endpoint + expect(widget.find("li").length).toEqual(1 + 1); + widget.find("input").simulate("change", { target: { value: "Aly" } }); + await waitForRequestToComplete("GET", /\/field\/.*\/search/); + expect(widget.find("li").length).toEqual(1 + 1 + 4); + }); + it("should allow searching PEOPLE.NAME by PEOPLE.NAME", async () => { + const widget = app.find(FieldValuesWidget).at(1); + // tests `search` endpoint + expect(widget.find("li").length).toEqual(1); + widget.find("input").simulate("change", { target: { value: "Aly" } }); + await waitForRequestToComplete("GET", /\/field\/.*\/search/); + expect(widget.find("li").length).toEqual(1 + 4); + }); + it("should show values for PEOPLE.SOURCE", async () => { + const widget = app.find(FieldValuesWidget).at(2); + // tests `values` endpoint + // NOTE: no need for waitForRequestToComplete because it was previously loaded? + // await waitForRequestToComplete("GET", /\/field\/.*\/values/); + expect(widget.find("li").length).toEqual(1 + 5); // 5 options + 1 for the input + }); + it("should allow searching ORDER.USER_ID by PEOPLE.NAME", async () => { + const widget = app.find(FieldValuesWidget).at(3); + // tests `search` endpoint + expect(widget.find("li").length).toEqual(1); + widget.find("input").simulate("change", { target: { value: "Aly" } }); + await waitForRequestToComplete("GET", /\/field\/.*\/search/); + expect(widget.find("li").length).toEqual(1 + 4); + }); +} diff --git a/frontend/test/public/public.integ.spec.js b/frontend/test/public/public.integ.spec.js index 576a74606795ed647c1d03e6b7693ae0c1f165aa..0ceab0944474b2c95427cb6312db0401eff375a2 100644 --- a/frontend/test/public/public.integ.spec.js +++ b/frontend/test/public/public.integ.spec.js @@ -1,62 +1,615 @@ -import { mount } from "enzyme"; +jest.mock("metabase/components/ExplicitSize"); +// Converted from an old Selenium E2E test import { - createDashboard, - createTestStore, useSharedAdminLogin, + logout, + createTestStore, + createDashboard, + restorePreviousLogin, + waitForRequestToComplete, + eventually, } from "__support__/integrated_tests"; -import { FETCH_DASHBOARD } from "metabase/dashboard/dashboard"; +import _ from "underscore"; +import jwt from "jsonwebtoken"; + +import { click, clickButton, setInputValue } from "__support__/enzyme_utils"; + +import { mount } from "enzyme"; +import { LOAD_CURRENT_USER } from "metabase/redux/user"; +import { + INITIALIZE_SETTINGS, + UPDATE_SETTING, + updateSetting, +} from "metabase/admin/settings/settings"; +import SettingToggle from "metabase/admin/settings/components/widgets/SettingToggle"; +import Toggle from "metabase/components/Toggle"; +import EmbeddingLegalese from "metabase/admin/settings/components/widgets/EmbeddingLegalese"; +import { + CREATE_PUBLIC_LINK, + INITIALIZE_QB, + API_CREATE_QUESTION, + QUERY_COMPLETED, + RUN_QUERY, + SET_QUERY_MODE, + setDatasetQuery, + UPDATE_EMBEDDING_PARAMS, + UPDATE_ENABLE_EMBEDDING, + UPDATE_TEMPLATE_TAG, +} from "metabase/query_builder/actions"; +import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor"; +import { delay } from "metabase/lib/promise"; +import TagEditorSidebar from "metabase/query_builder/components/template_tags/TagEditorSidebar"; +import { getQuery } from "metabase/query_builder/selectors"; +import { + ADD_PARAM_VALUES, + FETCH_TABLE_METADATA, +} from "metabase/redux/metadata"; +import { + FETCH_DASHBOARD_CARD_DATA, + FETCH_CARD_DATA, +} from "metabase/dashboard/dashboard"; +import RunButton from "metabase/query_builder/components/RunButton"; +import Scalar from "metabase/visualizations/visualizations/Scalar"; +import ParameterFieldWidget from "metabase/parameters/components/widgets/ParameterFieldWidget"; +import TextWidget from "metabase/parameters/components/widgets/TextWidget.jsx"; +import SaveQuestionModal from "metabase/containers/SaveQuestionModal"; +import { LOAD_COLLECTIONS } from "metabase/questions/collections"; +import SharingPane from "metabase/public/components/widgets/SharingPane"; +import { EmbedTitle } from "metabase/public/components/widgets/EmbedModalContent"; +import PreviewPane from "metabase/public/components/widgets/PreviewPane"; +import CopyWidget from "metabase/components/CopyWidget"; +import ListSearchField from "metabase/components/ListSearchField"; import * as Urls from "metabase/lib/urls"; +import QuestionEmbedWidget from "metabase/query_builder/containers/QuestionEmbedWidget"; +import EmbedWidget from "metabase/public/components/widgets/EmbedWidget"; -import { DashboardApi, SettingsApi } from "metabase/services"; +import { CardApi, DashboardApi, SettingsApi } from "metabase/services"; -describe("public pages", () => { - beforeAll(async () => { - // needed to create the public dash - useSharedAdminLogin(); - }); +const PEOPLE_TABLE_ID = 2; +const PEOPLE_ID_FIELD_ID = 13; - describe("public dashboards", () => { - let dashboard, store, publicDash; +async function updateQueryText(store, queryText) { + // We don't have Ace editor so we have to trigger the Redux action manually + const newDatasetQuery = getQuery(store.getState()) + .updateQueryText(queryText) + .datasetQuery(); - beforeAll(async () => { - store = await createTestStore(); + return store.dispatch(setDatasetQuery(newDatasetQuery)); +} - // enable public sharing - await SettingsApi.put({ key: "enable-public-sharing", value: true }); +const getRelativeUrlWithoutHash = url => + url.replace(/#.*$/, "").replace(/http:\/\/.*?\//, "/"); - // create a dashboard - dashboard = await createDashboard({ - name: "Test public dash", - description: "A dashboard for testing public things", - }); +const COUNT_ALL = "200"; +const COUNT_DOOHICKEY = "42"; +const COUNT_GADGET = "53"; + +describe("public/embedded", () => { + beforeAll(async () => useSharedAdminLogin()); - // create the public link for that dashboard - publicDash = await DashboardApi.createPublicLink({ id: dashboard.id }); + describe("questions", () => { + let publicUrl = null; + let embedUrl = null; + + it("should allow users to enable public sharing", async () => { + const store = await createTestStore(); + + // load public sharing settings + store.pushPath("/admin/settings/public_sharing"); + const app = mount(store.getAppContainer()); + + await store.waitForActions([LOAD_CURRENT_USER, INITIALIZE_SETTINGS]); + + // // if enabled, disable it so we're in a known state + // // TODO Atte Keinänen 8/9/17: This should be done with a direct API call in afterAll instead + const enabledToggleContainer = app.find(SettingToggle).first(); + + expect(enabledToggleContainer.text()).toBe("Disabled"); + + // toggle it on + click(enabledToggleContainer.find(Toggle)); + await store.waitForActions([UPDATE_SETTING]); + + // make sure it's enabled + expect(enabledToggleContainer.text()).toBe("Enabled"); }); - it("should be possible to view a public dashboard", async () => { - store.pushPath(Urls.publicDashboard(publicDash.uuid)); + it("should allow users to enable embedding", async () => { + const store = await createTestStore(); + // load public sharing settings + store.pushPath("/admin/settings/embedding_in_other_applications"); const app = mount(store.getAppContainer()); - await store.waitForActions([FETCH_DASHBOARD]); + await store.waitForActions([LOAD_CURRENT_USER, INITIALIZE_SETTINGS]); + + click(app.find(EmbeddingLegalese).find('button[children="Enable"]')); + await store.waitForActions([UPDATE_SETTING]); + + expect(app.find(EmbeddingLegalese).length).toBe(0); + const enabledToggleContainer = app.find(SettingToggle).first(); + expect(enabledToggleContainer.text()).toBe("Enabled"); + }); + + // Note: Test suite is sequential, so individual test cases can't be run individually + it("should allow users to create parameterized SQL questions", async () => { + // Don't render Ace editor in tests because it uses many DOM methods that aren't supported by jsdom + // NOTE Atte Keinänen 8/9/17: Ace provides a MockRenderer class which could be used for pseudo-rendering and + // testing Ace editor in tests, but it doesn't render stuff to DOM so I'm not sure how practical it would be + NativeQueryEditor.prototype.loadAceEditor = () => {}; + + const store = await createTestStore(); + + // load public sharing settings + store.pushPath(Urls.plainQuestion()); + const app = mount(store.getAppContainer()); + await store.waitForActions([INITIALIZE_QB]); + + click(app.find(".Icon-sql")); + await store.waitForActions([SET_QUERY_MODE]); + + await updateQueryText( + store, + "select count(*) from products where {{category}}", + ); + + const tagEditorSidebar = app.find(TagEditorSidebar); + + const fieldFilterVarType = tagEditorSidebar + .find(".ColumnarSelector-row") + .at(3); + expect(fieldFilterVarType.text()).toBe("Field Filter"); + click(fieldFilterVarType); + + // there's an async error here for some reason + await store.waitForActions([UPDATE_TEMPLATE_TAG]); + + await delay(500); + + const productsRow = tagEditorSidebar + .find(".TestPopoverBody .List-section") + .at(4) + .find("a"); + expect(productsRow.text()).toBe("Products"); + click(productsRow); + + // Table fields should be loaded on-the-fly before showing the field selector + await store.waitForActions(FETCH_TABLE_METADATA); + // Needed due to state update after fetching metadata + await delay(100); + + const searchField = tagEditorSidebar + .find(".TestPopoverBody") + .find(ListSearchField) + .find("input") + .first(); + setInputValue(searchField, "cat"); + + const categoryRow = tagEditorSidebar + .find(".TestPopoverBody .List-section") + .at(2) + .find("a"); + expect(categoryRow.text()).toBe("Category"); + click(categoryRow); + + await store.waitForActions([UPDATE_TEMPLATE_TAG]); + + // close the template variable sidebar + click(tagEditorSidebar.find(".Icon-close")); + + // test without the parameter + click(app.find(RunButton)); + await store.waitForActions([RUN_QUERY, QUERY_COMPLETED]); + expect(app.find(Scalar).text()).toBe(COUNT_ALL); + + // test the parameter + const parameter = app.find(ParameterFieldWidget).first(); + click(parameter.find("div").first()); + click(parameter.find('span[children="Doohickey"]')); + clickButton(parameter.find(".Button")); + click(app.find(RunButton)); + await store.waitForActions([RUN_QUERY, QUERY_COMPLETED]); + expect(app.find(Scalar).text()).toBe(COUNT_DOOHICKEY); + + // save the question, required for public link/embedding + click( + app + .find(".Header-buttonSection a") + .first() + .find("a"), + ); + await store.waitForActions([LOAD_COLLECTIONS]); + + setInputValue( + app.find(SaveQuestionModal).find("input[name='name']"), + "sql parametrized", + ); + + clickButton( + app + .find(SaveQuestionModal) + .find("button") + .last(), + ); + await store.waitForActions([API_CREATE_QUESTION]); + await delay(100); + + click(app.find('#QuestionSavedModal .Button[children="Not now"]')); + // wait for modal to close :'( + await delay(500); + + // open sharing panel + click(app.find(QuestionEmbedWidget).find(EmbedWidget)); + + // "Embed this question in an application" + click( + app + .find(SharingPane) + .find("h3") + .last(), + ); + + // make the parameter editable + click(app.find(".AdminSelect-content[children='Disabled']")); + + click(app.find(".TestPopoverBody .Icon-pencil")); + + await delay(500); + + click(app.find("div[children='Publish']")); + await store.waitForActions([ + UPDATE_ENABLE_EMBEDDING, + UPDATE_EMBEDDING_PARAMS, + ]); + + // save the embed url for next tests + embedUrl = getRelativeUrlWithoutHash( + app + .find(PreviewPane) + .find("iframe") + .prop("src"), + ); + + // back to main share panel + click(app.find(EmbedTitle)); + + // toggle public link on + click(app.find(SharingPane).find(Toggle)); + await store.waitForActions([CREATE_PUBLIC_LINK]); - const headerText = app.find(".EmbedFrame-header .h4").text(); + // save the public url for next tests + publicUrl = getRelativeUrlWithoutHash( + app + .find(CopyWidget) + .find("input") + .first() + .prop("value"), + ); + }); + + describe("as an anonymous user", () => { + beforeAll(() => logout()); + + async function runSharedQuestionTests(store, questionUrl, apiRegex) { + store.pushPath(questionUrl); + const app = mount(store.getAppContainer()); + + await store.waitForActions([ADD_PARAM_VALUES]); - expect(headerText).toEqual("Test public dash"); + // Loading the query results is done in PublicQuestion itself so we have to listen to API request instead of Redux action + await waitForRequestToComplete("GET", apiRegex); + // use `update()` because of setState + expect( + app + .update() + .find(Scalar) + .text(), + ).toBe(COUNT_ALL + "sql parametrized"); + + // NOTE: parameters tests moved to parameters.integ.spec.js + + // set parameter via url + store.pushPath("/"); // simulate a page reload by visiting other page + store.pushPath(questionUrl + "?category=Gadget"); + await waitForRequestToComplete("GET", apiRegex); + // use `update()` because of setState + await eventually(() => + expect( + app + .update() + .find(Scalar) + .text(), + ).toBe(COUNT_GADGET + "sql parametrized"), + ); + } + + it("should allow seeing an embedded question", async () => { + if (!embedUrl) + throw new Error( + "This test fails because previous tests didn't produce an embed url.", + ); + const embedUrlTestStore = await createTestStore({ embedApp: true }); + await runSharedQuestionTests( + embedUrlTestStore, + embedUrl, + new RegExp("/api/embed/card/.*/query"), + ); + }); + + it("should allow seeing a public question", async () => { + if (!publicUrl) + throw new Error( + "This test fails because previous tests didn't produce a public url.", + ); + const publicUrlTestStore = await createTestStore({ publicApp: true }); + await runSharedQuestionTests( + publicUrlTestStore, + publicUrl, + new RegExp("/api/public/card/.*/query"), + ); + }); + + // I think it's cleanest to restore the login here so that there are no surprises if you want to add tests + // that expect that we're already logged in + afterAll(() => restorePreviousLogin()); }); + }); + + describe("dashboards", () => { + let publicDashUrl = null; + let embedDashUrl = null; + let dashboardId = null; + let sqlCardId = null; + let mbqlCardId = null; + + it("should allow creating a public/embedded Dashboard with parameters", async () => { + // create a Dashboard + const dashboard = await createDashboard({ + name: "Test Dashboard", + parameters: [ + { name: "Num", slug: "num", id: "537e37b4", type: "category" }, + { + name: "People ID", + slug: "people_id", + id: "22486e00", + type: "people_id", + }, + ], + }); + dashboardId = dashboard.id; - afterAll(async () => { - // archive the dash so we don't impact other tests + // create the 2 Cards we will need + const sqlCard = await CardApi.create({ + name: "SQL Card", + display: "scalar", + visualization_settings: {}, + dataset_query: { + database: 1, + type: "native", + native: { + query: "SELECT {{num}} AS num", + template_tags: { + num: { + name: "num", + display_name: "Num", + type: "number", + required: true, + default: 1, + }, + }, + }, + }, + }); + sqlCardId = sqlCard.id; + + const mbqlCard = await CardApi.create({ + name: "MBQL Card", + display: "scalar", + visualization_settings: {}, + dataset_query: { + database: 1, + type: "query", + query: { + source_table: PEOPLE_TABLE_ID, + aggregation: ["count"], + }, + }, + }); + mbqlCardId = mbqlCard.id; + + // add the two Cards to the Dashboard + const sqlDashcard = await DashboardApi.addcard({ + dashId: dashboard.id, + cardId: sqlCard.id, + }); + const mbqlDashcard = await DashboardApi.addcard({ + dashId: dashboard.id, + cardId: mbqlCard.id, + }); + + // wire up the params for the Cards + await DashboardApi.reposition_cards({ + dashId: dashboard.id, + cards: [ + { + id: sqlDashcard.id, + card_id: sqlCard.id, + row: 0, + col: 0, + sizeX: 4, + sizeY: 4, + series: [], + visualization_settings: {}, + parameter_mappings: [ + { + card_id: sqlCard.id, + target: ["variable", ["template-tag", "num"]], + parameter_id: "537e37b4", + }, + ], + }, + { + id: mbqlDashcard.id, + card_id: mbqlCard.id, + row: 0, + col: 4, + sizeX: 4, + sizeY: 4, + series: [], + visualization_settings: {}, + parameter_mappings: [ + { + card_id: mbqlCard.id, + target: ["dimension", ["field-id", PEOPLE_ID_FIELD_ID]], + parameter_id: "22486e00", + }, + ], + }, + ], + }); + + // make the Dashboard public + save the URL + const publicDash = await DashboardApi.createPublicLink({ + id: dashboard.id, + }); + publicDashUrl = getRelativeUrlWithoutHash( + Urls.publicDashboard(publicDash.uuid), + ); + + // make the Dashboard embeddable + make params editable + save the URL await DashboardApi.update({ id: dashboard.id, + embedding_params: { + num: "enabled", + people_id: "enabled", + }, + enable_embedding: true, + }); + + const settings = await SettingsApi.list(); + const secretKey = _.findWhere(settings, { key: "embedding-secret-key" }) + .value; + + const token = jwt.sign( + { + resource: { + dashboard: dashboard.id, + }, + params: {}, + }, + secretKey, + ); + embedDashUrl = Urls.embedDashboard(token); + }); + + describe("as an anonymous user", () => { + beforeAll(() => logout()); + + async function runSharedDashboardTests(store, dashUrl) { + store.pushPath(dashUrl); + + const app = mount(store.getAppContainer()); + + const getValueOfCard = index => + app + .update() + .find(Scalar) + .find(".ScalarValue") + .at(index) + .text(); + + const getValueOfSqlCard = () => getValueOfCard(0); + const getValueOfMbqlCard = () => getValueOfCard(1); + + const waitForDashToReload = async () => { + // TODO - not sure what the correct way to wait for the cards to reload is + await store.waitForActions([ + FETCH_DASHBOARD_CARD_DATA, + FETCH_CARD_DATA, + ]); + await delay(500); + }; + + await waitForDashToReload(); + + // check that initial value of SQL Card is 1 + await eventually(() => expect(getValueOfSqlCard()).toBe("1")); + + // check that initial value of People Count MBQL Card is 2500 (or whatever people.count is supposed to be) + await eventually(() => expect(getValueOfMbqlCard()).toBe("2,500")); + + // now set the SQL param to '50' & wait for Dashboard to reload. check that value of SQL Card is updated + app + .update() + .find(TextWidget) + .first() + .props() + .setValue("50"); + await waitForDashToReload(); + await eventually(() => expect(getValueOfSqlCard()).toBe("50")); + + // now set our MBQL param' & wait for Dashboard to reload. check that value of the MBQL Card is updated + app + .update() + .find(ParameterFieldWidget) + .first() + .props() + .setValue("40"); + await waitForDashToReload(); + await eventually(() => expect(getValueOfMbqlCard()).toBe("1")); + } + + it("should handle parameters in public Dashboards correctly", async () => { + if (!publicDashUrl) + throw new Error( + "This test fails because test setup code didn't produce a public Dashboard URL.", + ); + + const publicUrlTestStore = await createTestStore({ publicApp: true }); + await runSharedDashboardTests(publicUrlTestStore, publicDashUrl); + }); + + it("should handle parameters in embedded Dashboards correctly", async () => { + if (!embedDashUrl) + throw new Error( + "This test fails because test setup code didn't produce a embedded Dashboard URL.", + ); + + const embedUrlTestStore = await createTestStore({ embedApp: true }); + await runSharedDashboardTests(embedUrlTestStore, embedDashUrl); + }); + afterAll(restorePreviousLogin); + }); + + afterAll(() => { + // delete the Dashboard & Cards we created + DashboardApi.update({ + id: dashboardId, + archived: true, + }); + CardApi.update({ + id: sqlCardId, + archived: true, + }); + CardApi.update({ + id: mbqlCardId, archived: true, }); - // do some cleanup so that we don't impact other tests - await SettingsApi.put({ key: "enable-public-sharing", value: false }); }); }); + + afterAll(async () => { + const store = await createTestStore(); + + // Disable public sharing and embedding after running tests + await store.dispatch( + updateSetting({ key: "enable-public-sharing", value: false }), + ); + await store.dispatch( + updateSetting({ key: "enable-embedding", value: false }), + ); + }); }); diff --git a/frontend/test/pulse/pulse.integ.spec.js b/frontend/test/pulse/pulse.integ.spec.js index b6255cf420b49c45a4d03792c54acae85e0d803a..9258d367a856a1670b6c2d0d2808a57c285b8d6c 100644 --- a/frontend/test/pulse/pulse.integ.spec.js +++ b/frontend/test/pulse/pulse.integ.spec.js @@ -131,10 +131,10 @@ describe("Pulse", () => { expect(previews.length).toBe(2); // NOTE: check text content since enzyme doesn't doesn't seem to work well with dangerouslySetInnerHTML - expect(previews.at(0).text()).toBe("count12,805"); + expect(previews.at(0).text()).toBe("count18,760"); expect(previews.at(0).find(".Icon-attachment").length).toBe(1); - expect(previews.at(1).text()).toBe( - "tableThis question will be added as a file attachment", + expect(previews.at(1).text()).toEqual( + expect.stringContaining("Showing 20 of 18,760 rows"), ); expect(previews.at(1).find(".Icon-attachment").length).toBe(0); @@ -142,10 +142,10 @@ describe("Pulse", () => { click(app.find(Toggle).first()); previews = app.find(PulseCardPreview); - expect(previews.at(0).text()).toBe("count12,805"); + expect(previews.at(0).text()).toBe("count18,760"); expect(previews.at(0).find(".Icon-attachment").length).toBe(0); - expect(previews.at(1).text()).toBe( - "tableThis question won't be included in your Pulse", + expect(previews.at(1).text()).toEqual( + expect.stringContaining("Showing 20 of 18,760 rows"), ); expect(previews.at(1).find(".Icon-attachment").length).toBe(0); diff --git a/frontend/test/query_builder/components/FieldList.integ.spec.js b/frontend/test/query_builder/components/FieldList.integ.spec.js index 72257f3c0a86002e43b1af725c677cbfb93e736f..a0264d65741f0e52c0f54f5332229750d2c1a336 100644 --- a/frontend/test/query_builder/components/FieldList.integ.spec.js +++ b/frontend/test/query_builder/components/FieldList.integ.spec.js @@ -1,7 +1,10 @@ +jest.mock("metabase/hoc/Remapped"); + // Important: import of integrated_tests always comes first in tests because of mocked modules import { createTestStore, useSharedAdminLogin, + cleanup, } from "__support__/integrated_tests"; import React from "react"; @@ -41,6 +44,7 @@ describe("FieldList", () => { beforeAll(async () => { useSharedAdminLogin(); }); + afterAll(cleanup); it("should allow using expression as aggregation dimension", async () => { const store = await createTestStore(); @@ -71,6 +75,7 @@ describe("FieldList", () => { // TODO Atte Keinänen 6/27/17: Check why the result is wrapped in a promise that needs to be resolved manually const segment = await (await createSegment(orders_past_300_days_segment)) .payload; + cleanup.segment(segment); const store = await createTestStore(); await store.dispatch(fetchDatabases()); diff --git a/frontend/test/query_builder/components/dataref/MetricPane.integ.spec.js b/frontend/test/query_builder/components/dataref/MetricPane.integ.spec.js index d75b8bf68f6d72241b8d5d277aa675ad0bbeaa49..ef4e546a230899fc514dd5c299a167eeeeebe06d 100644 --- a/frontend/test/query_builder/components/dataref/MetricPane.integ.spec.js +++ b/frontend/test/query_builder/components/dataref/MetricPane.integ.spec.js @@ -1,6 +1,7 @@ import { useSharedAdminLogin, createTestStore, + cleanup, } from "__support__/integrated_tests"; import { click } from "__support__/enzyme_utils"; @@ -27,11 +28,10 @@ import { MetricApi } from "metabase/services"; describe("MetricPane", () => { let store = null; let queryBuilder = null; - let metricId = null; beforeAll(async () => { useSharedAdminLogin(); - metricId = (await MetricApi.create(vendor_count_metric)).id; + cleanup.metric(await MetricApi.create(vendor_count_metric)); store = await createTestStore(); store.pushPath(Urls.plainQuestion()); @@ -39,12 +39,8 @@ describe("MetricPane", () => { await store.waitForActions([INITIALIZE_QB]); }); - afterAll(async () => { - await MetricApi.delete({ - metricId, - revision_message: "Let's exterminate this metric", - }); - }); + afterAll(cleanup); + // NOTE: These test cases are intentionally stateful // (doing the whole app rendering thing in every single test case would probably slow things down) diff --git a/frontend/test/query_builder/components/dataref/SegmentPane.integ.spec.js b/frontend/test/query_builder/components/dataref/SegmentPane.integ.spec.js index fe7db890dfd1a342d13233f793a2569e9d4e25ee..335485bed1c10d3982959a9996b7ef183223e759 100644 --- a/frontend/test/query_builder/components/dataref/SegmentPane.integ.spec.js +++ b/frontend/test/query_builder/components/dataref/SegmentPane.integ.spec.js @@ -1,6 +1,7 @@ import { useSharedAdminLogin, createTestStore, + cleanup, } from "__support__/integrated_tests"; import { click } from "__support__/enzyme_utils"; @@ -31,11 +32,10 @@ import * as Urls from "metabase/lib/urls"; describe("SegmentPane", () => { let store = null; let queryBuilder = null; - let segment = null; beforeAll(async () => { useSharedAdminLogin(); - segment = await SegmentApi.create(orders_past_300_days_segment); + cleanup.segment(await SegmentApi.create(orders_past_300_days_segment)); store = await createTestStore(); store.pushPath(Urls.plainQuestion()); @@ -43,12 +43,7 @@ describe("SegmentPane", () => { await store.waitForActions([INITIALIZE_QB]); }); - afterAll(async () => { - await SegmentApi.delete({ - segmentId: segment.id, - revision_message: "Please", - }); - }); + afterAll(cleanup); // NOTE: These test cases are intentionally stateful // (doing the whole app rendering thing in every single test case would probably slow things down) diff --git a/frontend/test/query_builder/components/filters/pickers/DatePicker.unit.spec.js b/frontend/test/query_builder/components/filters/pickers/DatePicker.unit.spec.js index f5ff8a215a21a9a130b2e6ad503c743dcaea9dc4..807b9cd4b3cb79b8f4367bbee9f26a5c06fa25ac 100644 --- a/frontend/test/query_builder/components/filters/pickers/DatePicker.unit.spec.js +++ b/frontend/test/query_builder/components/filters/pickers/DatePicker.unit.spec.js @@ -40,4 +40,24 @@ describe("DatePicker", () => { expect(picker.find(DateOperatorSelector).text()).toEqual("Current"); expect(picker.find(DateUnitSelector).text()).toEqual("Week"); }); + it("should render 'Between'", () => { + let picker = mount( + <DatePicker + filter={["between", ["field-id", 1], "2018-01-01", null]} + onFilterChange={nop} + />, + ); + expect(picker.find(DateOperatorSelector).text()).toEqual("Between"); + expect(picker.find(".Calendar-header").map(t => t.text())).toEqual([ + "January 2018", + "February 2018", + ]); + for (let i = 0; i < 24; i++) { + picker.find(".Icon-chevronright").simulate("click"); + } + expect(picker.find(".Calendar-header").map(t => t.text())).toEqual([ + "January 2020", + "February 2020", + ]); + }); }); diff --git a/frontend/test/query_builder/qb_drillthrough.integ.spec.js b/frontend/test/query_builder/qb_drillthrough.integ.spec.js index 3b6779c9a598b3a6e0aee9a50d4e5e5bf1e0a807..c018316bc8da7da4168effa0c24f8d7fccc72c34 100644 --- a/frontend/test/query_builder/qb_drillthrough.integ.spec.js +++ b/frontend/test/query_builder/qb_drillthrough.integ.spec.js @@ -186,10 +186,10 @@ describe("QueryBuilder", () => { .find("td"); expect(firstRowCells.length).toBe(2); - expect(firstRowCells.first().text()).toBe("AA"); + expect(firstRowCells.first().text()).toBe("AK"); const countCell = firstRowCells.last(); - expect(countCell.text()).toBe("233"); + expect(countCell.text()).toBe("474"); click(countCell.children().first()); // Drill-through is delayed in handleVisualizationClick of Visualization.jsx by 100ms @@ -212,7 +212,7 @@ describe("QueryBuilder", () => { // Should have visualization type set to Pin map (temporary workaround until we have polished heat maps) const card = getCard(store.getState()); expect(card.display).toBe("map"); - expect(card.visualization_settings).toEqual({ "map.type": "pin" }); + expect(card.visualization_settings).toEqual({ "map.type": "grid" }); }); it("works for Count of rows aggregation and FK Latitude Auto binned breakout", async () => { @@ -239,10 +239,10 @@ describe("QueryBuilder", () => { .find("td"); expect(firstRowCells.length).toBe(2); - expect(firstRowCells.first().text()).toBe("90° S – 80° S"); + expect(firstRowCells.first().text()).toBe("20° N – 30° N"); const countCell = firstRowCells.last(); - expect(countCell.text()).toBe("701"); + expect(countCell.text()).toBe("579"); click(countCell.children().first()); // Drill-through is delayed in handleVisualizationClick of Visualization.jsx by 100ms diff --git a/frontend/test/query_builder/qb_editor_bar.integ.spec.js b/frontend/test/query_builder/qb_editor_bar.integ.spec.js index 712a7d1588ceca0ddcc967c138896a41a6d0d635..541c543bd270aa06f7a777d6699a1d5de5581cf3 100644 --- a/frontend/test/query_builder/qb_editor_bar.integ.spec.js +++ b/frontend/test/query_builder/qb_editor_bar.integ.spec.js @@ -238,13 +238,13 @@ describe("QueryBuilder editor bar", () => { await store.waitForActions([QUERY_COMPLETED]); // We can use the visible row count as we have a low number of result rows - expect(qb.find(".ShownRowCount").text()).toBe("Showing 14 rows"); + expect(qb.find(".ShownRowCount").text()).toBe("Showing 11 rows"); // Get the binning const results = getQueryResults(store.getState())[0]; const breakoutBinningInfo = results.data.cols[0].binning_info; expect(breakoutBinningInfo.binning_strategy).toBe("num-bins"); - expect(breakoutBinningInfo.bin_width).toBe(20); + expect(breakoutBinningInfo.bin_width).toBe(30); expect(breakoutBinningInfo.num_bins).toBe(8); }); it("lets you change the binning strategy to 100 bins", async () => { @@ -272,11 +272,11 @@ describe("QueryBuilder editor bar", () => { click(qb.find(RunButton)); await store.waitForActions([QUERY_COMPLETED]); - expect(qb.find(".ShownRowCount").text()).toBe("Showing 253 rows"); + expect(qb.find(".ShownRowCount").text()).toBe("Showing 116 rows"); const results = getQueryResults(store.getState())[0]; const breakoutBinningInfo = results.data.cols[0].binning_info; expect(breakoutBinningInfo.binning_strategy).toBe("num-bins"); - expect(breakoutBinningInfo.bin_width).toBe(1); + expect(breakoutBinningInfo.bin_width).toBe(2.5); expect(breakoutBinningInfo.num_bins).toBe(100); }); it("lets you disable the binning", async () => { @@ -346,13 +346,13 @@ describe("QueryBuilder editor bar", () => { click(qb.find(RunButton)); await store.waitForActions([QUERY_COMPLETED]); - expect(qb.find(".ShownRowCount").text()).toBe("Showing 18 rows"); + expect(qb.find(".ShownRowCount").text()).toBe("Showing 6 rows"); const results = getQueryResults(store.getState())[0]; const breakoutBinningInfo = results.data.cols[0].binning_info; expect(breakoutBinningInfo.binning_strategy).toBe("bin-width"); expect(breakoutBinningInfo.bin_width).toBe(10); - expect(breakoutBinningInfo.num_bins).toBe(18); + expect(breakoutBinningInfo.num_bins).toBe(6); }); it("lets you group by Latitude with the 'Bin every 1 degree'", async () => { @@ -381,13 +381,13 @@ describe("QueryBuilder editor bar", () => { click(qb.find(RunButton)); await store.waitForActions([QUERY_COMPLETED]); - expect(qb.find(".ShownRowCount").text()).toBe("Showing 180 rows"); + expect(qb.find(".ShownRowCount").text()).toBe("Showing 40 rows"); const results = getQueryResults(store.getState())[0]; const breakoutBinningInfo = results.data.cols[0].binning_info; expect(breakoutBinningInfo.binning_strategy).toBe("bin-width"); expect(breakoutBinningInfo.bin_width).toBe(1); - expect(breakoutBinningInfo.num_bins).toBe(180); + expect(breakoutBinningInfo.num_bins).toBe(46); }); }); }); diff --git a/frontend/test/query_builder/qb_visualizations.integ.spec.js b/frontend/test/query_builder/qb_visualizations.integ.spec.js index 55a54eaa73e6b6b0036345b0a9b8f3cb21ad7534..b45d348a718254e8933393d1a670bf921e9679fb 100644 --- a/frontend/test/query_builder/qb_visualizations.integ.spec.js +++ b/frontend/test/query_builder/qb_visualizations.integ.spec.js @@ -2,6 +2,7 @@ import { useSharedAdminLogin, createTestStore, createSavedQuestion, + cleanup, } from "__support__/integrated_tests"; import { click, clickButton, setInputValue } from "__support__/enzyme_utils"; @@ -21,7 +22,6 @@ import { getCard, getQuestion } from "metabase/query_builder/selectors"; import SaveQuestionModal from "metabase/containers/SaveQuestionModal"; import Radio from "metabase/components/Radio"; import { LOAD_COLLECTIONS } from "metabase/questions/collections"; -import { CardApi } from "metabase/services"; import * as Urls from "metabase/lib/urls"; import VisualizationSettings from "metabase/query_builder/components/VisualizationSettings"; import Popover from "metabase/components/Popover"; @@ -39,18 +39,28 @@ const timeBreakoutQuestion = Question.create({ .setDisplayName("Time breakout question"); describe("Query Builder visualization logic", () => { - let questionId = null; let savedTimeBreakoutQuestion = null; + let app; beforeAll(async () => { useSharedAdminLogin(); savedTimeBreakoutQuestion = await createSavedQuestion(timeBreakoutQuestion); + cleanup.question(savedTimeBreakoutQuestion); + }); + + afterAll(cleanup); + + afterEach(() => { + if (app) { + app.unmount(); + app = null; + } }); it("should save the default x axis and y axis to `visualization_settings` when saving a new question in QB", async () => { const store = await createTestStore(); store.pushPath(timeBreakoutQuestion.getUrl()); - const app = mount(store.connectContainer(<QueryBuilder />)); + app = mount(store.connectContainer(<QueryBuilder />)); await store.waitForActions([INITIALIZE_QB]); expect(getCard(store.getState()).visualization_settings).toEqual({}); @@ -85,13 +95,13 @@ describe("Query Builder visualization logic", () => { "graph.metrics": ["count"], }); - questionId = getQuestion(store.getState()).id(); + cleanup.question(getQuestion(store.getState()).id()); }); - it("should save the default x axis and y axis to `visualization_settings` when saving a new question in QB", async () => { + it("should save the default x axis and y axis to `visualization_settings` when saving an existing question in QB", async () => { const store = await createTestStore(); store.pushPath(Urls.question(savedTimeBreakoutQuestion.id())); - const app = mount(store.connectContainer(<QueryBuilder />)); + app = mount(store.connectContainer(<QueryBuilder />)); await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]); expect(getCard(store.getState()).visualization_settings).toEqual({}); @@ -132,10 +142,4 @@ describe("Query Builder visualization logic", () => { "graph.metrics": ["count"], }); }); - afterAll(async () => { - if (questionId) { - await CardApi.delete({ cardId: questionId }); - await CardApi.delete({ cardId: savedTimeBreakoutQuestion.id() }); - } - }); }); diff --git a/frontend/test/redux/store.unit.spec.js b/frontend/test/redux/store.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..92d9560936ffac01ce1f69f111418be3f20ca480 --- /dev/null +++ b/frontend/test/redux/store.unit.spec.js @@ -0,0 +1,64 @@ +import { trackEvent } from "metabase/store"; +import MetabaseAnalytics from "metabase/lib/analytics"; + +jest.mock("metabase/lib/analytics", () => ({ + trackEvent: jest.fn(), +})); + +// fake next for redux +const next = jest.fn(); + +describe("store", () => { + describe("trackEvent", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it("should call MetabaseAnalytics with the proper custom values", () => { + const testAction = { + type: "metabase/test/ACTION_NAME", + meta: { + analytics: { + category: "cool", + action: "action", + label: "labeled", + value: "value", + }, + }, + }; + + trackEvent({})(next)(testAction); + expect(MetabaseAnalytics.trackEvent).toHaveBeenCalledTimes(1); + expect(MetabaseAnalytics.trackEvent).toHaveBeenCalledWith( + "cool", + "action", + "labeled", + "value", + ); + }); + it("should ignore actions if ignore is true", () => { + const testAction = { + type: "metabase/test/ACTION_NAME", + meta: { + analytics: { + ignore: true, + }, + }, + }; + + trackEvent({})(next)(testAction); + expect(MetabaseAnalytics.trackEvent).toHaveBeenCalledTimes(0); + }); + + it("should use the action name if no analytics action is present", () => { + const testAction = { + type: "metabase/test/ACTION_NAME", + }; + + trackEvent({})(next)(testAction); + expect(MetabaseAnalytics.trackEvent).toHaveBeenCalledWith( + "test", + "ACTION_NAME", + ); + }); + }); +}); diff --git a/frontend/test/reference/databases.integ.spec.js b/frontend/test/reference/databases.integ.spec.js index 66ce4addf3e1a409ea204995d029b5fd3384d11f..5b8d68b81fb6ce0a7b314b2e1beb4f5dacd1c17f 100644 --- a/frontend/test/reference/databases.integ.spec.js +++ b/frontend/test/reference/databases.integ.spec.js @@ -58,7 +58,7 @@ describe("The Reference Section", () => { it("should see databases", async () => { const store = await createTestStore(); store.pushPath("/reference/databases/"); - var container = mount(store.connectContainer(<DatabaseListContainer />)); + let container = mount(store.connectContainer(<DatabaseListContainer />)); await store.waitForActions([FETCH_REAL_DATABASES, END_LOADING]); expect(container.find(ReferenceHeader).length).toBe(1); @@ -71,10 +71,10 @@ describe("The Reference Section", () => { // database list it("should not see saved questions in the database list", async () => { - var card = await CardApi.create(cardDef); + let card = await CardApi.create(cardDef); const store = await createTestStore(); store.pushPath("/reference/databases/"); - var container = mount(store.connectContainer(<DatabaseListContainer />)); + let container = mount(store.connectContainer(<DatabaseListContainer />)); await store.waitForActions([FETCH_REAL_DATABASES, END_LOADING]); expect(container.find(ReferenceHeader).length).toBe(1); @@ -144,7 +144,7 @@ describe("The Reference Section", () => { mount(store.connectContainer(<TableQuestionsContainer />)); await store.waitForActions([FETCH_DATABASE_METADATA, END_LOADING]); - var card = await CardApi.create(cardDef); + let card = await CardApi.create(cardDef); expect(card.name).toBe(cardDef.name); diff --git a/frontend/test/reference/guide.integ.spec.js b/frontend/test/reference/guide.integ.spec.js index 8d3af63d80f18fb31b70fdeca7a487f815a22038..29a798baa6bb9452f113c58f84e4da92e031e7a3 100644 --- a/frontend/test/reference/guide.integ.spec.js +++ b/frontend/test/reference/guide.integ.spec.js @@ -78,9 +78,9 @@ describe("The Reference Section", () => { it("Adding metrics should to the guide should make them appear", async () => { expect(0).toBe(0); - var metric = await MetricApi.create(metricDef); + let metric = await MetricApi.create(metricDef); expect(1).toBe(1); - var metric2 = await MetricApi.create(anotherMetricDef); + let metric2 = await MetricApi.create(anotherMetricDef); expect(2).toBe(2); await MetricApi.delete({ metricId: metric.id, @@ -96,9 +96,9 @@ describe("The Reference Section", () => { it("Adding segments should to the guide should make them appear", async () => { expect(0).toBe(0); - var segment = await SegmentApi.create(segmentDef); + let segment = await SegmentApi.create(segmentDef); expect(1).toBe(1); - var anotherSegment = await SegmentApi.create(anotherSegmentDef); + let anotherSegment = await SegmentApi.create(anotherSegmentDef); expect(2).toBe(2); await SegmentApi.delete({ segmentId: segment.id, diff --git a/frontend/test/reference/metrics.integ.spec.js b/frontend/test/reference/metrics.integ.spec.js index e28524e48a1ef1b21b115713d36c894b849dd02a..642a1a52a068dc83de5c0f3417e254946261899c 100644 --- a/frontend/test/reference/metrics.integ.spec.js +++ b/frontend/test/reference/metrics.integ.spec.js @@ -67,12 +67,12 @@ describe("The Reference Section", () => { }); describe("With Metrics State", async () => { - var metricIds = []; + let metricIds = []; beforeAll(async () => { // Create some metrics to have something to look at - var metric = await MetricApi.create(metricDef); - var metric2 = await MetricApi.create(anotherMetricDef); + let metric = await MetricApi.create(metricDef); + let metric2 = await MetricApi.create(anotherMetricDef); metricIds.push(metric.id); metricIds.push(metric2.id); @@ -116,7 +116,7 @@ describe("The Reference Section", () => { }); it("Should see a newly asked question in its questions list", async () => { - var card = await CardApi.create(metricCardDef); + let card = await CardApi.create(metricCardDef); expect(card.name).toBe(metricCardDef.name); try { diff --git a/frontend/test/reference/segments.integ.spec.js b/frontend/test/reference/segments.integ.spec.js index 2d2232ad4f22ea7a1fe9dbe1c9d687ed533d1de1..3a463bc35d95fd139e35e72c4e460d66e03e6951 100644 --- a/frontend/test/reference/segments.integ.spec.js +++ b/frontend/test/reference/segments.integ.spec.js @@ -80,12 +80,12 @@ describe("The Reference Section", () => { }); describe("With Segments State", async () => { - var segmentIds = []; + let segmentIds = []; beforeAll(async () => { // Create some segments to have something to look at - var segment = await SegmentApi.create(segmentDef); - var anotherSegment = await SegmentApi.create(anotherSegmentDef); + let segment = await SegmentApi.create(segmentDef); + let anotherSegment = await SegmentApi.create(anotherSegmentDef); segmentIds.push(segment.id); segmentIds.push(anotherSegment.id); }); @@ -151,7 +151,7 @@ describe("The Reference Section", () => { }); it("Should see a newly asked question in its questions list", async () => { - var card = await CardApi.create(segmentCardDef); + let card = await CardApi.create(segmentCardDef); expect(card.name).toBe(segmentCardDef.name); diff --git a/frontend/test/services/MetabaseApi.integ.spec.js b/frontend/test/services/MetabaseApi.integ.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b3385a38086811b6abec39291ef69da4326634da --- /dev/null +++ b/frontend/test/services/MetabaseApi.integ.spec.js @@ -0,0 +1,33 @@ +import { useSharedAdminLogin } from "__support__/integrated_tests"; + +import { MetabaseApi } from "metabase/services"; + +describe("MetabaseApi", () => { + beforeAll(() => useSharedAdminLogin()); + describe("table_query_metadata", () => { + // these table IDs correspond to the sample dataset in the fixture db + [1, 2, 3, 4].map(tableId => + it(`should have the correct metadata for table ${tableId}`, async () => { + expect( + stripKeys(await MetabaseApi.table_query_metadata({ tableId })), + ).toMatchSnapshot(); + }), + ); + }); +}); + +function stripKeys(object) { + // handles both arrays and objects + if (object && typeof object === "object") { + for (const key in object) { + if ( + /^((updated|created)_at|last_analyzed|timezone|is_on_demand)$/.test(key) + ) { + delete object[key]; + } else { + stripKeys(object[key]); + } + } + } + return object; +} diff --git a/frontend/test/services/__snapshots__/MetabaseApi.integ.spec.js.snap b/frontend/test/services/__snapshots__/MetabaseApi.integ.spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..3362c20ec0071920e516f8466f5664c55675417a --- /dev/null +++ b/frontend/test/services/__snapshots__/MetabaseApi.integ.spec.js.snap @@ -0,0 +1,3918 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MetabaseApi table_query_metadata should have the correct metadata for table 1 1`] = ` +Object { + "active": true, + "caveats": null, + "db": Object { + "cache_field_values_schedule": "0 0 0 * * ? *", + "caveats": null, + "description": null, + "engine": "h2", + "features": Array [ + "native-query-params", + "basic-aggregations", + "standard-deviation-aggregations", + "expression-aggregations", + "foreign-keys", + "native-parameters", + "nested-queries", + "expressions", + "binning", + ], + "id": 1, + "is_full_sync": true, + "is_sample": true, + "metadata_sync_schedule": "0 0 * * * ? *", + "name": "Sample Dataset", + "options": null, + "points_of_interest": null, + }, + "db_id": 1, + "description": "This is a confirmed order for a product from a user.", + "dimension_options": Object { + "0": Object { + "mbql": Array [ + "datetime-field", + null, + "minute", + ], + "name": "Minute", + "type": "type/DateTime", + }, + "1": Object { + "mbql": Array [ + "datetime-field", + null, + "hour", + ], + "name": "Hour", + "type": "type/DateTime", + }, + "10": Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-month", + ], + "name": "Day of Month", + "type": "type/DateTime", + }, + "11": Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-year", + ], + "name": "Day of Year", + "type": "type/DateTime", + }, + "12": Object { + "mbql": Array [ + "datetime-field", + null, + "week-of-year", + ], + "name": "Week of Year", + "type": "type/DateTime", + }, + "13": Object { + "mbql": Array [ + "datetime-field", + null, + "month-of-year", + ], + "name": "Month of Year", + "type": "type/DateTime", + }, + "14": Object { + "mbql": Array [ + "datetime-field", + null, + "quarter-of-year", + ], + "name": "Quarter of Year", + "type": "type/DateTime", + }, + "15": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + "16": Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 10, + ], + "name": "10 bins", + "type": "type/Number", + }, + "17": Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 50, + ], + "name": "50 bins", + "type": "type/Number", + }, + "18": Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 100, + ], + "name": "100 bins", + "type": "type/Number", + }, + "19": Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Number", + }, + "2": Object { + "mbql": Array [ + "datetime-field", + null, + "day", + ], + "name": "Day", + "type": "type/DateTime", + }, + "20": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Coordinate", + }, + "21": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 0.1, + ], + "name": "Bin every 0.1 degrees", + "type": "type/Coordinate", + }, + "22": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 1, + ], + "name": "Bin every 1 degree", + "type": "type/Coordinate", + }, + "23": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 10, + ], + "name": "Bin every 10 degrees", + "type": "type/Coordinate", + }, + "24": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 20, + ], + "name": "Bin every 20 degrees", + "type": "type/Coordinate", + }, + "25": Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Coordinate", + }, + "3": Object { + "mbql": Array [ + "datetime-field", + null, + "week", + ], + "name": "Week", + "type": "type/DateTime", + }, + "4": Object { + "mbql": Array [ + "datetime-field", + null, + "month", + ], + "name": "Month", + "type": "type/DateTime", + }, + "5": Object { + "mbql": Array [ + "datetime-field", + null, + "quarter", + ], + "name": "Quarter", + "type": "type/DateTime", + }, + "6": Object { + "mbql": Array [ + "datetime-field", + null, + "year", + ], + "name": "Year", + "type": "type/DateTime", + }, + "7": Object { + "mbql": Array [ + "datetime-field", + null, + "minute-of-hour", + ], + "name": "Minute of Hour", + "type": "type/DateTime", + }, + "8": Object { + "mbql": Array [ + "datetime-field", + null, + "hour-of-day", + ], + "name": "Hour of Day", + "type": "type/DateTime", + }, + "9": Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-week", + ], + "name": "Day of Week", + "type": "type/DateTime", + }, + }, + "display_name": "Orders", + "entity_name": null, + "entity_type": "entity/TransactionTable", + "fields": Array [ + Object { + "active": true, + "base_type": "type/DateTime", + "caveats": null, + "database_type": "TIMESTAMP", + "default_dimension_option": Object { + "mbql": Array [ + "datetime-field", + null, + "day", + ], + "name": "Day", + "type": "type/DateTime", + }, + "description": "The date and time an order was submitted.", + "dimension_options": Array [ + Object { + "mbql": Array [ + "datetime-field", + null, + "minute", + ], + "name": "Minute", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "hour", + ], + "name": "Hour", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day", + ], + "name": "Day", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "week", + ], + "name": "Week", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "month", + ], + "name": "Month", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "quarter", + ], + "name": "Quarter", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "year", + ], + "name": "Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "minute-of-hour", + ], + "name": "Minute of Hour", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "hour-of-day", + ], + "name": "Hour of Day", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-week", + ], + "name": "Day of Week", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-month", + ], + "name": "Day of Month", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-year", + ], + "name": "Day of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "week-of-year", + ], + "name": "Week of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "month-of-year", + ], + "name": "Month of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "quarter-of-year", + ], + "name": "Quarter of Year", + "type": "type/DateTime", + }, + ], + "dimensions": Array [], + "display_name": "Created At", + "fingerprint": Object { + "global": Object { + "distinct-count": 1387, + }, + "type": Object { + "type/DateTime": Object { + "earliest": "2016-04-30T00:00:00.000-07:00", + "latest": "2020-04-19T00:00:00.000-07:00", + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 1, + "max_value": null, + "min_value": null, + "name": "CREATED_AT", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 9, + "special_type": "type/CreationTimestamp", + "table_id": 1, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Float", + "caveats": null, + "database_type": "DOUBLE", + "default_dimension_option": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + "description": "Discount amount.", + "dimension_options": Array [ + Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 10, + ], + "name": "10 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 50, + ], + "name": "50 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 100, + ], + "name": "100 bins", + "type": "type/Number", + }, + Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Number", + }, + ], + "dimensions": Array [], + "display_name": "Discount", + "fingerprint": Object { + "global": Object { + "distinct-count": 700, + }, + "type": Object { + "type/Number": Object { + "avg": 5.161255547580321, + "max": 61.69684269960571, + "min": 0.17088996672584322, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 35, + "max_value": null, + "min_value": null, + "name": "DISCOUNT", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": null, + "special_type": "type/Discount", + "table_id": 1, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/BigInteger", + "caveats": null, + "database_type": "BIGINT", + "default_dimension_option": null, + "description": "This is a unique ID for the product. It is also called the “Invoice number†or “Confirmation number†in customer facing emails and screens.", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "ID", + "fingerprint": Object { + "global": Object { + "distinct-count": 10000, + }, + "type": Object { + "type/Number": Object { + "avg": 5000.5, + "max": 10000, + "min": 1, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 2, + "max_value": 17624, + "min_value": 1, + "name": "ID", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 10, + "special_type": "type/PK", + "table_id": 1, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Integer", + "caveats": null, + "database_type": "INTEGER", + "default_dimension_option": null, + "description": "The product ID. This is an internal identifier for the product, NOT the SKU.", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "Product ID", + "fingerprint": Object { + "global": Object { + "distinct-count": 200, + }, + "type": Object { + "type/Number": Object { + "avg": 100.5084, + "max": 200, + "min": 1, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": 24, + "has_field_values": "none", + "id": 3, + "max_value": 200, + "min_value": 1, + "name": "PRODUCT_ID", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 11, + "special_type": "type/FK", + "table_id": 1, + "target": Object { + "active": true, + "base_type": "type/BigInteger", + "caveats": null, + "database_type": "BIGINT", + "description": "The numerical product number. Only used internally. All external communication should use the title or EAN.", + "display_name": "ID", + "fingerprint": Object { + "global": Object { + "distinct-count": 200, + }, + "type": Object { + "type/Number": Object { + "avg": 100.5, + "max": 200, + "min": 1, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 24, + "max_value": 200, + "min_value": 1, + "name": "ID", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 4, + "special_type": "type/PK", + "table_id": 3, + "visibility_type": "normal", + }, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Integer", + "caveats": null, + "database_type": "INTEGER", + "default_dimension_option": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + "description": "Number of products bought.", + "dimension_options": Array [ + Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 10, + ], + "name": "10 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 50, + ], + "name": "50 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 100, + ], + "name": "100 bins", + "type": "type/Number", + }, + Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Number", + }, + ], + "dimensions": Array [], + "display_name": "Quantity", + "fingerprint": Object { + "global": Object { + "distinct-count": 62, + }, + "type": Object { + "type/Number": Object { + "avg": 3.7015, + "max": 100, + "min": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "list", + "id": 36, + "max_value": null, + "min_value": null, + "name": "QUANTITY", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": null, + "special_type": "type/Quantity", + "table_id": 1, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Float", + "caveats": null, + "database_type": "DOUBLE", + "default_dimension_option": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + "description": "The raw, pre-tax cost of the order. Note that this might be different in the future from the product price due to promotions, credits, etc.", + "dimension_options": Array [ + Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 10, + ], + "name": "10 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 50, + ], + "name": "50 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 100, + ], + "name": "100 bins", + "type": "type/Number", + }, + Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Number", + }, + ], + "dimensions": Array [], + "display_name": "Subtotal", + "fingerprint": Object { + "global": Object { + "distinct-count": 340, + }, + "type": Object { + "type/Number": Object { + "avg": 77.01295465356537, + "max": 148.22900526552291, + "min": 15.691943673970439, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 4, + "max_value": 99.37, + "min_value": 12.02, + "name": "SUBTOTAL", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 12, + "special_type": null, + "table_id": 1, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Float", + "caveats": null, + "database_type": "DOUBLE", + "default_dimension_option": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + "description": "This is the amount of local and federal taxes that are collected on the purchase. Note that other governmental fees on some products are not included here, but instead are accounted for in the subtotal.", + "dimension_options": Array [ + Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 10, + ], + "name": "10 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 50, + ], + "name": "50 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 100, + ], + "name": "100 bins", + "type": "type/Number", + }, + Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Number", + }, + ], + "dimensions": Array [], + "display_name": "Tax", + "fingerprint": Object { + "global": Object { + "distinct-count": 797, + }, + "type": Object { + "type/Number": Object { + "avg": 3.8722099999999875, + "max": 11.12, + "min": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 5, + "max_value": 7.45, + "min_value": 0, + "name": "TAX", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 13, + "special_type": null, + "table_id": 1, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Float", + "caveats": null, + "database_type": "DOUBLE", + "default_dimension_option": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + "description": "The total billed amount.", + "dimension_options": Array [ + Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 10, + ], + "name": "10 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 50, + ], + "name": "50 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 100, + ], + "name": "100 bins", + "type": "type/Number", + }, + Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Number", + }, + ], + "dimensions": Array [], + "display_name": "Total", + "fingerprint": Object { + "global": Object { + "distinct-count": 10000, + }, + "type": Object { + "type/Number": Object { + "avg": 82.96014815230829, + "max": 238.32732001721533, + "min": 12.061602936923117, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 6, + "max_value": 106.82000000000001, + "min_value": 12.02, + "name": "TOTAL", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 14, + "special_type": "type/Income", + "table_id": 1, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Integer", + "caveats": null, + "database_type": "INTEGER", + "default_dimension_option": null, + "description": "The id of the user who made this order. Note that in some cases where an order was created on behalf of a customer who phoned the order in, this might be the employee who handled the request.", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "User ID", + "fingerprint": Object { + "global": Object { + "distinct-count": 929, + }, + "type": Object { + "type/Number": Object { + "avg": 678.6893, + "max": 1322, + "min": 1, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": 13, + "has_field_values": "none", + "id": 7, + "max_value": 2498, + "min_value": 2, + "name": "USER_ID", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 15, + "special_type": "type/FK", + "table_id": 1, + "target": Object { + "active": true, + "base_type": "type/BigInteger", + "caveats": null, + "database_type": "BIGINT", + "description": "A unique identifier given to each user.", + "display_name": "ID", + "fingerprint": Object { + "global": Object { + "distinct-count": 2500, + }, + "type": Object { + "type/Number": Object { + "avg": 1250.5, + "max": 2500, + "min": 1, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 13, + "max_value": 2500, + "min_value": 1, + "name": "ID", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 21, + "special_type": "type/PK", + "table_id": 2, + "visibility_type": "normal", + }, + "visibility_type": "normal", + }, + ], + "id": 1, + "metrics": Array [], + "name": "ORDERS", + "points_of_interest": null, + "raw_table_id": 2, + "rows": 12805, + "schema": "PUBLIC", + "segments": Array [], + "show_in_getting_started": false, + "visibility_type": null, +} +`; + +exports[`MetabaseApi table_query_metadata should have the correct metadata for table 2 1`] = ` +Object { + "active": true, + "caveats": null, + "db": Object { + "cache_field_values_schedule": "0 0 0 * * ? *", + "caveats": null, + "description": null, + "engine": "h2", + "features": Array [ + "native-query-params", + "basic-aggregations", + "standard-deviation-aggregations", + "expression-aggregations", + "foreign-keys", + "native-parameters", + "nested-queries", + "expressions", + "binning", + ], + "id": 1, + "is_full_sync": true, + "is_sample": true, + "metadata_sync_schedule": "0 0 * * * ? *", + "name": "Sample Dataset", + "options": null, + "points_of_interest": null, + }, + "db_id": 1, + "description": "This is a user account. Note that employees and customer support staff will have accounts.", + "dimension_options": Object { + "0": Object { + "mbql": Array [ + "datetime-field", + null, + "minute", + ], + "name": "Minute", + "type": "type/DateTime", + }, + "1": Object { + "mbql": Array [ + "datetime-field", + null, + "hour", + ], + "name": "Hour", + "type": "type/DateTime", + }, + "10": Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-month", + ], + "name": "Day of Month", + "type": "type/DateTime", + }, + "11": Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-year", + ], + "name": "Day of Year", + "type": "type/DateTime", + }, + "12": Object { + "mbql": Array [ + "datetime-field", + null, + "week-of-year", + ], + "name": "Week of Year", + "type": "type/DateTime", + }, + "13": Object { + "mbql": Array [ + "datetime-field", + null, + "month-of-year", + ], + "name": "Month of Year", + "type": "type/DateTime", + }, + "14": Object { + "mbql": Array [ + "datetime-field", + null, + "quarter-of-year", + ], + "name": "Quarter of Year", + "type": "type/DateTime", + }, + "15": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + "16": Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 10, + ], + "name": "10 bins", + "type": "type/Number", + }, + "17": Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 50, + ], + "name": "50 bins", + "type": "type/Number", + }, + "18": Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 100, + ], + "name": "100 bins", + "type": "type/Number", + }, + "19": Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Number", + }, + "2": Object { + "mbql": Array [ + "datetime-field", + null, + "day", + ], + "name": "Day", + "type": "type/DateTime", + }, + "20": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Coordinate", + }, + "21": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 0.1, + ], + "name": "Bin every 0.1 degrees", + "type": "type/Coordinate", + }, + "22": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 1, + ], + "name": "Bin every 1 degree", + "type": "type/Coordinate", + }, + "23": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 10, + ], + "name": "Bin every 10 degrees", + "type": "type/Coordinate", + }, + "24": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 20, + ], + "name": "Bin every 20 degrees", + "type": "type/Coordinate", + }, + "25": Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Coordinate", + }, + "3": Object { + "mbql": Array [ + "datetime-field", + null, + "week", + ], + "name": "Week", + "type": "type/DateTime", + }, + "4": Object { + "mbql": Array [ + "datetime-field", + null, + "month", + ], + "name": "Month", + "type": "type/DateTime", + }, + "5": Object { + "mbql": Array [ + "datetime-field", + null, + "quarter", + ], + "name": "Quarter", + "type": "type/DateTime", + }, + "6": Object { + "mbql": Array [ + "datetime-field", + null, + "year", + ], + "name": "Year", + "type": "type/DateTime", + }, + "7": Object { + "mbql": Array [ + "datetime-field", + null, + "minute-of-hour", + ], + "name": "Minute of Hour", + "type": "type/DateTime", + }, + "8": Object { + "mbql": Array [ + "datetime-field", + null, + "hour-of-day", + ], + "name": "Hour of Day", + "type": "type/DateTime", + }, + "9": Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-week", + ], + "name": "Day of Week", + "type": "type/DateTime", + }, + }, + "display_name": "People", + "entity_name": null, + "entity_type": "entity/UserTable", + "fields": Array [ + Object { + "active": true, + "base_type": "type/Text", + "caveats": null, + "database_type": "VARCHAR", + "default_dimension_option": null, + "description": "The street address of the account’s billing address", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "Address", + "fingerprint": Object { + "global": Object { + "distinct-count": 2490, + }, + "type": Object { + "type/Text": Object { + "average-length": 20.85, + "percent-email": 0, + "percent-json": 0, + "percent-url": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "search", + "id": 8, + "max_value": null, + "min_value": null, + "name": "ADDRESS", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 16, + "special_type": null, + "table_id": 2, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Date", + "caveats": null, + "database_type": "DATE", + "default_dimension_option": Object { + "mbql": Array [ + "datetime-field", + null, + "day", + ], + "name": "Day", + "type": "type/DateTime", + }, + "description": "The date of birth of the user", + "dimension_options": Array [ + Object { + "mbql": Array [ + "datetime-field", + null, + "minute", + ], + "name": "Minute", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "hour", + ], + "name": "Hour", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day", + ], + "name": "Day", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "week", + ], + "name": "Week", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "month", + ], + "name": "Month", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "quarter", + ], + "name": "Quarter", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "year", + ], + "name": "Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "minute-of-hour", + ], + "name": "Minute of Hour", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "hour-of-day", + ], + "name": "Hour of Day", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-week", + ], + "name": "Day of Week", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-month", + ], + "name": "Day of Month", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-year", + ], + "name": "Day of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "week-of-year", + ], + "name": "Week of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "month-of-year", + ], + "name": "Month of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "quarter-of-year", + ], + "name": "Quarter of Year", + "type": "type/DateTime", + }, + ], + "dimensions": Array [], + "display_name": "Birth Date", + "fingerprint": Object { + "global": Object { + "distinct-count": 2308, + }, + "type": Object { + "type/DateTime": Object { + "earliest": "1958-04-26T00:00:00.000-08:00", + "latest": "2000-04-03T00:00:00.000-07:00", + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 9, + "max_value": null, + "min_value": null, + "name": "BIRTH_DATE", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 17, + "special_type": null, + "table_id": 2, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Text", + "caveats": null, + "database_type": "VARCHAR", + "default_dimension_option": null, + "description": "The city of the account’s billing address", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "City", + "fingerprint": Object { + "global": Object { + "distinct-count": 1966, + }, + "type": Object { + "type/Text": Object { + "average-length": 8.284, + "percent-email": 0, + "percent-json": 0, + "percent-url": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "search", + "id": 10, + "max_value": null, + "min_value": null, + "name": "CITY", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 18, + "special_type": "type/City", + "table_id": 2, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/DateTime", + "caveats": null, + "database_type": "TIMESTAMP", + "default_dimension_option": Object { + "mbql": Array [ + "datetime-field", + null, + "day", + ], + "name": "Day", + "type": "type/DateTime", + }, + "description": "The date the user record was created. Also referred to as the user’s \\"join date\\"", + "dimension_options": Array [ + Object { + "mbql": Array [ + "datetime-field", + null, + "minute", + ], + "name": "Minute", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "hour", + ], + "name": "Hour", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day", + ], + "name": "Day", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "week", + ], + "name": "Week", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "month", + ], + "name": "Month", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "quarter", + ], + "name": "Quarter", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "year", + ], + "name": "Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "minute-of-hour", + ], + "name": "Minute of Hour", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "hour-of-day", + ], + "name": "Hour of Day", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-week", + ], + "name": "Day of Week", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-month", + ], + "name": "Day of Month", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-year", + ], + "name": "Day of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "week-of-year", + ], + "name": "Week of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "month-of-year", + ], + "name": "Month of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "quarter-of-year", + ], + "name": "Quarter of Year", + "type": "type/DateTime", + }, + ], + "dimensions": Array [], + "display_name": "Created At", + "fingerprint": Object { + "global": Object { + "distinct-count": 992, + }, + "type": Object { + "type/DateTime": Object { + "earliest": "2016-04-19T00:00:00.000-07:00", + "latest": "2019-04-19T00:00:00.000-07:00", + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 11, + "max_value": null, + "min_value": null, + "name": "CREATED_AT", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 19, + "special_type": "type/CreationTimestamp", + "table_id": 2, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Text", + "caveats": null, + "database_type": "VARCHAR", + "default_dimension_option": null, + "description": "The contact email for the account.", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "Email", + "fingerprint": Object { + "global": Object { + "distinct-count": 2500, + }, + "type": Object { + "type/Text": Object { + "average-length": 24.1824, + "percent-email": 1, + "percent-json": 0, + "percent-url": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "search", + "id": 12, + "max_value": null, + "min_value": null, + "name": "EMAIL", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 20, + "special_type": "type/Email", + "table_id": 2, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/BigInteger", + "caveats": null, + "database_type": "BIGINT", + "default_dimension_option": null, + "description": "A unique identifier given to each user.", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "ID", + "fingerprint": Object { + "global": Object { + "distinct-count": 2500, + }, + "type": Object { + "type/Number": Object { + "avg": 1250.5, + "max": 2500, + "min": 1, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 13, + "max_value": 2500, + "min_value": 1, + "name": "ID", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 21, + "special_type": "type/PK", + "table_id": 2, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Float", + "caveats": null, + "database_type": "DOUBLE", + "default_dimension_option": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Coordinate", + }, + "description": "This is the latitude of the user on sign-up. It might be updated in the future to the last seen location.", + "dimension_options": Array [ + Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Coordinate", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 0.1, + ], + "name": "Bin every 0.1 degrees", + "type": "type/Coordinate", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 1, + ], + "name": "Bin every 1 degree", + "type": "type/Coordinate", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 10, + ], + "name": "Bin every 10 degrees", + "type": "type/Coordinate", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 20, + ], + "name": "Bin every 20 degrees", + "type": "type/Coordinate", + }, + Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Coordinate", + }, + ], + "dimensions": Array [], + "display_name": "Latitude", + "fingerprint": Object { + "global": Object { + "distinct-count": 2491, + }, + "type": Object { + "type/Number": Object { + "avg": 39.879346704840046, + "max": 70.6355001, + "min": 25.775827, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 14, + "max_value": 89.98241873383432, + "min_value": -89.96310010740648, + "name": "LATITUDE", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 22, + "special_type": "type/Latitude", + "table_id": 2, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Float", + "caveats": null, + "database_type": "DOUBLE", + "default_dimension_option": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Coordinate", + }, + "description": "This is the longitude of the user on sign-up. It might be updated in the future to the last seen location.", + "dimension_options": Array [ + Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Coordinate", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 0.1, + ], + "name": "Bin every 0.1 degrees", + "type": "type/Coordinate", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 1, + ], + "name": "Bin every 1 degree", + "type": "type/Coordinate", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 10, + ], + "name": "Bin every 10 degrees", + "type": "type/Coordinate", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 20, + ], + "name": "Bin every 20 degrees", + "type": "type/Coordinate", + }, + Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Coordinate", + }, + ], + "dimensions": Array [], + "display_name": "Longitude", + "fingerprint": Object { + "global": Object { + "distinct-count": 2491, + }, + "type": Object { + "type/Number": Object { + "avg": -95.18741780364007, + "max": -67.96735199999999, + "min": -166.5425726, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 15, + "max_value": 179.73650520575882, + "min_value": -179.30480334715446, + "name": "LONGITUDE", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 23, + "special_type": "type/Longitude", + "table_id": 2, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Text", + "caveats": null, + "database_type": "VARCHAR", + "default_dimension_option": null, + "description": "The name of the user who owns an account", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "Name", + "fingerprint": Object { + "global": Object { + "distinct-count": 2499, + }, + "type": Object { + "type/Text": Object { + "average-length": 13.532, + "percent-email": 0, + "percent-json": 0, + "percent-url": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "search", + "id": 16, + "max_value": null, + "min_value": null, + "name": "NAME", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 24, + "special_type": "type/Name", + "table_id": 2, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Text", + "caveats": null, + "database_type": "VARCHAR", + "default_dimension_option": null, + "description": "This is the salted password of the user. It should not be visible", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "Password", + "fingerprint": Object { + "global": Object { + "distinct-count": 2500, + }, + "type": Object { + "type/Text": Object { + "average-length": 36, + "percent-email": 0, + "percent-json": 0, + "percent-url": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "search", + "id": 17, + "max_value": null, + "min_value": null, + "name": "PASSWORD", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 25, + "special_type": null, + "table_id": 2, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Text", + "caveats": null, + "database_type": "VARCHAR", + "default_dimension_option": null, + "description": "The channel through which we acquired this user. Valid values include: Affiliate, Facebook, Google, Organic and Twitter", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "Source", + "fingerprint": Object { + "global": Object { + "distinct-count": 5, + }, + "type": Object { + "type/Text": Object { + "average-length": 7.4084, + "percent-email": 0, + "percent-json": 0, + "percent-url": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "list", + "id": 18, + "max_value": null, + "min_value": null, + "name": "SOURCE", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 26, + "special_type": "type/Source", + "table_id": 2, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Text", + "caveats": null, + "database_type": "CHAR", + "default_dimension_option": null, + "description": "The state or province of the account’s billing address", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "State", + "fingerprint": Object { + "global": Object { + "distinct-count": 49, + }, + "type": Object { + "type/Text": Object { + "average-length": 2, + "percent-email": 0, + "percent-json": 0, + "percent-url": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "list", + "id": 19, + "max_value": null, + "min_value": null, + "name": "STATE", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 27, + "special_type": "type/State", + "table_id": 2, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Text", + "caveats": null, + "database_type": "CHAR", + "default_dimension_option": null, + "description": "The postal code of the account’s billing address", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "Zip", + "fingerprint": Object { + "global": Object { + "distinct-count": 2235, + }, + "type": Object { + "type/Text": Object { + "average-length": 5, + "percent-email": 0, + "percent-json": 0, + "percent-url": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "search", + "id": 20, + "max_value": null, + "min_value": null, + "name": "ZIP", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 28, + "special_type": "type/ZipCode", + "table_id": 2, + "target": null, + "visibility_type": "normal", + }, + ], + "id": 2, + "metrics": Array [], + "name": "PEOPLE", + "points_of_interest": null, + "raw_table_id": 3, + "rows": 2500, + "schema": "PUBLIC", + "segments": Array [], + "show_in_getting_started": false, + "visibility_type": null, +} +`; + +exports[`MetabaseApi table_query_metadata should have the correct metadata for table 3 1`] = ` +Object { + "active": true, + "caveats": null, + "db": Object { + "cache_field_values_schedule": "0 0 0 * * ? *", + "caveats": null, + "description": null, + "engine": "h2", + "features": Array [ + "native-query-params", + "basic-aggregations", + "standard-deviation-aggregations", + "expression-aggregations", + "foreign-keys", + "native-parameters", + "nested-queries", + "expressions", + "binning", + ], + "id": 1, + "is_full_sync": true, + "is_sample": true, + "metadata_sync_schedule": "0 0 * * * ? *", + "name": "Sample Dataset", + "options": null, + "points_of_interest": null, + }, + "db_id": 1, + "description": "This is our product catalog. It includes all products ever sold by the Sample Company.", + "dimension_options": Object { + "0": Object { + "mbql": Array [ + "datetime-field", + null, + "minute", + ], + "name": "Minute", + "type": "type/DateTime", + }, + "1": Object { + "mbql": Array [ + "datetime-field", + null, + "hour", + ], + "name": "Hour", + "type": "type/DateTime", + }, + "10": Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-month", + ], + "name": "Day of Month", + "type": "type/DateTime", + }, + "11": Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-year", + ], + "name": "Day of Year", + "type": "type/DateTime", + }, + "12": Object { + "mbql": Array [ + "datetime-field", + null, + "week-of-year", + ], + "name": "Week of Year", + "type": "type/DateTime", + }, + "13": Object { + "mbql": Array [ + "datetime-field", + null, + "month-of-year", + ], + "name": "Month of Year", + "type": "type/DateTime", + }, + "14": Object { + "mbql": Array [ + "datetime-field", + null, + "quarter-of-year", + ], + "name": "Quarter of Year", + "type": "type/DateTime", + }, + "15": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + "16": Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 10, + ], + "name": "10 bins", + "type": "type/Number", + }, + "17": Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 50, + ], + "name": "50 bins", + "type": "type/Number", + }, + "18": Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 100, + ], + "name": "100 bins", + "type": "type/Number", + }, + "19": Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Number", + }, + "2": Object { + "mbql": Array [ + "datetime-field", + null, + "day", + ], + "name": "Day", + "type": "type/DateTime", + }, + "20": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Coordinate", + }, + "21": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 0.1, + ], + "name": "Bin every 0.1 degrees", + "type": "type/Coordinate", + }, + "22": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 1, + ], + "name": "Bin every 1 degree", + "type": "type/Coordinate", + }, + "23": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 10, + ], + "name": "Bin every 10 degrees", + "type": "type/Coordinate", + }, + "24": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 20, + ], + "name": "Bin every 20 degrees", + "type": "type/Coordinate", + }, + "25": Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Coordinate", + }, + "3": Object { + "mbql": Array [ + "datetime-field", + null, + "week", + ], + "name": "Week", + "type": "type/DateTime", + }, + "4": Object { + "mbql": Array [ + "datetime-field", + null, + "month", + ], + "name": "Month", + "type": "type/DateTime", + }, + "5": Object { + "mbql": Array [ + "datetime-field", + null, + "quarter", + ], + "name": "Quarter", + "type": "type/DateTime", + }, + "6": Object { + "mbql": Array [ + "datetime-field", + null, + "year", + ], + "name": "Year", + "type": "type/DateTime", + }, + "7": Object { + "mbql": Array [ + "datetime-field", + null, + "minute-of-hour", + ], + "name": "Minute of Hour", + "type": "type/DateTime", + }, + "8": Object { + "mbql": Array [ + "datetime-field", + null, + "hour-of-day", + ], + "name": "Hour of Day", + "type": "type/DateTime", + }, + "9": Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-week", + ], + "name": "Day of Week", + "type": "type/DateTime", + }, + }, + "display_name": "Products", + "entity_name": null, + "entity_type": "entity/ProductTable", + "fields": Array [ + Object { + "active": true, + "base_type": "type/Text", + "caveats": null, + "database_type": "VARCHAR", + "default_dimension_option": null, + "description": "The type of product, valid values include: Doohicky, Gadget, Gizmo and Widget", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "Category", + "fingerprint": Object { + "global": Object { + "distinct-count": 4, + }, + "type": Object { + "type/Text": Object { + "average-length": 6.375, + "percent-email": 0, + "percent-json": 0, + "percent-url": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "list", + "id": 21, + "max_value": null, + "min_value": null, + "name": "CATEGORY", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 1, + "special_type": "type/Category", + "table_id": 3, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/DateTime", + "caveats": null, + "database_type": "TIMESTAMP", + "default_dimension_option": Object { + "mbql": Array [ + "datetime-field", + null, + "day", + ], + "name": "Day", + "type": "type/DateTime", + }, + "description": "The date the product was added to our catalog.", + "dimension_options": Array [ + Object { + "mbql": Array [ + "datetime-field", + null, + "minute", + ], + "name": "Minute", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "hour", + ], + "name": "Hour", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day", + ], + "name": "Day", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "week", + ], + "name": "Week", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "month", + ], + "name": "Month", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "quarter", + ], + "name": "Quarter", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "year", + ], + "name": "Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "minute-of-hour", + ], + "name": "Minute of Hour", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "hour-of-day", + ], + "name": "Hour of Day", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-week", + ], + "name": "Day of Week", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-month", + ], + "name": "Day of Month", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-year", + ], + "name": "Day of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "week-of-year", + ], + "name": "Week of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "month-of-year", + ], + "name": "Month of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "quarter-of-year", + ], + "name": "Quarter of Year", + "type": "type/DateTime", + }, + ], + "dimensions": Array [], + "display_name": "Created At", + "fingerprint": Object { + "global": Object { + "distinct-count": 186, + }, + "type": Object { + "type/DateTime": Object { + "earliest": "2016-04-26T00:00:00.000-07:00", + "latest": "2019-04-15T00:00:00.000-07:00", + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 22, + "max_value": null, + "min_value": null, + "name": "CREATED_AT", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 2, + "special_type": "type/CreationTimestamp", + "table_id": 3, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Text", + "caveats": null, + "database_type": "CHAR", + "default_dimension_option": null, + "description": "The international article number. A 13 digit number uniquely identifying the product.", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "Ean", + "fingerprint": Object { + "global": Object { + "distinct-count": 200, + }, + "type": Object { + "type/Text": Object { + "average-length": 13, + "percent-email": 0, + "percent-json": 0, + "percent-url": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "search", + "id": 23, + "max_value": null, + "min_value": null, + "name": "EAN", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 3, + "special_type": null, + "table_id": 3, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/BigInteger", + "caveats": null, + "database_type": "BIGINT", + "default_dimension_option": null, + "description": "The numerical product number. Only used internally. All external communication should use the title or EAN.", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "ID", + "fingerprint": Object { + "global": Object { + "distinct-count": 200, + }, + "type": Object { + "type/Number": Object { + "avg": 100.5, + "max": 200, + "min": 1, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 24, + "max_value": 200, + "min_value": 1, + "name": "ID", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 4, + "special_type": "type/PK", + "table_id": 3, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Float", + "caveats": null, + "database_type": "DOUBLE", + "default_dimension_option": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + "description": "The list price of the product. Note that this is not always the price the product sold for due to discounts, promotions, etc.", + "dimension_options": Array [ + Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 10, + ], + "name": "10 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 50, + ], + "name": "50 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 100, + ], + "name": "100 bins", + "type": "type/Number", + }, + Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Number", + }, + ], + "dimensions": Array [], + "display_name": "Price", + "fingerprint": Object { + "global": Object { + "distinct-count": 170, + }, + "type": Object { + "type/Number": Object { + "avg": 55.74639966792074, + "max": 98.81933684368194, + "min": 15.691943673970439, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 25, + "max_value": 99.37, + "min_value": 12.02, + "name": "PRICE", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 5, + "special_type": null, + "table_id": 3, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Float", + "caveats": null, + "database_type": "DOUBLE", + "default_dimension_option": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + "description": "The average rating users have given the product. This ranges from 1 - 5", + "dimension_options": Array [ + Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 10, + ], + "name": "10 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 50, + ], + "name": "50 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 100, + ], + "name": "100 bins", + "type": "type/Number", + }, + Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Number", + }, + ], + "dimensions": Array [], + "display_name": "Rating", + "fingerprint": Object { + "global": Object { + "distinct-count": 23, + }, + "type": Object { + "type/Number": Object { + "avg": 3.4715000000000007, + "max": 5, + "min": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "list", + "id": 26, + "max_value": 5, + "min_value": 0, + "name": "RATING", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 6, + "special_type": "type/Score", + "table_id": 3, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Text", + "caveats": null, + "database_type": "VARCHAR", + "default_dimension_option": null, + "description": "The name of the product as it should be displayed to customers.", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "Title", + "fingerprint": Object { + "global": Object { + "distinct-count": 199, + }, + "type": Object { + "type/Text": Object { + "average-length": 21.495, + "percent-email": 0, + "percent-json": 0, + "percent-url": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "search", + "id": 27, + "max_value": null, + "min_value": null, + "name": "TITLE", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 7, + "special_type": "type/Title", + "table_id": 3, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Text", + "caveats": null, + "database_type": "VARCHAR", + "default_dimension_option": null, + "description": "The source of the product.", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "Vendor", + "fingerprint": Object { + "global": Object { + "distinct-count": 200, + }, + "type": Object { + "type/Text": Object { + "average-length": 20.6, + "percent-email": 0, + "percent-json": 0, + "percent-url": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "search", + "id": 28, + "max_value": null, + "min_value": null, + "name": "VENDOR", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 8, + "special_type": "type/Company", + "table_id": 3, + "target": null, + "visibility_type": "normal", + }, + ], + "id": 3, + "metrics": Array [], + "name": "PRODUCTS", + "points_of_interest": null, + "raw_table_id": 1, + "rows": 200, + "schema": "PUBLIC", + "segments": Array [], + "show_in_getting_started": false, + "visibility_type": null, +} +`; + +exports[`MetabaseApi table_query_metadata should have the correct metadata for table 4 1`] = ` +Object { + "active": true, + "caveats": null, + "db": Object { + "cache_field_values_schedule": "0 0 0 * * ? *", + "caveats": null, + "description": null, + "engine": "h2", + "features": Array [ + "native-query-params", + "basic-aggregations", + "standard-deviation-aggregations", + "expression-aggregations", + "foreign-keys", + "native-parameters", + "nested-queries", + "expressions", + "binning", + ], + "id": 1, + "is_full_sync": true, + "is_sample": true, + "metadata_sync_schedule": "0 0 * * * ? *", + "name": "Sample Dataset", + "options": null, + "points_of_interest": null, + }, + "db_id": 1, + "description": "These are reviews our customers have left on products. Note that these are not tied to orders so it is possible people have reviewed products they did not purchase from us.", + "dimension_options": Object { + "0": Object { + "mbql": Array [ + "datetime-field", + null, + "minute", + ], + "name": "Minute", + "type": "type/DateTime", + }, + "1": Object { + "mbql": Array [ + "datetime-field", + null, + "hour", + ], + "name": "Hour", + "type": "type/DateTime", + }, + "10": Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-month", + ], + "name": "Day of Month", + "type": "type/DateTime", + }, + "11": Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-year", + ], + "name": "Day of Year", + "type": "type/DateTime", + }, + "12": Object { + "mbql": Array [ + "datetime-field", + null, + "week-of-year", + ], + "name": "Week of Year", + "type": "type/DateTime", + }, + "13": Object { + "mbql": Array [ + "datetime-field", + null, + "month-of-year", + ], + "name": "Month of Year", + "type": "type/DateTime", + }, + "14": Object { + "mbql": Array [ + "datetime-field", + null, + "quarter-of-year", + ], + "name": "Quarter of Year", + "type": "type/DateTime", + }, + "15": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + "16": Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 10, + ], + "name": "10 bins", + "type": "type/Number", + }, + "17": Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 50, + ], + "name": "50 bins", + "type": "type/Number", + }, + "18": Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 100, + ], + "name": "100 bins", + "type": "type/Number", + }, + "19": Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Number", + }, + "2": Object { + "mbql": Array [ + "datetime-field", + null, + "day", + ], + "name": "Day", + "type": "type/DateTime", + }, + "20": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Coordinate", + }, + "21": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 0.1, + ], + "name": "Bin every 0.1 degrees", + "type": "type/Coordinate", + }, + "22": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 1, + ], + "name": "Bin every 1 degree", + "type": "type/Coordinate", + }, + "23": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 10, + ], + "name": "Bin every 10 degrees", + "type": "type/Coordinate", + }, + "24": Object { + "mbql": Array [ + "binning-strategy", + null, + "bin-width", + 20, + ], + "name": "Bin every 20 degrees", + "type": "type/Coordinate", + }, + "25": Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Coordinate", + }, + "3": Object { + "mbql": Array [ + "datetime-field", + null, + "week", + ], + "name": "Week", + "type": "type/DateTime", + }, + "4": Object { + "mbql": Array [ + "datetime-field", + null, + "month", + ], + "name": "Month", + "type": "type/DateTime", + }, + "5": Object { + "mbql": Array [ + "datetime-field", + null, + "quarter", + ], + "name": "Quarter", + "type": "type/DateTime", + }, + "6": Object { + "mbql": Array [ + "datetime-field", + null, + "year", + ], + "name": "Year", + "type": "type/DateTime", + }, + "7": Object { + "mbql": Array [ + "datetime-field", + null, + "minute-of-hour", + ], + "name": "Minute of Hour", + "type": "type/DateTime", + }, + "8": Object { + "mbql": Array [ + "datetime-field", + null, + "hour-of-day", + ], + "name": "Hour of Day", + "type": "type/DateTime", + }, + "9": Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-week", + ], + "name": "Day of Week", + "type": "type/DateTime", + }, + }, + "display_name": "Reviews", + "entity_name": null, + "entity_type": "entity/GenericTable", + "fields": Array [ + Object { + "active": true, + "base_type": "type/Text", + "caveats": null, + "database_type": "CLOB", + "default_dimension_option": null, + "description": "The review the user left. Limited to 2000 characters.", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "Body", + "fingerprint": Object { + "global": Object { + "distinct-count": 1112, + }, + "type": Object { + "type/Text": Object { + "average-length": 180.6501798561151, + "percent-email": 0, + "percent-json": 0, + "percent-url": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "search", + "id": 29, + "max_value": null, + "min_value": null, + "name": "BODY", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": false, + "raw_column_id": 31, + "special_type": "type/Description", + "table_id": 4, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/DateTime", + "caveats": null, + "database_type": "TIMESTAMP", + "default_dimension_option": Object { + "mbql": Array [ + "datetime-field", + null, + "day", + ], + "name": "Day", + "type": "type/DateTime", + }, + "description": "The day and time a review was written by a user.", + "dimension_options": Array [ + Object { + "mbql": Array [ + "datetime-field", + null, + "minute", + ], + "name": "Minute", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "hour", + ], + "name": "Hour", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day", + ], + "name": "Day", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "week", + ], + "name": "Week", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "month", + ], + "name": "Month", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "quarter", + ], + "name": "Quarter", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "year", + ], + "name": "Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "minute-of-hour", + ], + "name": "Minute of Hour", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "hour-of-day", + ], + "name": "Hour of Day", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-week", + ], + "name": "Day of Week", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-month", + ], + "name": "Day of Month", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "day-of-year", + ], + "name": "Day of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "week-of-year", + ], + "name": "Week of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "month-of-year", + ], + "name": "Month of Year", + "type": "type/DateTime", + }, + Object { + "mbql": Array [ + "datetime-field", + null, + "quarter-of-year", + ], + "name": "Quarter of Year", + "type": "type/DateTime", + }, + ], + "dimensions": Array [], + "display_name": "Created At", + "fingerprint": Object { + "global": Object { + "distinct-count": 720, + }, + "type": Object { + "type/DateTime": Object { + "earliest": "2016-06-03T00:00:00.000-07:00", + "latest": "2020-04-19T00:00:00.000-07:00", + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 30, + "max_value": null, + "min_value": null, + "name": "CREATED_AT", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 32, + "special_type": "type/CreationTimestamp", + "table_id": 4, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/BigInteger", + "caveats": null, + "database_type": "BIGINT", + "default_dimension_option": null, + "description": "A unique internal identifier for the review. Should not be used externally.", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "ID", + "fingerprint": Object { + "global": Object { + "distinct-count": 1112, + }, + "type": Object { + "type/Number": Object { + "avg": 556.5, + "max": 1112, + "min": 1, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 31, + "max_value": 1078, + "min_value": 1, + "name": "ID", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 33, + "special_type": "type/PK", + "table_id": 4, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Integer", + "caveats": null, + "database_type": "INTEGER", + "default_dimension_option": null, + "description": "The product the review was for", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "Product ID", + "fingerprint": Object { + "global": Object { + "distinct-count": 176, + }, + "type": Object { + "type/Number": Object { + "avg": 96.88489208633094, + "max": 200, + "min": 1, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": 24, + "has_field_values": "none", + "id": 32, + "max_value": 200, + "min_value": 1, + "name": "PRODUCT_ID", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 34, + "special_type": "type/FK", + "table_id": 4, + "target": Object { + "active": true, + "base_type": "type/BigInteger", + "caveats": null, + "database_type": "BIGINT", + "description": "The numerical product number. Only used internally. All external communication should use the title or EAN.", + "display_name": "ID", + "fingerprint": Object { + "global": Object { + "distinct-count": 200, + }, + "type": Object { + "type/Number": Object { + "avg": 100.5, + "max": 200, + "min": 1, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "none", + "id": 24, + "max_value": 200, + "min_value": 1, + "name": "ID", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 4, + "special_type": "type/PK", + "table_id": 3, + "visibility_type": "normal", + }, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Integer", + "caveats": null, + "database_type": "SMALLINT", + "default_dimension_option": Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + "description": "The rating (on a scale of 1-5) the user left.", + "dimension_options": Array [ + Object { + "mbql": Array [ + "binning-strategy", + null, + "default", + ], + "name": "Auto bin", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 10, + ], + "name": "10 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 50, + ], + "name": "50 bins", + "type": "type/Number", + }, + Object { + "mbql": Array [ + "binning-strategy", + null, + "num-bins", + 100, + ], + "name": "100 bins", + "type": "type/Number", + }, + Object { + "mbql": null, + "name": "Don't bin", + "type": "type/Number", + }, + ], + "dimensions": Array [], + "display_name": "Rating", + "fingerprint": Object { + "global": Object { + "distinct-count": 5, + }, + "type": Object { + "type/Number": Object { + "avg": 3.987410071942446, + "max": 5, + "min": 1, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "list", + "id": 33, + "max_value": 5, + "min_value": 1, + "name": "RATING", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 35, + "special_type": "type/Score", + "table_id": 4, + "target": null, + "visibility_type": "normal", + }, + Object { + "active": true, + "base_type": "type/Text", + "caveats": null, + "database_type": "VARCHAR", + "default_dimension_option": null, + "description": "The user who left the review", + "dimension_options": Array [], + "dimensions": Array [], + "display_name": "Reviewer", + "fingerprint": Object { + "global": Object { + "distinct-count": 1076, + }, + "type": Object { + "type/Text": Object { + "average-length": 9.972122302158274, + "percent-email": 0, + "percent-json": 0, + "percent-url": 0, + }, + }, + }, + "fingerprint_version": 2, + "fk_target_field_id": null, + "has_field_values": "search", + "id": 34, + "max_value": null, + "min_value": null, + "name": "REVIEWER", + "parent_id": null, + "points_of_interest": null, + "position": 0, + "preview_display": true, + "raw_column_id": 36, + "special_type": null, + "table_id": 4, + "target": null, + "visibility_type": "normal", + }, + ], + "id": 4, + "metrics": Array [], + "name": "REVIEWS", + "points_of_interest": null, + "raw_table_id": 5, + "rows": 984, + "schema": "PUBLIC", + "segments": Array [], + "show_in_getting_started": false, + "visibility_type": null, +} +`; diff --git a/frontend/test/setup/signup.integ.spec.js b/frontend/test/setup/signup.integ.spec.js index ff21362ed7464177ef31eef007dcd1c11ca4a093..fe27b504f1254400a0c4c934d8241078c1935cee 100644 --- a/frontend/test/setup/signup.integ.spec.js +++ b/frontend/test/setup/signup.integ.spec.js @@ -134,7 +134,7 @@ describe("setup wizard", () => { const nextButton = databaseStep.find('button[children="Next"]'); expect(nextButton.props().disabled).toBe(true); - const dbPath = path.resolve(__dirname, "../__runner__/test_db_fixture.db"); + const dbPath = path.resolve(__dirname, "../__runner__/empty.db"); setInputValue(databaseStep.find("input[name='db']"), `file:${dbPath}`); expect(nextButton.props().disabled).toBe(undefined); @@ -204,7 +204,7 @@ describe("setup wizard", () => { const allSetUpSection = app.find(".SetupStep").last(); expect(allSetUpSection.find(".SetupStep--active").length).toBe(1); - expect(allSetUpSection.find('a[href="/?new"]').length).toBe(1); + expect(allSetUpSection.find('a[href="/explore"]').length).toBe(1); }); it("should show you the onboarding modal", async () => { diff --git a/frontend/test/xray/xray.integ.spec.js b/frontend/test/xray/xray.integ.spec.js index ddc16c604b116e43651fb72a6fcbffd78ee62305..9ff948a2baf4227d2154e203404ab23bcf541fa8 100644 --- a/frontend/test/xray/xray.integ.spec.js +++ b/frontend/test/xray/xray.integ.spec.js @@ -10,7 +10,7 @@ import { CardApi, SegmentApi, SettingsApi } from "metabase/services"; import { delay } from "metabase/lib/promise"; import { - FETCH_CARD_XRAY, + // FETCH_CARD_XRAY, FETCH_FIELD_XRAY, FETCH_SEGMENT_XRAY, FETCH_SHARED_TYPE_COMPARISON_XRAY, @@ -21,7 +21,7 @@ import { import FieldXray from "metabase/xray/containers/FieldXray"; import TableXRay from "metabase/xray/containers/TableXRay"; import SegmentXRay from "metabase/xray/containers/SegmentXRay"; -import CardXRay from "metabase/xray/containers/CardXRay"; +// import CardXRay from "metabase/xray/containers/CardXRay"; import CostSelect from "metabase/xray/components/CostSelect"; import Constituent from "metabase/xray/components/Constituent"; @@ -51,9 +51,9 @@ import ItemLink from "metabase/xray/components/ItemLink"; import { TableLikeComparisonXRay } from "metabase/xray/containers/TableLikeComparison"; import { InsightCard, - NoisinessInsight, + // NoisinessInsight, NormalRangeInsight, - AutocorrelationInsight, + // AutocorrelationInsight, } from "metabase/xray/components/InsightCard"; describe("xray integration tests", () => { @@ -345,57 +345,57 @@ describe("xray integration tests", () => { await SettingsApi.put({ key: "enable-xrays", value: "true" }); }); - it("let you see card xray for a timeseries question", async () => { - await SettingsApi.put({ key: "xray-max-cost", value: "extended" }); - const store = await createTestStore(); - // make sure xrays are on and at the proper cost - store.pushPath(Urls.question(timeBreakoutQuestion.id())); - const app = mount(store.getAppContainer()); - - await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]); - // NOTE Atte Keinänen: Not sure why we need this delay to get most of action widget actions to appear :/ - await delay(500); - - const actionsWidget = app.find(ActionsWidget); - click(actionsWidget.childAt(0)); - const xrayOptionIcon = actionsWidget.find(".Icon.Icon-beaker"); - click(xrayOptionIcon); - - await store.waitForActions([FETCH_CARD_XRAY], { timeout: 20000 }); - expect(store.getPath()).toBe( - `/xray/card/${timeBreakoutQuestion.id()}/extended`, - ); - - const cardXRay = app.find(CardXRay); - expect(cardXRay.length).toBe(1); - expect(cardXRay.text()).toMatch(/Time breakout question/); - - // Should contain the expected insights - expect(app.find(InsightCard).length > 0).toBe(true); - expect(app.find(NoisinessInsight).length).toBe(1); - expect(app.find(AutocorrelationInsight).length).toBe(1); - }); - - it("let you see segment xray for a question containing a segment", async () => { - const store = await createTestStore(); - store.pushPath(Urls.question(segmentQuestion.id())); - const app = mount(store.getAppContainer()); - - await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]); - - const actionsWidget = app.find(ActionsWidget); - click(actionsWidget.childAt(0)); - const xrayOptionIcon = actionsWidget.find(".Icon.Icon-beaker"); - click(xrayOptionIcon); - - await store.waitForActions([FETCH_SEGMENT_XRAY], { timeout: 20000 }); - expect(store.getPath()).toBe(`/xray/segment/${segmentId}/approximate`); - - const segmentXRay = app.find(SegmentXRay); - expect(segmentXRay.length).toBe(1); - expect(segmentXRay.find(CostSelect).length).toBe(1); - expect(segmentXRay.text()).toMatch(/A Segment/); - }); + // it("let you see card xray for a timeseries question", async () => { + // await SettingsApi.put({ key: "xray-max-cost", value: "extended" }); + // const store = await createTestStore(); + // // make sure xrays are on and at the proper cost + // store.pushPath(Urls.question(timeBreakoutQuestion.id())); + // const app = mount(store.getAppContainer()); + + // await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]); + // // NOTE Atte Keinänen: Not sure why we need this delay to get most of action widget actions to appear :/ + // await delay(500); + + // const actionsWidget = app.find(ActionsWidget); + // click(actionsWidget.childAt(0)); + // const xrayOptionIcon = actionsWidget.find(".Icon.Icon-bolt"); + // click(xrayOptionIcon); + + // await store.waitForActions([FETCH_CARD_XRAY], { timeout: 20000 }); + // expect(store.getPath()).toBe( + // `/xray/card/${timeBreakoutQuestion.id()}/extended`, + // ); + + // const cardXRay = app.find(CardXRay); + // expect(cardXRay.length).toBe(1); + // expect(cardXRay.text()).toMatch(/Time breakout question/); + + // // Should contain the expected insights + // expect(app.find(InsightCard).length > 0).toBe(true); + // expect(app.find(NoisinessInsight).length).toBe(1); + // expect(app.find(AutocorrelationInsight).length).toBe(1); + // }); + + // it("let you see segment xray for a question containing a segment", async () => { + // const store = await createTestStore(); + // store.pushPath(Urls.question(segmentQuestion.id())); + // const app = mount(store.getAppContainer()); + + // await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]); + + // const actionsWidget = app.find(ActionsWidget); + // click(actionsWidget.childAt(0)); + // const xrayOptionIcon = actionsWidget.find(".Icon.Icon-bolt"); + // click(xrayOptionIcon); + + // await store.waitForActions([FETCH_SEGMENT_XRAY], { timeout: 20000 }); + // expect(store.getPath()).toBe(`/xray/segment/${segmentId}/approximate`); + + // const segmentXRay = app.find(SegmentXRay); + // expect(segmentXRay.length).toBe(1); + // expect(segmentXRay.find(CostSelect).length).toBe(1); + // expect(segmentXRay.text()).toMatch(/A Segment/); + // }); }); describe("admin management of xrays", async () => { @@ -441,7 +441,7 @@ describe("xray integration tests", () => { click(actionsWidget.childAt(0)); // there should not be an xray option - const xrayOptionIcon = actionsWidget.find(".Icon.Icon-beaker"); + const xrayOptionIcon = actionsWidget.find(".Icon.Icon-bolt"); expect(xrayOptionIcon.length).toEqual(0); }); @@ -459,7 +459,7 @@ describe("xray integration tests", () => { const actionsWidget = app.find(ActionsWidget); click(actionsWidget.childAt(0)); - const xrayOptionIcon = actionsWidget.find(".Icon.Icon-beaker"); + const xrayOptionIcon = actionsWidget.find(".Icon.Icon-bolt"); expect(xrayOptionIcon.length).toEqual(0); }); @@ -510,35 +510,35 @@ describe("xray integration tests", () => { await store.waitForActions([END_LOADING]); - const xrayTableSideBarItem = app.find(".Icon.Icon-beaker"); + const xrayTableSideBarItem = app.find(".Icon.Icon-bolt"); expect(xrayTableSideBarItem.length).toEqual(1); store.pushPath("/reference/databases/1/tables/1/fields/1"); await store.waitForActions([END_LOADING]); - const xrayFieldSideBarItem = app.find(".Icon.Icon-beaker"); + const xrayFieldSideBarItem = app.find(".Icon.Icon-bolt"); expect(xrayFieldSideBarItem.length).toEqual(1); }); - it("should not be possible to access an Xray from the data reference if xrays are disabled", async () => { - // turn off xrays - await SettingsApi.put({ key: "enable-xrays", value: false }); - const store = await createTestStore(); + // it("should not be possible to access an Xray from the data reference if xrays are disabled", async () => { + // // turn off xrays + // await SettingsApi.put({ key: "enable-xrays", value: false }); + // const store = await createTestStore(); - const app = mount(store.getAppContainer()); + // const app = mount(store.getAppContainer()); - store.pushPath("/reference/databases/1/tables/1"); + // store.pushPath("/reference/databases/1/tables/1"); - await store.waitForActions([END_LOADING]); + // await store.waitForActions([END_LOADING]); - const xrayTableSideBarItem = app.find(".Icon.Icon-beaker"); - expect(xrayTableSideBarItem.length).toEqual(0); + // const xrayTableSideBarItem = app.find(".Icon.Icon-bolt"); + // expect(xrayTableSideBarItem.length).toEqual(0); - store.pushPath("/reference/databases/1/tables/1/fields/1"); - await store.waitForActions([END_LOADING]); - const xrayFieldSideBarItem = app.find(".Icon.Icon-beaker"); - expect(xrayFieldSideBarItem.length).toEqual(0); - }); + // store.pushPath("/reference/databases/1/tables/1/fields/1"); + // await store.waitForActions([END_LOADING]); + // const xrayFieldSideBarItem = app.find(".Icon.Icon-bolt"); + // expect(xrayFieldSideBarItem.length).toEqual(0); + // }); }); afterAll(async () => { diff --git a/jest.integ.conf.json b/jest.integ.conf.json index 34a98f4e4ef891d01d1432c0c70127018d55ef9b..c90bcc0f3ba59de3fd2e67dcb4f33a76fad6cc6c 100644 --- a/jest.integ.conf.json +++ b/jest.integ.conf.json @@ -16,6 +16,7 @@ "<rootDir>/frontend/src" ], "setupFiles": [ + "<rootDir>/frontend/test/jest-setup.js", "<rootDir>/frontend/test/metabase-bootstrap.js" ], "globals": { diff --git a/jest.unit.conf.json b/jest.unit.conf.json index de57c0a87cf70744cde19cf455af76be31835d64..38eaf71141ed8fbf5edc378a79966ff1fe6fd483 100644 --- a/jest.unit.conf.json +++ b/jest.unit.conf.json @@ -14,6 +14,7 @@ "<rootDir>/frontend/src" ], "setupFiles": [ + "<rootDir>/frontend/test/jest-setup.js", "<rootDir>/frontend/test/metabase-bootstrap.js" ], "globals": { @@ -23,5 +24,6 @@ }, "coverageDirectory": "./", "coverageReporters": ["text", "json-summary"], - "collectCoverageFrom": ["frontend/src/**/*.js", "frontend/src/**/*.jsx"] + "collectCoverageFrom": ["frontend/src/**/*.js", "frontend/src/**/*.jsx"], + "coveragePathIgnorePatterns": ["/node_modules/", "/frontend/src/metabase/visualizations/lib/errors.js"] } diff --git a/locales/de.po b/locales/de.po deleted file mode 100644 index 5bb687d8b456a2d43cb869b78e11a828dbd93f94..0000000000000000000000000000000000000000 --- a/locales/de.po +++ /dev/null @@ -1,136 +0,0 @@ -# #-#-#-#-# metabase-backend.pot (metabase) #-#-#-#-# -# German translations for metabase package. -# Copyright (C) 2017 Metabase <docs@metabase.com> -# This file is distributed under the same license as the metabase package. -# Tom Robinson <tom@metabase.com>, 2017. -# -msgid "" -msgstr "" -"Project-Id-Version: metabase\n" -"Report-Msgid-Bugs-To: docs@metabase.com\n" -"POT-Creation-Date: 2017-08-21 21:54+0200\n" -"PO-Revision-Date: 2017-08-21 21:17+0200\n" -"Last-Translator: Tom Robinson <tom@metabase.com>\n" -"Language-Team: German <translation-team-de@lists.sourceforge.net>\n" -"Language: de\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"#-#-#-#-# metabase-frontend.pot #-#-#-#-#\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"#-#-#-#-# metabase-backend.pot (metabase) #-#-#-#-#\n" - -#: frontend/src/metabase/home/components/Activity.jsx:131 -msgid "Hello World!" -msgstr "Hallo Welt!" - -#: frontend/src/metabase/home/components/Activity.jsx:132 -msgid "Metabase is up and running." -msgstr "Metabase ist auf und laufen" - -#: frontend/src/metabase/home/components/Activity.jsx:176 -msgid "removed the filter {item.details.name}" -msgstr "entfernen sie den filter {item.details.name}" - -#: frontend/src/metabase/home/components/Activity.jsx:179 -msgid "joined!" -msgstr "beigetreten!" - -#: frontend/src/metabase/home/components/NextStep.jsx:31 -msgid "Setup Tip" -msgstr "Setup Tipp" - -#: frontend/src/metabase/home/components/NextStep.jsx:32 -msgid "View all" -msgstr "Alle Anzeigen" - -#: frontend/src/metabase/home/components/RecentViews.jsx:38 -msgid "Recently Viewed" -msgstr "Vor Kurzem Anzeige" - -#: frontend/src/metabase/home/components/RecentViews.jsx:60 -msgid "You haven't looked at any dashboards or questions recently" -msgstr "Sie haben nicht schaute zu jeder dashboards oder fragen kürzlich" - -#: frontend/src/metabase/home/containers/HomepageApp.jsx:93 -msgid "Activity" -msgstr "Aktivitäten" - -#: frontend/src/metabase/lib/greeting.js:2 -msgid "Hey there" -msgstr "Hallo" - -#: frontend/src/metabase/lib/greeting.js:3 -#: frontend/src/metabase/lib/greeting.js:25 -msgid "How's it going" -msgstr "Wie ist es gehen" - -#: frontend/src/metabase/lib/greeting.js:4 -msgid "Howdy" -msgstr "Hallo" - -#: frontend/src/metabase/lib/greeting.js:5 -msgid "Greetings" -msgstr "Grüße" - -#: frontend/src/metabase/lib/greeting.js:6 -msgid "Good to see you" -msgstr "Schön, dich zu sehen" - -#: frontend/src/metabase/lib/greeting.js:10 -msgid "What do you want to know?" -msgstr "Was möchten sie wissen?" - -#: frontend/src/metabase/lib/greeting.js:11 -msgid "What's on your mind?" -msgstr "Was auf dem herzen?" - -#: frontend/src/metabase/lib/greeting.js:12 -msgid "What do you want to find out?" -msgstr "Was möchten sie finden ot?" - -#: frontend/src/metabase/nav/containers/Navbar.jsx:129 -msgid "Dashboards" -msgstr "Ãœbersicht" - -#: frontend/src/metabase/nav/containers/Navbar.jsx:132 -msgid "Questions" -msgstr "Fragen" - -#: frontend/src/metabase/nav/containers/Navbar.jsx:135 -msgid "Pulses" -msgstr "Impuse" - -#: frontend/src/metabase/nav/containers/Navbar.jsx:138 -msgid "Data Reference" -msgstr "Daten Referenz" - -#: frontend/src/metabase/nav/containers/Navbar.jsx:142 -msgid "New Question" -msgstr "Neue Frage" - -#: src/metabase/api/setup.clj -msgid "Add a database" -msgstr "Fügen sue eine Ãœbersicht" - -#: src/metabase/api/setup.clj -msgid "Get connected" -msgstr "Holen sie verbunden" - -#: src/metabase/api/setup.clj -msgid "Connect to your data so your whole team can start to explore." -msgstr "Schließen sie auf ihre daten so ihre ganze team kann beginnen zu erkuden" - -#. This is the very first log message that will get printed. -#. It's here because this is one of the very first namespaces that gets loaded, and the first that has access to the logger -#. It shows up a solid 10-15 seconds before the "Starting Metabase in STANDALONE mode" message because so many other namespaces need to get loaded -#: src/metabase/util.clj -msgid "Loading {0}..." -msgstr "Laden {0}..." - -#. This is the very first log message that will get printed. -#. It's here because this is one of the very first namespaces that gets loaded, and the first that has access to the logger -#. It shows up a solid 10-15 seconds before the "Starting Metabase in STANDALONE mode" message because so many other namespaces need to get loaded -#: src/metabase/util.clj -msgid "Metabase" -msgstr "Metabaes" diff --git a/locales/metabase.pot b/locales/metabase.pot index bec4f100d828b1e48089dac33505cd713606ddb2..bd710fe4c347d4a9f3bbcc047a4540d3e075647a 100644 --- a/locales/metabase.pot +++ b/locales/metabase.pot @@ -13,7 +13,7 @@ msgstr "" "#-#-#-#-# metabase-backend.pot (metabase) #-#-#-#-#\n" "Project-Id-Version: metabase\n" "Report-Msgid-Bugs-To: docs@metabase.com\n" -"POT-Creation-Date: 2017-08-21 21:54+0200\n" +"POT-Creation-Date: 2018-03-02 12:58+0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -22,93 +22,7110 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: frontend/src/metabase/home/components/Activity.jsx:131 +#: frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx:19 +msgid "Your database has been added!" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx:22 +msgid "" +"We're analyzing its schema now to make some educated guesses about its\n" +"metadata. {0} in the Data Model section to see what we've found and to\n" +"make edits, or {1} about\n" +"this database." +msgstr "" + +#: frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx:41 +#: frontend/src/metabase/admin/people/components/GroupsListing.jsx:135 +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:222 +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:259 +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:348 +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:368 +#: frontend/src/metabase/components/HeaderModal.jsx:43 +#: frontend/src/metabase/parameters/components/widgets/CategoryWidget.jsx:106 +#: frontend/src/metabase/query_builder/components/AggregationPopover.jsx:298 +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:182 +#: frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx:96 +#: frontend/src/metabase/visualizations/components/ChartSettings.jsx:200 +msgid "Done" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DatabaseEditForms.jsx:42 +msgid "Select a database type" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DatabaseEditForms.jsx:75 +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:182 +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:438 +#: frontend/src/metabase/admin/settings/components/widgets/LdapGroupMappingsWidget.jsx:175 +#: frontend/src/metabase/components/ActionButton.jsx:51 +#: frontend/src/metabase/components/ButtonWithStatus.jsx:6 +#: frontend/src/metabase/reference/components/EditHeader.jsx:54 +#: frontend/src/metabase/reference/components/EditHeader.jsx:69 +#: frontend/src/metabase/user/components/SetUserPassword.jsx:167 +#: frontend/src/metabase/user/components/UpdateUserDetails.jsx:162 +msgid "Save" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DatabaseSchedulingForm.jsx:120 +msgid "" +"To do some of its magic, Metabase needs to scan your database. We will also " +"rescan it periodically to keep the metadata up-to-date. You can control when " +"the periodic rescans happen below." +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DatabaseSchedulingForm.jsx:125 +msgid "Database syncing" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DatabaseSchedulingForm.jsx:126 +msgid "" +"This is a lightweight process that checks for\n" +"updates to this database’s schema. In most cases, you should be fine leaving " +"this\n" +"set to sync hourly." +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DatabaseSchedulingForm.jsx:145 +#: frontend/src/metabase/admin/databases/components/DatabaseSchedulingForm.jsx:182 +msgid "Scan" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DatabaseSchedulingForm.jsx:150 +msgid "Scanning for Filter Values" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DatabaseSchedulingForm.jsx:151 +msgid "" +"Metabase can scan the values present in each\n" +"field in this database to enable checkbox filters in dashboards and " +"questions. This\n" +"can be a somewhat resource-intensive process, particularly if you have a " +"very large\n" +"database." +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DatabaseSchedulingForm.jsx:157 +msgid "When should Metabase automatically scan and cache field values?" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DatabaseSchedulingForm.jsx:162 +msgid "Regularly, on a schedule" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DatabaseSchedulingForm.jsx:193 +msgid "Only when adding a new filter widget" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DatabaseSchedulingForm.jsx:197 +msgid "" +"When a user adds a new filter to a dashboard or a SQL question, Metabase " +"will\n" +"scan the field(s) mapped to that filter in order to show the list of " +"selectable values." +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DatabaseSchedulingForm.jsx:208 +msgid "Never, I'll do this manually if I need to" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DatabaseSchedulingForm.jsx:220 +#: frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx:245 +#: frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx:258 +#: frontend/src/metabase/admin/settings/components/SettingsSlackForm.jsx:218 +#: frontend/src/metabase/components/ActionButton.jsx:52 +#: frontend/src/metabase/components/ButtonWithStatus.jsx:7 +#: frontend/src/metabase/components/DatabaseDetailsForm.jsx:398 +msgid "Saving..." +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx:38 +#: frontend/src/metabase/components/CreateDashboardModal.jsx:59 +#: frontend/src/metabase/components/form/FormMessage.jsx:4 +#: frontend/src/metabase/containers/SaveQuestionModal.jsx:142 +msgid "Server error encountered" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx:53 +msgid "Delete this database?" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx:58 +msgid "" +"<strong>Just a heads up:</strong> without the Sample Dataset, the Query " +"Builder tutorial won't work. You can always restore the Sample Dataset, but " +"any questions you've saved using this data will be lost." +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx:61 +msgid "" +"All saved questions, metrics, and segments that rely on this database will " +"be lost." +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx:62 +msgid "This cannot be undone." +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx:65 +msgid "If you're sure, please type" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx:65 +msgid "DELETE" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx:66 +msgid "in this box:" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx:80 +#: frontend/src/metabase/admin/datamodel/components/ObjectRetireModal.jsx:50 +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:87 +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:93 +#: frontend/src/metabase/admin/people/components/AddRow.jsx:27 +#: frontend/src/metabase/admin/people/components/EditUserForm.jsx:234 +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:299 +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:324 +#: frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx:49 +#: frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx:52 +#: frontend/src/metabase/admin/permissions/selectors.js:151 +#: frontend/src/metabase/admin/permissions/selectors.js:161 +#: frontend/src/metabase/admin/permissions/selectors.js:176 +#: frontend/src/metabase/admin/permissions/selectors.js:215 +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:355 +#: frontend/src/metabase/admin/settings/components/widgets/LdapGroupMappingsWidget.jsx:174 +#: frontend/src/metabase/admin/settings/components/widgets/LdapGroupMappingsWidget.jsx:240 +#: frontend/src/metabase/components/ConfirmContent.jsx:15 +#: frontend/src/metabase/components/CreateDashboardModal.jsx:78 +#: frontend/src/metabase/components/DeleteModalWithConfirm.jsx:71 +#: frontend/src/metabase/components/HeaderModal.jsx:49 +#: frontend/src/metabase/containers/SaveQuestionModal.jsx:194 +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:199 +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:180 +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:332 +#: frontend/src/metabase/query_builder/components/RunButton.jsx:24 +#: frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx:83 +#: frontend/src/metabase/query_builder/containers/ArchiveQuestionModal.jsx:48 +#: frontend/src/metabase/questions/containers/ArchiveCollectionWidget.jsx:50 +#: frontend/src/metabase/questions/containers/CollectionEditorForm.jsx:49 +#: frontend/src/metabase/questions/containers/EditLabels.jsx:110 +#: frontend/src/metabase/questions/containers/MoveToCollection.jsx:61 +#: frontend/src/metabase/reference/components/EditHeader.jsx:34 +#: frontend/src/metabase/reference/components/RevisionMessageModal.jsx:52 +#: frontend/src/metabase/visualizations/components/ChartSettings.jsx:205 +msgid "Cancel" +msgstr "" + +#: frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx:86 +#: frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx:121 +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:177 +msgid "Delete" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:141 +#: frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx:74 +#: frontend/src/metabase/admin/permissions/selectors.js:316 +#: frontend/src/metabase/admin/permissions/selectors.js:323 +#: frontend/src/metabase/admin/permissions/selectors.js:419 +#: frontend/src/metabase/reference/databases/DatabaseSidebar.jsx:18 +#: frontend/src/metabase/reference/databases/TableSidebar.jsx:18 +#: frontend/src/metabase/routes.jsx:376 +msgid "Databases" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:142 +msgid "Add Database" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:150 +msgid "Connection" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:150 +msgid "Scheduling" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:182 +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:78 +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:84 +#: frontend/src/metabase/admin/people/components/EditUserForm.jsx:237 +#: frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx:244 +#: frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx:257 +#: frontend/src/metabase/admin/settings/components/SettingsSlackForm.jsx:217 +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:194 +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:334 +#: frontend/src/metabase/reference/components/RevisionMessageModal.jsx:47 +msgid "Save changes" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:197 +#: frontend/src/metabase/admin/datamodel/components/database/MetricsList.jsx:38 +#: frontend/src/metabase/admin/datamodel/components/database/SegmentsList.jsx:38 +msgid "Actions" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:205 +msgid "Sync database schema now" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:206 +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:218 +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:783 +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:791 +#: frontend/src/metabase/admin/datamodel/containers/TableSettingsApp.jsx:112 +#: frontend/src/metabase/admin/datamodel/containers/TableSettingsApp.jsx:120 +msgid "Starting…" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:207 +msgid "Failed to sync" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:208 +msgid "Sync triggered!" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:217 +msgid "Re-scan field values now" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:219 +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:784 +#: frontend/src/metabase/admin/datamodel/containers/TableSettingsApp.jsx:113 +msgid "Failed to start scan" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:220 +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:785 +#: frontend/src/metabase/admin/datamodel/containers/TableSettingsApp.jsx:114 +msgid "Scan triggered!" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:227 +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:165 +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:377 +msgid "Danger Zone" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:233 +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:236 +msgid "Discard saved field values" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx:251 +msgid "Remove this database" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx:73 +msgid "Add database" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx:85 +#: frontend/src/metabase/admin/datamodel/components/database/MetricsList.jsx:36 +#: frontend/src/metabase/admin/datamodel/components/database/SegmentsList.jsx:36 +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:421 +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:183 +#: frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx:91 +#: frontend/src/metabase/components/CreateDashboardModal.jsx:90 +#: frontend/src/metabase/components/DatabaseDetailsForm.jsx:371 +#: frontend/src/metabase/containers/EntitySearch.jsx:24 +#: frontend/src/metabase/containers/SaveQuestionModal.jsx:215 +#: frontend/src/metabase/questions/containers/CollectionEditorForm.jsx:76 +#: frontend/src/metabase/questions/containers/LabelEditorForm.jsx:60 +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:463 +#: frontend/src/metabase/visualizations/visualizations/Scalar.jsx:62 +msgid "Name" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx:86 +msgid "Engine" +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx:115 +msgid "Deleting..." +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx:145 +msgid "Loading ..." +msgstr "" + +#: frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx:161 +msgid "Bring the sample dataset back" +msgstr "" + +#: frontend/src/metabase/admin/databases/database.js:180 +msgid "Couldn't connect to the database. Please check the connection details." +msgstr "" + +#: frontend/src/metabase/admin/databases/database.js:402 +msgid "Successfully created!" +msgstr "" + +#: frontend/src/metabase/admin/databases/database.js:412 +msgid "Successfully saved!" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/ObjectActionSelect.jsx:44 +#: frontend/src/metabase/parameters/components/ParameterWidget.jsx:173 +#: frontend/src/metabase/pulse/components/PulseListItem.jsx:54 +#: frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx:205 +#: frontend/src/metabase/questions/containers/EditLabels.jsx:119 +#: frontend/src/metabase/reference/components/EditButton.jsx:18 +msgid "Edit" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/ObjectActionSelect.jsx:59 +msgid "Revision History" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/ObjectRetireModal.jsx:33 +msgid "Retire this {0}?" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/ObjectRetireModal.jsx:38 +msgid "" +"Saved questions and other things that depend on this {0} will continue to " +"work, but this {1} will no longer be selectable from the query builder." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/ObjectRetireModal.jsx:39 +msgid "" +"If you're sure you want to retire this {0}, please write a quick explanation " +"of why it's being retired:" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/ObjectRetireModal.jsx:43 +msgid "" +"This will show up in the activity feed and in an email that will be sent to " +"anyone on your team who created something that uses this {0}." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/ObjectRetireModal.jsx:58 +msgid "Retire" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/ObjectRetireModal.jsx:59 +msgid "Retiring…" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/ObjectRetireModal.jsx:60 +msgid "Failed" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/ObjectRetireModal.jsx:61 +msgid "Success" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/PartialQueryBuilder.jsx:118 +#: frontend/src/metabase/public/components/widgets/AdvancedEmbedPane.jsx:110 +msgid "Preview" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/ColumnItem.jsx:97 +msgid "No column description yet" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/ColumnItem.jsx:133 +msgid "Select a field visibility" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/ColumnItem.jsx:189 +msgid "No special type" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/ColumnItem.jsx:190 +#: frontend/src/metabase/reference/components/Field.jsx:57 +#: frontend/src/metabase/reference/components/FieldTypeDetail.jsx:42 +msgid "Other" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/ColumnItem.jsx:208 +msgid "Select a special type" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/ColumnItem.jsx:222 +msgid "Select a target" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/ColumnsList.jsx:17 +msgid "Columns" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/ColumnsList.jsx:22 +#: frontend/src/metabase/admin/datamodel/components/database/MetadataSchema.jsx:41 +msgid "Column" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/ColumnsList.jsx:24 +#: frontend/src/metabase/admin/datamodel/components/database/MetadataTable.jsx:119 +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:206 +msgid "Visibility" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/ColumnsList.jsx:25 +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:217 +msgid "Type" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetadataHeader.jsx:85 +msgid "Current database:" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetadataHeader.jsx:90 +msgid "Show original schema" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetadataSchema.jsx:42 +msgid "Data Type" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetadataSchema.jsx:43 +msgid "Additional Info" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetadataSchemaList.jsx:45 +msgid "Find a schema" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetadataTable.jsx:80 +msgid "Why Hide?" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetadataTable.jsx:81 +msgid "Technical Data" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetadataTable.jsx:82 +msgid "Irrelevant/Cruft" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetadataTable.jsx:88 +msgid "Queryable" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetadataTable.jsx:89 +msgid "Hidden" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetadataTable.jsx:115 +msgid "No table description yet" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetadataTable.jsx:122 +msgid "Metadata Strength" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetadataTableList.jsx:104 +msgid "Find a table" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetadataTableList.jsx:117 +msgid "Schemas" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetricsList.jsx:24 +#: frontend/src/metabase/new_query/containers/NewQueryOptions.jsx:139 +#: frontend/src/metabase/reference/guide/BaseSidebar.jsx:33 +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:192 +#: frontend/src/metabase/reference/metrics/MetricList.jsx:56 +#: frontend/src/metabase/reference/metrics/MetricSidebar.jsx:18 +#: frontend/src/metabase/routes.jsx:235 +msgid "Metrics" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetricsList.jsx:30 +msgid "Add a Metric" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetricsList.jsx:37 +#: frontend/src/metabase/admin/datamodel/components/database/SegmentsList.jsx:37 +#: frontend/src/metabase/query_builder/components/QueryDefinitionTooltip.jsx:30 +msgid "Definition" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/MetricsList.jsx:54 +msgid "Create metrics to add them to the View dropdown in the query builder" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/SegmentsList.jsx:24 +#: frontend/src/metabase/query_builder/components/DataSelector.jsx:919 +#: frontend/src/metabase/reference/guide/BaseSidebar.jsx:39 +#: frontend/src/metabase/reference/segments/SegmentFieldSidebar.jsx:19 +#: frontend/src/metabase/reference/segments/SegmentList.jsx:56 +#: frontend/src/metabase/reference/segments/SegmentSidebar.jsx:18 +msgid "Segments" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/SegmentsList.jsx:30 +msgid "Add a Segment" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/database/SegmentsList.jsx:54 +msgid "Create segments to add them to the Filter dropdown in the query builder" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/revisions/Revision.jsx:24 +msgid "created" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/revisions/Revision.jsx:27 +msgid "reverted to a previous version" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/revisions/Revision.jsx:33 +msgid "edited the title" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/revisions/Revision.jsx:35 +msgid "edited the description" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/revisions/Revision.jsx:37 +msgid "edited the " +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/revisions/Revision.jsx:40 +msgid "made some changes" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/revisions/Revision.jsx:46 +#: frontend/src/metabase/home/components/Activity.jsx:80 +msgid "You" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/revisions/RevisionHistory.jsx:37 +msgid "Datamodel" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/revisions/RevisionHistory.jsx:43 +msgid " History" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/components/revisions/RevisionHistory.jsx:48 +msgid "Revision History for" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:188 +msgid "{0} – Field Settings" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:207 +msgid "Where this field will appear throughout Metabase" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:229 +msgid "Filtering on this field" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:230 +msgid "" +"When this field is used in a filter, what should people use to enter the " +"value they want to filter on?" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:337 +msgid "No description for this field yet" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:416 +msgid "Original value" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:417 +msgid "Mapped value" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:460 +msgid "Enter value" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:483 +msgid "Use original value" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:484 +msgid "Use foreign key" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:485 +msgid "Custom mapping" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:505 +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:605 +msgid "Unrecognized mapping type" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:539 +msgid "" +"Current field isn't a foreign key or FK target table metadata is missing" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:636 +msgid "The selected field isn't a foreign key" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:698 +msgid "Display values" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:699 +msgid "" +"Choose to show the original value from the database, or have this field " +"display associated or custom information." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:725 +msgid "Choose a field" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:746 +msgid "Please select a column to use for display." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:766 +msgid "Tip:" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:767 +msgid "" +"You might want to update the field name to make sure it still makes sense " +"based on your remapping choices." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:776 +#: frontend/src/metabase/admin/datamodel/containers/TableSettingsApp.jsx:105 +msgid "Cached field values" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:777 +msgid "" +"Metabase can scan the values for this field to enable checkbox filters in " +"dashboards and questions." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:782 +msgid "Re-scan this field" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:790 +#: frontend/src/metabase/admin/datamodel/containers/TableSettingsApp.jsx:119 +msgid "Discard cached field values" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:792 +#: frontend/src/metabase/admin/datamodel/containers/TableSettingsApp.jsx:121 +msgid "Failed to discard values" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:793 +#: frontend/src/metabase/admin/datamodel/containers/TableSettingsApp.jsx:122 +msgid "Discard triggered!" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetadataEditorApp.jsx:105 +msgid "Select any table to see its schema and add or edit metadata." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:37 +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:34 +#: frontend/src/metabase/questions/containers/CollectionEditorForm.jsx:19 +msgid "Name is required" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:40 +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:37 +msgid "Description is required" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:44 +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:41 +msgid "Revision message is required" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:50 +msgid "Aggregation is required" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:110 +msgid "Edit Your Metric" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:111 +msgid "Create Your Metric" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:115 +msgid "Make changes to your metric and leave an explanatory note." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:116 +msgid "" +"You can create saved metrics to add a named metric option to this table. " +"Saved metrics include the aggregation type, the aggregated field, and " +"optionally any filter you add. As an example, you might use this to create " +"something like the official way of calculating \"Average Price\" for an " +"Orders table." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:149 +msgid "Result: " +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:157 +msgid "Name Your Metric" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:158 +msgid "Give your metric a name to help others find it." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:162 +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:166 +msgid "Something descriptive but not too long" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:166 +msgid "Describe Your Metric" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:167 +msgid "" +"Give your metric a description to help others understand what it's about." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:171 +msgid "" +"This is a good place to be more specific about less obvious metric rules" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:175 +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:179 +msgid "Reason For Changes" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:177 +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:181 +msgid "" +"Leave a note to explain what changes you made and why they were required." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx:181 +msgid "" +"This will show up in the revision history for this metric to help everyone " +"remember why things changed" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:49 +msgid "At least one filter is required" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:116 +msgid "Edit Your Segment" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:117 +msgid "Create Your Segment" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:121 +msgid "Make changes to your segment and leave an explanatory note." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:122 +msgid "Select and add filters to create your new segment for the {0} table" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:161 +msgid "Name Your Segment" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:162 +msgid "Give your segment a name to help others find it." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:170 +msgid "Describe Your Segment" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:171 +msgid "" +"Give your segment a description to help others understand what it's about." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:175 +msgid "" +"This is a good place to be more specific about less obvious segment rules" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx:185 +msgid "" +"This will show up in the revision history for this segment to help everyone " +"remember why things changed" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/TableSettingsApp.jsx:91 +#: frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx:272 +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.jsx:99 +#: frontend/src/metabase/routes.jsx:419 +msgid "Settings" +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/TableSettingsApp.jsx:106 +msgid "" +"Metabase can scan the values in this table to enable checkbox filters in " +"dashboards and questions." +msgstr "" + +#: frontend/src/metabase/admin/datamodel/containers/TableSettingsApp.jsx:111 +msgid "Re-scan this table" +msgstr "" + +#: frontend/src/metabase/admin/people/components/AddRow.jsx:34 +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:188 +#: frontend/src/metabase/admin/settings/components/widgets/LdapGroupMappingsWidget.jsx:246 +msgid "Add" +msgstr "" + +#: frontend/src/metabase/admin/people/components/EditUserForm.jsx:78 +#: frontend/src/metabase/setup/components/UserStep.jsx:100 +#: frontend/src/metabase/user/components/UpdateUserDetails.jsx:65 +msgid "Not a valid formatted email address" +msgstr "" + +#: frontend/src/metabase/admin/people/components/EditUserForm.jsx:124 +#: frontend/src/metabase/setup/components/UserStep.jsx:182 +#: frontend/src/metabase/user/components/UpdateUserDetails.jsx:98 +msgid "First name" +msgstr "" + +#: frontend/src/metabase/admin/people/components/EditUserForm.jsx:145 +#: frontend/src/metabase/setup/components/UserStep.jsx:199 +#: frontend/src/metabase/user/components/UpdateUserDetails.jsx:115 +msgid "Last name" +msgstr "" + +#: frontend/src/metabase/admin/people/components/EditUserForm.jsx:167 +#: frontend/src/metabase/auth/containers/ForgotPasswordApp.jsx:77 +#: frontend/src/metabase/auth/containers/LoginApp.jsx:156 +#: frontend/src/metabase/components/NewsletterForm.jsx:90 +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:472 +#: frontend/src/metabase/setup/components/UserStep.jsx:217 +#: frontend/src/metabase/user/components/UpdateUserDetails.jsx:136 +msgid "Email address" +msgstr "" + +#: frontend/src/metabase/admin/people/components/EditUserForm.jsx:191 +msgid "Permission Groups" +msgstr "" + +#: frontend/src/metabase/admin/people/components/EditUserForm.jsx:227 +msgid "Make this user an admin" +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupDetail.jsx:31 +msgid "" +"All users belong to the {0} group and can't be removed from it. Setting " +"permissions for this group is a great way to\n" +"make sure you know what new Metabase users will be able to see." +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupDetail.jsx:40 +msgid "" +"This is a special group whose members can see everything in the Metabase " +"instance, and who can access and make changes to the\n" +"settings in the Admin Panel, including changing permissions! So, add people " +"to this group with care." +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupDetail.jsx:44 +msgid "" +"To make sure you don't get locked out of Metabase, there always has to be at " +"least one user in this group." +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupDetail.jsx:176 +#: frontend/src/metabase/admin/people/components/GroupsListing.jsx:212 +msgid "Members" +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupDetail.jsx:176 +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:423 +#: frontend/src/metabase/admin/settings/selectors.js:104 +#: frontend/src/metabase/lib/core.js:50 +#: frontend/src/metabase/pulse/components/PulseEditChannels.jsx:296 +msgid "Email" +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupDetail.jsx:202 +msgid "A group is only as good as its members." +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupSummary.jsx:16 +#: frontend/src/metabase/routes.jsx:373 +msgid "Admin" +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupSummary.jsx:36 +msgid "Default" +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupsListing.jsx:36 +msgid "Something like \"Marketing\"" +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupsListing.jsx:55 +msgid "Remove this group?" +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupsListing.jsx:57 +msgid "" +"Are you sure? All members of this group will lose any permissions settings " +"the have based on this group.\n" +"This can't be undone." +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupsListing.jsx:68 +#: frontend/src/metabase/components/ConfirmContent.jsx:14 +#: frontend/src/metabase/xray/components/InsightCard.jsx:21 +msgid "Yes" +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupsListing.jsx:71 +#: frontend/src/metabase/xray/components/InsightCard.jsx:27 +msgid "No" +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupsListing.jsx:89 +msgid "Edit Name" +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupsListing.jsx:92 +msgid "Remove Group" +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupsListing.jsx:212 +msgid "Group name" +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupsListing.jsx:389 +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:424 +#: frontend/src/metabase/admin/settings/components/widgets/LdapGroupMappingsWidget.jsx:147 +#: frontend/src/metabase/routes.jsx:412 +msgid "Groups" +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupsListing.jsx:390 +msgid "Create a group" +msgstr "" + +#: frontend/src/metabase/admin/people/components/GroupsListing.jsx:396 +msgid "" +"You can use groups to control your users' access to your data. Put users in " +"groups and then go to the Permissions section to control each group's " +"access. The Administrators and All Users groups are special default groups " +"that can't be removed." +msgstr "" + +#: frontend/src/metabase/admin/people/components/UserActionsSelect.jsx:79 +msgid "Edit Details" +msgstr "" + +#: frontend/src/metabase/admin/people/components/UserActionsSelect.jsx:85 +msgid "Re-send Invite" +msgstr "" + +#: frontend/src/metabase/admin/people/components/UserActionsSelect.jsx:90 +msgid "Reset Password" +msgstr "" + +#: frontend/src/metabase/admin/people/components/UserActionsSelect.jsx:97 +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:303 +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:201 +#: frontend/src/metabase/admin/settings/components/widgets/LdapGroupMappingsWidget.jsx:320 +#: frontend/src/metabase/parameters/components/ParameterWidget.jsx:177 +#: frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx:104 +msgid "Remove" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:186 +msgid "Who do you want to add?" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:203 +msgid "Edit {0}'s details" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:217 +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:254 +msgid "{0} has been added" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:221 +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:258 +msgid "Add another person" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:227 +msgid "" +"We couldn’t send them an email invitation,\n" +"so make sure to tell them to log in using {0}\n" +"and this password we’ve generated for them:" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:238 +msgid "If you want to be able to send email invites, just go to the {0} page." +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:266 +msgid "We’ve sent an invite to {0} with instructions to set their password." +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:280 +msgid "We've re-sent {0}'s invite" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:282 +#: frontend/src/metabase/query_builder/components/SavedQuestionIntroModal.jsx:22 +#: frontend/src/metabase/tutorial/Tutorial.jsx:245 +msgid "Okay" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:286 +msgid "Any previous email invites they have will no longer work." +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:297 +msgid "Remove {0}?" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:308 +msgid "{0} won't be able to log in anymore. This can't be undone." +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:322 +msgid "Reset {0}'s password?" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:328 +msgid "Reset" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:332 +#: frontend/src/metabase/components/ConfirmContent.jsx:10 +msgid "Are you sure you want to do this?" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:343 +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:367 +msgid "{0}'s password has been reset" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:353 +msgid "" +"Here’s a temporary password they can use to log in and then change their " +"password." +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:371 +msgid "We've sent them an email with instructions for creating a new password." +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:411 +#: frontend/src/metabase/routes.jsx:410 +msgid "People" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:412 +msgid "Add someone" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:425 +msgid "Last Login" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:447 +msgid "Signed up via Google" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:452 +msgid "Signed up via LDAP" +msgstr "" + +#: frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx:467 +msgid "Never" +msgstr "" + +#: frontend/src/metabase/admin/permissions/components/PermissionsConfirm.jsx:46 +msgid " will be " +msgstr "" + +#: frontend/src/metabase/admin/permissions/components/PermissionsConfirm.jsx:49 +msgid "given access to" +msgstr "" + +#: frontend/src/metabase/admin/permissions/components/PermissionsConfirm.jsx:54 +msgid " and " +msgstr "" + +#: frontend/src/metabase/admin/permissions/components/PermissionsConfirm.jsx:57 +msgid "denied access to" +msgstr "" + +#: frontend/src/metabase/admin/permissions/components/PermissionsConfirm.jsx:71 +msgid " will no longer able to " +msgstr "" + +#: frontend/src/metabase/admin/permissions/components/PermissionsConfirm.jsx:72 +msgid " will now be able to " +msgstr "" + +#: frontend/src/metabase/admin/permissions/components/PermissionsConfirm.jsx:80 +msgid " native queries for " +msgstr "" + +#: frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx:17 +#: frontend/src/metabase/admin/permissions/routes.jsx:12 +msgid "Permissions" +msgstr "" + +#: frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx:32 +msgid "Save permissions?" +msgstr "" + +#: frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx:38 +#: frontend/src/metabase/admin/settings/components/SettingsSingleSignOnForm.jsx:157 +msgid "Save Changes" +msgstr "" + +#: frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx:44 +msgid "Discard changes?" +msgstr "" + +#: frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx:46 +msgid "No changes to permissions will be made." +msgstr "" + +#: frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx:81 +msgid "You've made changes to permissions." +msgstr "" + +#: frontend/src/metabase/admin/permissions/containers/PermissionsApp.jsx:53 +msgid "You have unsaved changes" +msgstr "" + +#: frontend/src/metabase/admin/permissions/containers/PermissionsApp.jsx:54 +msgid "Do you want to leave this page and discard your changes?" +msgstr "" + +#: frontend/src/metabase/admin/permissions/permissions.js:146 +msgid "Sorry, an error occurred." +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:55 +msgid "" +"Administrators always have the highest level of access to everything in " +"Metabase." +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:57 +msgid "" +"Every Metabase user belongs to the All Users group. If you want to limit or " +"restrict a group's access to something, make sure the All Users group has an " +"equal or lower level of access." +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:59 +msgid "" +"MetaBot is Metabase's Slack bot. You can choose what it has access to here." +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:111 +msgid "" +"The \"{0}\" group may have access to a different set of {1} than this group, " +"which may give this group additional access to some {2}." +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:116 +msgid "" +"The \"{0}\" group has a higher level of access than this, which will " +"override this setting. You should limit or revoke the \"{1}\" group's access " +"to this item." +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:145 +msgid "{0} access even though \"{1}\" has greater access?" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:150 +#: frontend/src/metabase/admin/permissions/selectors.js:247 +msgid "Limit access" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:150 +#: frontend/src/metabase/admin/permissions/selectors.js:214 +#: frontend/src/metabase/admin/permissions/selectors.js:255 +msgid "Revoke access" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:159 +msgid "Change access to this database to limited?" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:160 +msgid "Change" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:173 +msgid "Allow Raw Query Writing?" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:174 +msgid "" +"This will also change this group's data access to Unrestricted for this " +"database." +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:175 +msgid "Allow" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:212 +msgid "Revoke access to all tables?" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:213 +msgid "" +"This will also revoke this group's access to raw queries for this database." +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:240 +msgid "Grant unrestricted access" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:241 +msgid "Unrestricted access" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:248 +msgid "Limited access" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:256 +msgid "No access" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:262 +msgid "Write raw queries" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:263 +msgid "Can write raw queries" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:270 +msgid "View raw queries" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:271 +msgid "Can view raw queries" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:277 +msgid "Curate collection" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:278 +msgid "Can add and remove questions from this collection" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:284 +msgid "View collection" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:285 +msgid "Can view questions in this collection" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:327 +#: frontend/src/metabase/admin/permissions/selectors.js:423 +#: frontend/src/metabase/admin/permissions/selectors.js:520 +msgid "Data Access" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:488 +#: frontend/src/metabase/admin/permissions/selectors.js:645 +#: frontend/src/metabase/admin/permissions/selectors.js:650 +msgid "View tables" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:586 +msgid "SQL Queries" +msgstr "" + +#: frontend/src/metabase/admin/permissions/selectors.js:656 +msgid "View schemas" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsAuthenticationOptions.jsx:11 +#: frontend/src/metabase/admin/settings/components/SettingsSingleSignOnForm.jsx:114 +msgid "Sign in with Google" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsAuthenticationOptions.jsx:13 +#: frontend/src/metabase/admin/settings/components/SettingsSingleSignOnForm.jsx:116 +msgid "" +"Allows users with existing Metabase accounts to login with a Google account " +"that matches their email address in addition to their Metabase username and " +"password." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsAuthenticationOptions.jsx:17 +#: frontend/src/metabase/admin/settings/components/SettingsAuthenticationOptions.jsx:29 +#: frontend/src/metabase/components/ChannelSetupMessage.jsx:32 +msgid "Configure" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsAuthenticationOptions.jsx:23 +#: frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx:270 +#: frontend/src/metabase/admin/settings/selectors.js:194 +msgid "LDAP" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsAuthenticationOptions.jsx:25 +msgid "" +"Allows users within your LDAP directory to log in to Metabase with their " +"LDAP credentials, and allows automatic mapping of LDAP groups to Metabase " +"groups." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx:75 +#: frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx:71 +#: frontend/src/metabase/admin/settings/components/SettingsSlackForm.jsx:67 +#: frontend/src/metabase/admin/settings/selectors.js:150 +msgid "That's not a valid email address" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx:79 +#: frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx:75 +#: frontend/src/metabase/admin/settings/components/SettingsSlackForm.jsx:71 +msgid "That's not a valid integer" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx:133 +#: frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx:136 +#: frontend/src/metabase/admin/settings/components/SettingsSlackForm.jsx:128 +msgid "Looks like we ran into some problems" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx:238 +msgid "Send test email" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx:239 +msgid "Sending..." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx:240 +msgid "Sent!" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx:246 +#: frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx:259 +#: frontend/src/metabase/admin/settings/components/SettingsSingleSignOnForm.jsx:157 +#: frontend/src/metabase/admin/settings/components/SettingsSlackForm.jsx:219 +msgid "Changes saved!" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx:80 +#: frontend/src/metabase/admin/settings/selectors.js:246 +msgid "Check your parentheses" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx:269 +#: frontend/src/metabase/admin/settings/components/SettingsSingleSignOnForm.jsx:109 +#: frontend/src/metabase/admin/settings/selectors.js:190 +msgid "Authentication" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx:274 +msgid "Server Settings" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx:276 +msgid "User Schema" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx:285 +msgid "Attributes" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx:292 +msgid "Group Schema" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsSetting.jsx:28 +msgid "Using " +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsSetupList.jsx:104 +msgid "Getting set up" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsSetupList.jsx:105 +msgid "A few things you can do to get the most out of Metabase." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsSetupList.jsx:114 +msgid "Recommended next step" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsSingleSignOnForm.jsx:110 +msgid "Google Sign-In" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsSingleSignOnForm.jsx:119 +msgid "" +"To allow users to sign in with Google you'll need to give Metabase a Google " +"Developers console application client ID. It only takes a few steps and " +"instructions on how to create a key can be found {0}" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsSingleSignOnForm.jsx:133 +msgid "Your Google client ID" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsSingleSignOnForm.jsx:138 +msgid "" +"Allow users to sign up on their own if their Google account email address is " +"from:" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsSlackForm.jsx:238 +msgid "Answers sent right to your Slack #channels" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsSlackForm.jsx:247 +msgid "Create a Slack Bot User for MetaBot" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsSlackForm.jsx:257 +msgid "" +"Once you're there, give it a name and click {0}. Then copy and paste the Bot " +"API Token into the field below. Once you are done, create a \"metabase_files" +"\" channel in Slack. Metabase needs this to upload graphs." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsUpdatesForm.jsx:88 +msgid "You're running Metabase {0} which is the latest and greatest!" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsUpdatesForm.jsx:97 +msgid "Metabase {0} is available. You're running {1}" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsUpdatesForm.jsx:110 +#: frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx:96 +#: frontend/src/metabase/questions/containers/CollectionEditorForm.jsx:39 +msgid "Update" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsUpdatesForm.jsx:114 +msgid "What's Changed:" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsXrayForm.jsx:28 +msgid "X-Rays and Comparisons" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsXrayForm.jsx:40 +msgid "Maximum Cost" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsXrayForm.jsx:42 +msgid "" +"If you're having performance issues related to x-ray usage you can cap how " +"expensive x-rays are allowed to be." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/SettingsXrayForm.jsx:45 +msgid "{0} \"Extended\" is required for viewing time series x-rays." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:131 +msgid "Add a map" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:184 +#: frontend/src/metabase/lib/core.js:100 +msgid "URL" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:199 +msgid "Delete custom map" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:226 +#: frontend/src/metabase/parameters/components/ParameterValueWidget.jsx:244 +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx:142 +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx:184 +msgid "Select…" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:241 +msgid "Sample values:" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:279 +msgid "Add a new map" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:279 +msgid "Edit map" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:280 +msgid "What do you want to call this map?" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:285 +msgid "e.g. United Kingdom, Brazil, Mars" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:292 +msgid "URL for the GeoJSON file you want to use" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:298 +msgid "Like https://my-mb-server.com/maps/my-map.json" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:309 +#: frontend/src/metabase/query_builder/components/RunButton.jsx:33 +msgid "Refresh" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:309 +msgid "Load" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:315 +msgid "Which property specifies the region’s identifier?" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:324 +msgid "Which property specifies the region’s display name?" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:345 +msgid "Load a GeoJSON file to see a preview" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:363 +msgid "Save map" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx:363 +msgid "Add map" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/EmbeddingLegalese.jsx:7 +msgid "Using embedding" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/EmbeddingLegalese.jsx:9 +msgid "" +"By enabling embedding you're agreeing to the embedding license located at" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/EmbeddingLegalese.jsx:19 +msgid "" +"In plain english, when you embed charts or dashboards from Metabase in your " +"own application, that application isn't subject to the Affero General Public " +"License that covers the rest of Metabase, provided you keep the Metabase " +"logo and the \"Powered by Metabase\" visible on those embeds. You should " +"however, read the license text linked above as that is the actual license " +"that you will be agreeing to by enabling this feature." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/EmbeddingLegalese.jsx:32 +msgid "Enable" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/EmbeddingLevel.jsx:14 +msgid "Premium embedding enabled" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/EmbeddingLevel.jsx:15 +msgid "Enter the token you bought from the Metabase Store" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/EmbeddingLevel.jsx:28 +msgid "" +"Premium embedding lets you disable \"Powered by Metabase\" on your embeded " +"dashboards and questions." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/EmbeddingLevel.jsx:35 +msgid "Buy a token" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/EmbeddingLevel.jsx:38 +msgid "Enter a token" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/LdapGroupMappingsWidget.jsx:127 +msgid "Edit Mappings" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/LdapGroupMappingsWidget.jsx:133 +msgid "Group Mappings" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/LdapGroupMappingsWidget.jsx:140 +msgid "Create a mapping" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/LdapGroupMappingsWidget.jsx:142 +msgid "" +"Mappings allow Metabase to automatically add and remove users from groups " +"based on the membership information provided by the\n" +"directory server. Membership to the Admin group can be granted through " +"mappings, but will not be automatically removed as a\n" +"failsafe measure." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/LdapGroupMappingsWidget.jsx:147 +msgid "Distinguished Name" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx:92 +msgid "Public Link" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx:93 +msgid "Revoke Link" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx:121 +msgid "Disable this link?" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx:122 +msgid "" +"They won't work any more, and can't be restored, but you can create new " +"links." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx:149 +msgid "Public Dashboard Listing" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx:152 +msgid "No dashboards have been publicly shared yet." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx:160 +msgid "Public Card Listing" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx:163 +msgid "No questions have been publicly shared yet." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx:172 +msgid "Embedded Dashboard Listing" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx:173 +msgid "No dashboards have been embedded yet." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx:183 +msgid "Embedded Card Listing" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx:184 +msgid "No questions have been embedded yet." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/SecretKeyWidget.jsx:35 +msgid "Regenerate embedding key?" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/SecretKeyWidget.jsx:36 +msgid "" +"This will cause existing embeds to stop working until they are updated with " +"the new key." +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/SecretKeyWidget.jsx:39 +msgid "Regenerate key" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/SecretKeyWidget.jsx:47 +msgid "Generate Key" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/SettingToggle.jsx:11 +#: frontend/src/metabase/admin/settings/selectors.js:75 +#: frontend/src/metabase/admin/settings/selectors.js:84 +msgid "Enabled" +msgstr "" + +#: frontend/src/metabase/admin/settings/components/widgets/SettingToggle.jsx:11 +#: frontend/src/metabase/admin/settings/selectors.js:80 +#: frontend/src/metabase/public/components/widgets/AdvancedSettingsPane.jsx:104 +msgid "Disabled" +msgstr "" + +#: frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx:116 +msgid "Unknown setting {0}" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:22 +msgid "Setup" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:26 +msgid "General" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:30 +msgid "Site Name" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:35 +msgid "Site URL" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:40 +msgid "Email Address for Help Requests" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:45 +msgid "Report Timezone" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:48 +msgid "Database Default" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:51 +msgid "Select a timezone" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:52 +msgid "" +"Not all databases support timezones, in which case this setting won't take " +"effect." +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:57 +msgid "Language" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:62 +msgid "Select a language" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:67 +msgid "Anonymous Tracking" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:72 +msgid "Friendly Table and Field Names" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:78 +msgid "Only replace underscores and dashes with spaces" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:88 +msgid "Enable Nested Queries" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:94 +msgid "Updates" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:98 +msgid "Check for updates" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:108 +msgid "SMTP Host" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:116 +msgid "SMTP Port" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:120 +#: frontend/src/metabase/admin/settings/selectors.js:216 +msgid "That's not a valid port number" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:124 +msgid "SMTP Security" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:132 +msgid "SMTP Username" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:139 +msgid "SMTP Password" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:146 +msgid "From Address" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:159 +msgid "Slack API Token" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:161 +msgid "Enter the token you received from Slack" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:178 +msgid "Single Sign-On" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:199 +msgid "LDAP Authentication" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:205 +msgid "LDAP Host" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:213 +msgid "LDAP Port" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:220 +msgid "LDAP Security" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:228 +msgid "Username or DN" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:233 +#: frontend/src/metabase/auth/containers/LoginApp.jsx:178 +#: frontend/src/metabase/user/components/UserSettings.jsx:72 +msgid "Password" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:238 +msgid "User search base" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:244 +msgid "User filter" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:250 +msgid "Email attribute" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:255 +msgid "First name attribute" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:260 +msgid "Last name attribute" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:265 +msgid "Synchronize group memberships" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:271 +msgid "\"Group search base" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:280 +msgid "Maps" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:284 +msgid "Map tile server URL" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:285 +msgid "Metabase uses OpenStreetMaps by default." +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:290 +msgid "Custom Maps" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:291 +msgid "" +"Add your own GeoJSON files to enable different region map visualizations" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:298 +msgid "Public Sharing" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:302 +msgid "Enable Public Sharing" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:307 +msgid "Shared Dashboards" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:313 +msgid "Shared Questions" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:320 +msgid "Embedding in other Applications" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:346 +msgid "Enable Embedding Metabase in other Applications" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:356 +msgid "Embedding secret key" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:362 +msgid "Embedded Dashboards" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:368 +msgid "Embedded Questions" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:375 +msgid "Caching" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:379 +msgid "Enable Caching" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:384 +msgid "Minimum Query Duration" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:391 +msgid "Cache Time-To-Live (TTL) multiplier" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:398 +msgid "Max Cache Entry Size" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:406 +msgid "X-Rays" +msgstr "" + +#: frontend/src/metabase/admin/settings/selectors.js:410 +msgid "Enable X-Rays" +msgstr "" + +#: frontend/src/metabase/alert/alert.js:66 +msgid "Your alert is all set up." +msgstr "" + +#: frontend/src/metabase/alert/alert.js:100 +msgid "Your alert was updated." +msgstr "" + +#: frontend/src/metabase/alert/alert.js:156 +msgid "The alert was successfully deleted." +msgstr "" + +#: frontend/src/metabase/auth/auth.js:33 +msgid "Please enter a valid formatted email address." +msgstr "" + +#: frontend/src/metabase/auth/auth.js:113 +#: frontend/src/metabase/setup/components/UserStep.jsx:107 +#: frontend/src/metabase/user/components/SetUserPassword.jsx:67 +msgid "Passwords do not match" +msgstr "" + +#: frontend/src/metabase/auth/components/BackToLogin.jsx:6 +msgid "Back to login" +msgstr "" + +#: frontend/src/metabase/auth/components/GoogleNoAccount.jsx:15 +msgid "No Metabase account exists for this Google account." +msgstr "" + +#: frontend/src/metabase/auth/components/GoogleNoAccount.jsx:17 +msgid "" +"You'll need an administrator to create a Metabase account before you can use " +"Google to log in." +msgstr "" + +#: frontend/src/metabase/auth/components/SSOLoginButton.jsx:18 +msgid "Sign in with {0}" +msgstr "" + +#: frontend/src/metabase/auth/containers/ForgotPasswordApp.jsx:56 +msgid "Please contact an administrator to have them reset your password" +msgstr "" + +#: frontend/src/metabase/auth/containers/ForgotPasswordApp.jsx:69 +msgid "Forgot password" +msgstr "" + +#: frontend/src/metabase/auth/containers/ForgotPasswordApp.jsx:84 +msgid "The email you use for your Metabase account" +msgstr "" + +#: frontend/src/metabase/auth/containers/ForgotPasswordApp.jsx:99 +msgid "Send password reset email" +msgstr "" + +#: frontend/src/metabase/auth/containers/ForgotPasswordApp.jsx:110 +msgid "Check your email for instructions on how to reset your password." +msgstr "" + +#: frontend/src/metabase/auth/containers/LoginApp.jsx:126 +msgid "Sign in to Metabase" +msgstr "" + +#: frontend/src/metabase/auth/containers/LoginApp.jsx:136 +msgid "OR" +msgstr "" + +#: frontend/src/metabase/auth/containers/LoginApp.jsx:155 +msgid "Username or email address" +msgstr "" + +#: frontend/src/metabase/auth/containers/LoginApp.jsx:195 +msgid "Remember Me:" +msgstr "" + +#: frontend/src/metabase/auth/containers/LoginApp.jsx:219 +msgid "I seem to have forgotten my password" +msgstr "" + +#: frontend/src/metabase/auth/containers/PasswordResetApp.jsx:112 +msgid "Whoops, that's an expired link" +msgstr "" + +#: frontend/src/metabase/auth/containers/PasswordResetApp.jsx:114 +msgid "" +"For security reasons, password reset links expire after a little while. If " +"you still need\n" +"to reset your password, you can <Link to=\"/auth/forgot_password\" className=" +"\"link\">request a new reset email</Link>." +msgstr "" + +#: frontend/src/metabase/auth/containers/PasswordResetApp.jsx:139 +#: frontend/src/metabase/user/components/SetUserPassword.jsx:122 +msgid "New password" +msgstr "" + +#: frontend/src/metabase/auth/containers/PasswordResetApp.jsx:141 +msgid "To keep your data secure, passwords {0}" +msgstr "" + +#: frontend/src/metabase/auth/containers/PasswordResetApp.jsx:155 +msgid "Create a new password" +msgstr "" + +#: frontend/src/metabase/auth/containers/PasswordResetApp.jsx:162 +#: frontend/src/metabase/user/components/SetUserPassword.jsx:137 +msgid "Make sure its secure like the instructions above" +msgstr "" + +#: frontend/src/metabase/auth/containers/PasswordResetApp.jsx:176 +#: frontend/src/metabase/user/components/SetUserPassword.jsx:146 +msgid "Confirm new password" +msgstr "" + +#: frontend/src/metabase/auth/containers/PasswordResetApp.jsx:183 +#: frontend/src/metabase/user/components/SetUserPassword.jsx:155 +msgid "Make sure it matches the one you just entered" +msgstr "" + +#: frontend/src/metabase/auth/containers/PasswordResetApp.jsx:208 +msgid "Your password has been reset." +msgstr "" + +#: frontend/src/metabase/auth/containers/PasswordResetApp.jsx:214 +#: frontend/src/metabase/auth/containers/PasswordResetApp.jsx:219 +msgid "Sign in with your new password" +msgstr "" + +#: frontend/src/metabase/components/ActionButton.jsx:53 +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:196 +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:269 +msgid "Save failed" +msgstr "" + +#: frontend/src/metabase/components/ActionButton.jsx:54 +#: frontend/src/metabase/components/SaveStatus.jsx:60 +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:197 +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:243 +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:270 +msgid "Saved" +msgstr "" + +#: frontend/src/metabase/components/Alert.jsx:12 +msgid "Ok" +msgstr "" + +#: frontend/src/metabase/components/Archived.jsx:10 +msgid "This {0} has been archived" +msgstr "" + +#: frontend/src/metabase/components/Archived.jsx:15 +#: frontend/src/metabase/dashboards/containers/Dashboards.jsx:170 +#: frontend/src/metabase/questions/containers/QuestionIndex.jsx:94 +msgid "View the archive" +msgstr "" + +#: frontend/src/metabase/components/ArchivedItem.jsx:24 +msgid "Unarchive this question" +msgstr "" + +#: frontend/src/metabase/components/ArchivedItem.jsx:25 +msgid "Unarchive this {0}" +msgstr "" + +#: frontend/src/metabase/components/Button.info.js:11 +#: frontend/src/metabase/components/Button.info.js:12 +#: frontend/src/metabase/components/Button.info.js:13 +msgid "Clickity click" +msgstr "" + +#: frontend/src/metabase/components/ButtonWithStatus.jsx:8 +msgid "Saved!" +msgstr "" + +#: frontend/src/metabase/components/ButtonWithStatus.jsx:9 +msgid "Saving failed." +msgstr "" + +#: frontend/src/metabase/components/Calendar.jsx:109 +msgid "Su" +msgstr "" + +#: frontend/src/metabase/components/Calendar.jsx:109 +msgid "Mo" +msgstr "" + +#: frontend/src/metabase/components/Calendar.jsx:109 +msgid "Tu" +msgstr "" + +#: frontend/src/metabase/components/Calendar.jsx:109 +msgid "We" +msgstr "" + +#: frontend/src/metabase/components/Calendar.jsx:109 +msgid "Th" +msgstr "" + +#: frontend/src/metabase/components/Calendar.jsx:109 +msgid "Fr" +msgstr "" + +#: frontend/src/metabase/components/Calendar.jsx:109 +msgid "Sa" +msgstr "" + +#: frontend/src/metabase/components/ChannelSetupMessage.jsx:41 +msgid "Your admin's email address" +msgstr "" + +#: frontend/src/metabase/components/ChannelSetupModal.jsx:37 +msgid "To send {0}, you'll need to set up {1} integration." +msgstr "" + +#: frontend/src/metabase/components/ChannelSetupModal.jsx:38 +#: frontend/src/metabase/components/ChannelSetupModal.jsx:41 +msgid " or " +msgstr "" + +#: frontend/src/metabase/components/ChannelSetupModal.jsx:40 +msgid "To send {0}, an admin needs to set up {1} integration." +msgstr "" + +#: frontend/src/metabase/components/CopyButton.jsx:35 +msgid "Copied!" +msgstr "" + +#: frontend/src/metabase/components/CreateDashboardModal.jsx:75 +msgid "Create dashboard" +msgstr "" + +#: frontend/src/metabase/components/CreateDashboardModal.jsx:83 +#: frontend/src/metabase/questions/containers/CollectionEditorForm.jsx:39 +msgid "Create" +msgstr "" + +#: frontend/src/metabase/components/CreateDashboardModal.jsx:97 +msgid "What is the name of your dashboard?" +msgstr "" + +#: frontend/src/metabase/components/CreateDashboardModal.jsx:105 +#: frontend/src/metabase/containers/SaveQuestionModal.jsx:229 +#: frontend/src/metabase/lib/core.js:45 +#: frontend/src/metabase/questions/containers/CollectionEditorForm.jsx:84 +#: frontend/src/metabase/reference/databases/DatabaseDetail.jsx:156 +#: frontend/src/metabase/reference/databases/FieldDetail.jsx:211 +#: frontend/src/metabase/reference/databases/TableDetail.jsx:189 +#: frontend/src/metabase/reference/metrics/MetricDetail.jsx:203 +#: frontend/src/metabase/reference/segments/SegmentDetail.jsx:207 +#: frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx:207 +#: frontend/src/metabase/visualizations/lib/settings.js:166 +msgid "Description" +msgstr "" + +#: frontend/src/metabase/components/CreateDashboardModal.jsx:112 +#: frontend/src/metabase/containers/SaveQuestionModal.jsx:236 +#: frontend/src/metabase/questions/containers/CollectionEditorForm.jsx:87 +msgid "It's optional but oh, so helpful" +msgstr "" + +#: frontend/src/metabase/components/DatabaseDetailsForm.jsx:212 +msgid "Use an SSH-tunnel for database connections" +msgstr "" + +#: frontend/src/metabase/components/DatabaseDetailsForm.jsx:214 +msgid "" +"Some database installations can only be accessed by connecting through an " +"SSH bastion host.\n" +"This option also provides an extra layer of security when a VPN is not " +"available.\n" +"Enabling this is usually slower than a direct connection." +msgstr "" + +#: frontend/src/metabase/components/DatabaseDetailsForm.jsx:243 +msgid "" +"This is a large database, so let me choose when Metabase syncs and scans" +msgstr "" + +#: frontend/src/metabase/components/DatabaseDetailsForm.jsx:245 +msgid "" +"By default, Metabase does a lightweight hourly sync, and an intensive daily " +"scan of field values.\n" +"If you have a large database, we recommend turning this on and reviewing " +"when and how often the field value scans happen." +msgstr "" + +#: frontend/src/metabase/components/DatabaseDetailsForm.jsx:261 +msgid "{0} to generate a Client ID and Client Secret for your project." +msgstr "" + +#: frontend/src/metabase/components/DatabaseDetailsForm.jsx:266 +msgid "Choose \"Other\" as the application type. Name it whatever you'd like." +msgstr "" + +#: frontend/src/metabase/components/DatabaseDetailsForm.jsx:288 +msgid "{0} to get an auth code" +msgstr "" + +#: frontend/src/metabase/components/DatabaseDetailsForm.jsx:300 +msgid "with Google Drive permissions" +msgstr "" + +#: frontend/src/metabase/components/DatabaseDetailsForm.jsx:320 +msgid "" +"To use Metabase with this data you must enable API access in the Google " +"Developers Console." +msgstr "" + +#: frontend/src/metabase/components/DatabaseDetailsForm.jsx:323 +msgid "{0} to go to the console if you haven't already done so." +msgstr "" + +#: frontend/src/metabase/components/DatabaseDetailsForm.jsx:372 +msgid "How would you like to refer to this database?" +msgstr "" + +#: frontend/src/metabase/components/DatabaseDetailsForm.jsx:399 +#: frontend/src/metabase/home/components/NewUserOnboardingModal.jsx:96 +#: frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx:240 +#: frontend/src/metabase/setup/components/DatabaseSchedulingStep.jsx:74 +#: frontend/src/metabase/setup/components/PreferencesStep.jsx:116 +#: frontend/src/metabase/setup/components/UserStep.jsx:303 +msgid "Next" +msgstr "" + +#: frontend/src/metabase/components/DeleteModalWithConfirm.jsx:79 +msgid "Delete this {0}" +msgstr "" + +#: frontend/src/metabase/components/EntityMenu.info.js:24 +#: frontend/src/metabase/components/EntityMenu.info.js:80 +msgid "Edit this question" +msgstr "" + +#: frontend/src/metabase/components/EntityMenu.info.js:26 +#: frontend/src/metabase/components/EntityMenu.info.js:47 +#: frontend/src/metabase/components/EntityMenu.info.js:82 +#: frontend/src/metabase/components/EntityMenu.info.js:99 +msgid "Action type" +msgstr "" + +#: frontend/src/metabase/components/EntityMenu.info.js:28 +#: frontend/src/metabase/components/EntityMenu.info.js:84 +msgid "View revision history" +msgstr "" + +#: frontend/src/metabase/components/EntityMenu.info.js:29 +#: frontend/src/metabase/components/EntityMenu.info.js:85 +#: frontend/src/metabase/questions/components/ActionHeader.jsx:57 +#: frontend/src/metabase/questions/containers/MoveToCollection.jsx:68 +msgid "Move" +msgstr "" + +#: frontend/src/metabase/components/EntityMenu.info.js:29 +#: frontend/src/metabase/components/EntityMenu.info.js:85 +msgid "Move action" +msgstr "" + +#: frontend/src/metabase/components/EntityMenu.info.js:31 +#: frontend/src/metabase/components/EntityMenu.info.js:87 +#: frontend/src/metabase/dashboards/components/DashboardList.jsx:40 +#: frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx:78 +#: frontend/src/metabase/query_builder/containers/ArchiveQuestionModal.jsx:40 +#: frontend/src/metabase/query_builder/containers/ArchiveQuestionModal.jsx:53 +#: frontend/src/metabase/questions/components/ActionHeader.jsx:71 +#: frontend/src/metabase/questions/components/Item.jsx:106 +#: frontend/src/metabase/questions/containers/Archive.jsx:61 +#: frontend/src/metabase/questions/containers/ArchiveCollectionWidget.jsx:51 +#: frontend/src/metabase/questions/containers/EntityList.jsx:100 +#: frontend/src/metabase/questions/selectors.js:223 +#: frontend/src/metabase/routes.jsx:250 +msgid "Archive" +msgstr "" + +#: frontend/src/metabase/components/EntityMenu.info.js:33 +#: frontend/src/metabase/components/EntityMenu.info.js:89 +msgid "Archive action" +msgstr "" + +#: frontend/src/metabase/components/EntityMenu.info.js:45 +#: frontend/src/metabase/components/EntityMenu.info.js:97 +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:343 +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:356 +msgid "Add to dashboard" +msgstr "" + +#: frontend/src/metabase/components/EntityMenu.info.js:49 +#: frontend/src/metabase/components/EntityMenu.info.js:101 +msgid "Download results" +msgstr "" + +#: frontend/src/metabase/components/EntityMenu.info.js:51 +#: frontend/src/metabase/components/EntityMenu.info.js:103 +#: frontend/src/metabase/public/components/widgets/EmbedWidget.jsx:52 +msgid "Sharing and embedding" +msgstr "" + +#: frontend/src/metabase/components/EntityMenu.info.js:53 +#: frontend/src/metabase/components/EntityMenu.info.js:105 +msgid "Another action type" +msgstr "" + +#: frontend/src/metabase/components/EntityMenu.info.js:65 +#: frontend/src/metabase/components/EntityMenu.info.js:67 +#: frontend/src/metabase/components/EntityMenu.info.js:113 +#: frontend/src/metabase/components/EntityMenu.info.js:115 +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:462 +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:467 +msgid "Get alerts about this" +msgstr "" + +#: frontend/src/metabase/components/EntityMenu.info.js:69 +#: frontend/src/metabase/components/EntityMenu.info.js:117 +msgid "View the SQL" +msgstr "" + +#: frontend/src/metabase/components/FieldValuesWidget.jsx:237 +msgid "Search the list" +msgstr "" + +#: frontend/src/metabase/components/FieldValuesWidget.jsx:241 +msgid "Search by {0}" +msgstr "" + +#: frontend/src/metabase/components/FieldValuesWidget.jsx:243 +msgid " or enter an ID" +msgstr "" + +#: frontend/src/metabase/components/FieldValuesWidget.jsx:247 +msgid "Enter an ID" +msgstr "" + +#: frontend/src/metabase/components/FieldValuesWidget.jsx:249 +msgid "Enter a number" +msgstr "" + +#: frontend/src/metabase/components/FieldValuesWidget.jsx:251 +msgid "Enter some text" +msgstr "" + +#: frontend/src/metabase/components/FieldValuesWidget.jsx:357 +msgid "No matching {0} found." +msgstr "" + +#: frontend/src/metabase/components/FieldValuesWidget.jsx:365 +msgid "Including every option in your filter probably won’t do much…" +msgstr "" + +#: frontend/src/metabase/components/Header.jsx:93 +#: frontend/src/metabase/components/HeaderBar.jsx:45 +#: frontend/src/metabase/components/ListItem.jsx:37 +#: frontend/src/metabase/components/SortableItemList.jsx:78 +#: frontend/src/metabase/questions/components/Item.jsx:191 +#: frontend/src/metabase/reference/components/Detail.jsx:47 +#: frontend/src/metabase/reference/databases/DatabaseDetail.jsx:158 +#: frontend/src/metabase/reference/databases/FieldDetail.jsx:213 +#: frontend/src/metabase/reference/databases/TableDetail.jsx:191 +#: frontend/src/metabase/reference/metrics/MetricDetail.jsx:205 +#: frontend/src/metabase/reference/segments/SegmentDetail.jsx:209 +#: frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx:209 +msgid "No description yet" +msgstr "" + +#: frontend/src/metabase/components/Header.jsx:108 +msgid "New {0}" +msgstr "" + +#: frontend/src/metabase/components/Header.jsx:119 +msgid "Asked by {0}" +msgstr "" + +#: frontend/src/metabase/components/HistoryModal.jsx:13 +msgid "Today, " +msgstr "" + +#: frontend/src/metabase/components/HistoryModal.jsx:15 +msgid "Yesterday, " +msgstr "" + +#: frontend/src/metabase/components/HistoryModal.jsx:68 +msgid "First revision." +msgstr "" + +#: frontend/src/metabase/components/HistoryModal.jsx:70 +msgid "Reverted to an earlier revision and {0}" +msgstr "" + +#: frontend/src/metabase/components/HistoryModal.jsx:80 +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:392 +#: frontend/src/metabase/reference/segments/SegmentSidebar.jsx:53 +msgid "Revision history" +msgstr "" + +#: frontend/src/metabase/components/HistoryModal.jsx:91 +msgid "When" +msgstr "" + +#: frontend/src/metabase/components/HistoryModal.jsx:92 +msgid "Who" +msgstr "" + +#: frontend/src/metabase/components/HistoryModal.jsx:93 +msgid "What" +msgstr "" + +#: frontend/src/metabase/components/HistoryModal.jsx:114 +msgid "Revert" +msgstr "" + +#: frontend/src/metabase/components/HistoryModal.jsx:115 +msgid "Reverting…" +msgstr "" + +#: frontend/src/metabase/components/HistoryModal.jsx:116 +msgid "Revert failed" +msgstr "" + +#: frontend/src/metabase/components/HistoryModal.jsx:117 +msgid "Reverted" +msgstr "" + +#: frontend/src/metabase/components/LeftNavPane.jsx:36 +#: frontend/src/metabase/query_builder/components/dataref/DataReference.jsx:86 +msgid "Back" +msgstr "" + +#: frontend/src/metabase/components/ListSearchField.jsx:18 +msgid "Find..." +msgstr "" + +#: frontend/src/metabase/components/LoadingAndErrorWrapper.jsx:47 +msgid "An error occured" +msgstr "" + +#: frontend/src/metabase/components/LoadingAndErrorWrapper.jsx:36 +msgid "Loading..." +msgstr "" + +#: frontend/src/metabase/components/NewsletterForm.jsx:70 +msgid "Metabase Newsletter" +msgstr "" + +#: frontend/src/metabase/components/NewsletterForm.jsx:77 +msgid "Get infrequent emails about new releases and feature updates." +msgstr "" + +#: frontend/src/metabase/components/NewsletterForm.jsx:95 +msgid "Subscribe" +msgstr "" + +#: frontend/src/metabase/components/NewsletterForm.jsx:102 +msgid "You're subscribed. Thanks for using Metabase!" +msgstr "" + +#: frontend/src/metabase/components/NotFound.jsx:11 +msgid "We're a little lost..." +msgstr "" + +#: frontend/src/metabase/components/NotFound.jsx:13 +msgid "The page you asked for couldn't be found" +msgstr "" + +#: frontend/src/metabase/components/NotFound.jsx:15 +msgid "" +"You might've been tricked by a ninja, but in all likelihood, you were just " +"given a bad link." +msgstr "" + +#: frontend/src/metabase/components/NotFound.jsx:16 +msgid "You can always:" +msgstr "" + +#: frontend/src/metabase/components/NotFound.jsx:19 +msgid "Ask a new question." +msgstr "" + +#: frontend/src/metabase/components/NotFound.jsx:21 +msgid "or" +msgstr "" + +#: frontend/src/metabase/components/NotFound.jsx:29 +msgid "Take a kitten break." +msgstr "" + +#: frontend/src/metabase/components/PasswordReveal.jsx:26 +msgid "Temporary Password" +msgstr "" + +#: frontend/src/metabase/components/PasswordReveal.jsx:67 +msgid "Hide" +msgstr "" + +#: frontend/src/metabase/components/PasswordReveal.jsx:67 +msgid "Show" +msgstr "" + +#: frontend/src/metabase/components/QuestionSavedModal.jsx:17 +msgid "Saved! Add this to a dashboard?" +msgstr "" + +#: frontend/src/metabase/components/QuestionSavedModal.jsx:25 +msgid "Yes please!" +msgstr "" + +#: frontend/src/metabase/components/QuestionSavedModal.jsx:29 +msgid "Not now" +msgstr "" + +#: frontend/src/metabase/components/SaveStatus.jsx:53 +msgid "Error:" +msgstr "" + +#: frontend/src/metabase/components/SchedulePicker.jsx:23 +msgid "\"Sunday" +msgstr "" + +#: frontend/src/metabase/components/SchedulePicker.jsx:24 +msgid "Monday" +msgstr "" + +#: frontend/src/metabase/components/SchedulePicker.jsx:25 +msgid "Tuesday" +msgstr "" + +#: frontend/src/metabase/components/SchedulePicker.jsx:26 +msgid "Wednesday" +msgstr "" + +#: frontend/src/metabase/components/SchedulePicker.jsx:27 +msgid "Thursday" +msgstr "" + +#: frontend/src/metabase/components/SchedulePicker.jsx:28 +msgid "Friday" +msgstr "" + +#: frontend/src/metabase/components/SchedulePicker.jsx:29 +msgid "Saturday" +msgstr "" + +#: frontend/src/metabase/components/SchedulePicker.jsx:33 +msgid "First" +msgstr "" + +#: frontend/src/metabase/components/SchedulePicker.jsx:34 +msgid "Last" +msgstr "" + +#: frontend/src/metabase/components/SchedulePicker.jsx:35 +msgid "15th (Midpoint)" +msgstr "" + +#: frontend/src/metabase/components/SchedulePicker.jsx:125 +msgid "Calendar Day" +msgstr "" + +#: frontend/src/metabase/components/SchedulePicker.jsx:210 +msgid "your Metabase timezone" +msgstr "" + +#: frontend/src/metabase/components/SearchHeader.jsx:21 +msgid "Filter this list..." +msgstr "" + +#: frontend/src/metabase/components/Select.info.js:8 +msgid "Blue" +msgstr "" + +#: frontend/src/metabase/components/Select.info.js:9 +msgid "Green" +msgstr "" + +#: frontend/src/metabase/components/Select.info.js:10 +msgid "Red" +msgstr "" + +#: frontend/src/metabase/components/Select.info.js:11 +msgid "Yellow" +msgstr "" + +#: frontend/src/metabase/components/Select.info.js:14 +msgid "A component used to make a selection" +msgstr "" + +#: frontend/src/metabase/components/Select.info.js:20 +#: frontend/src/metabase/components/Select.info.js:25 +msgid "Selected" +msgstr "" + +#: frontend/src/metabase/components/Select.jsx:280 +msgid "Nothing to select" +msgstr "" + +#: frontend/src/metabase/components/Unauthorized.jsx:10 +msgid "Sorry, you don’t have permission to see that." +msgstr "" + +#: frontend/src/metabase/components/form/FormMessage.jsx:5 +msgid "Unknown error encountered" +msgstr "" + +#: frontend/src/metabase/containers/AddToDashSelectDashModal.jsx:79 +msgid "Add Question to Dashboard" +msgstr "" + +#: frontend/src/metabase/containers/AddToDashSelectDashModal.jsx:92 +msgid "Add to new dashboard" +msgstr "" + +#: frontend/src/metabase/containers/EntitySearch.jsx:33 +#: frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx:318 +#: frontend/src/metabase/visualizations/visualizations/Table.jsx:40 +#: frontend/src/metabase/xray/containers/TableXRay.jsx:61 +msgid "Table" +msgstr "" + +#: frontend/src/metabase/containers/EntitySearch.jsx:40 +#: frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx:293 +msgid "Database" +msgstr "" + +#: frontend/src/metabase/containers/EntitySearch.jsx:47 +msgid "Creator" +msgstr "" + +#: frontend/src/metabase/containers/EntitySearch.jsx:236 +#: frontend/src/metabase/dashboards/containers/Dashboards.jsx:221 +#: frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx:118 +msgid "No results found" +msgstr "" + +#: frontend/src/metabase/containers/EntitySearch.jsx:237 +#: frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx:119 +msgid "Try adjusting your filter to find what you’re looking for." +msgstr "" + +#: frontend/src/metabase/containers/EntitySearch.jsx:256 +msgid "View by" +msgstr "" + +#: frontend/src/metabase/containers/EntitySearch.jsx:488 +#: frontend/src/metabase/query_builder/components/AggregationWidget.jsx:69 +#: frontend/src/metabase/tutorial/TutorialModal.jsx:34 +msgid "of" +msgstr "" + +#: frontend/src/metabase/containers/SaveQuestionModal.jsx:162 +msgid "Replace or save as new?" +msgstr "" + +#: frontend/src/metabase/containers/SaveQuestionModal.jsx:171 +msgid "Replace original question, \"{0}\"" +msgstr "" + +#: frontend/src/metabase/containers/SaveQuestionModal.jsx:176 +msgid "Save as new question" +msgstr "" + +#: frontend/src/metabase/containers/SaveQuestionModal.jsx:185 +msgid "First, save your question" +msgstr "" + +#: frontend/src/metabase/containers/SaveQuestionModal.jsx:186 +msgid "Save question" +msgstr "" + +#: frontend/src/metabase/containers/SaveQuestionModal.jsx:222 +msgid "What is the name of your card?" +msgstr "" + +#: frontend/src/metabase/containers/SaveQuestionModal.jsx:245 +msgid "Which collection should this go in?" +msgstr "" + +#: frontend/src/metabase/containers/SaveQuestionModal.jsx:256 +#: frontend/src/metabase/query_builder/components/LimitWidget.jsx:27 +#: frontend/src/metabase/questions/containers/MoveToCollection.jsx:81 +msgid "None" +msgstr "" + +#: frontend/src/metabase/containers/UndoListing.jsx:58 +msgid "Undo" +msgstr "" + +#: frontend/src/metabase/dashboards/components/DashboardList.jsx:40 +#: frontend/src/metabase/questions/components/ActionHeader.jsx:71 +#: frontend/src/metabase/questions/components/Item.jsx:106 +msgid "Unarchive" +msgstr "" + +#: frontend/src/metabase/dashboards/components/DashboardList.jsx:57 +#: frontend/src/metabase/questions/components/Item.jsx:170 +msgid "Unfavorite" +msgstr "" + +#: frontend/src/metabase/dashboards/components/DashboardList.jsx:57 +#: frontend/src/metabase/questions/components/Item.jsx:170 +msgid "Favorite" +msgstr "" + +#: frontend/src/metabase/dashboards/containers/Dashboards.jsx:46 +msgid "All dashboards" +msgstr "" + +#: frontend/src/metabase/dashboards/containers/Dashboards.jsx:52 +#: frontend/src/metabase/questions/containers/EntityList.jsx:76 +#: frontend/src/metabase/questions/selectors.js:155 +msgid "Favorites" +msgstr "" + +#: frontend/src/metabase/dashboards/containers/Dashboards.jsx:58 +#: frontend/src/metabase/questions/containers/EntityList.jsx:88 +#: frontend/src/metabase/questions/selectors.js:157 +msgid "Saved by me" +msgstr "" + +#: frontend/src/metabase/dashboards/containers/Dashboards.jsx:163 +#: frontend/src/metabase/nav/containers/Navbar.jsx:163 +#: frontend/src/metabase/routes.jsx:209 frontend/src/metabase/routes.jsx:214 +msgid "Dashboards" +msgstr "" + +#: frontend/src/metabase/dashboards/containers/Dashboards.jsx:179 +msgid "Add new dashboard" +msgstr "" + +#: frontend/src/metabase/dashboards/containers/Dashboards.jsx:190 +msgid "" +"Put the charts and graphs you look at {0}frequently in a single, handy place." +msgstr "" + +#: frontend/src/metabase/dashboards/containers/Dashboards.jsx:195 +#: frontend/src/metabase/dashboards/containers/Dashboards.jsx:228 +msgid "Create a dashboard" +msgstr "" + +#: frontend/src/metabase/dashboards/containers/Dashboards.jsx:222 +msgid "" +"Try adjusting your filter to find what you’re\n" +"looking for." +msgstr "" + +#: frontend/src/metabase/dashboards/containers/DashboardsArchive.jsx:96 +msgid "No dashboards have been {0} archived yet" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:92 +msgid "did some super awesome stuff thats hard to describe" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:101 +#: frontend/src/metabase/home/components/Activity.jsx:116 +msgid "created an alert about - " +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:126 +msgid "deleted an alert about - " +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:141 +msgid "deleted an alert about- " +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:152 +msgid "saved a question about " +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:165 +msgid "saved a question" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:169 +msgid "deleted a question" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:172 +msgid "created a dashboard" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:175 +msgid "deleted a dashboard" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:181 +#: frontend/src/metabase/home/components/Activity.jsx:196 +msgid "added a question to the dashboard - " +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:206 +#: frontend/src/metabase/home/components/Activity.jsx:221 +msgid "removed a question from the dashboard - " +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:234 +#: frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx:267 +msgid "Unknown" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:238 +#: frontend/src/metabase/home/components/Activity.jsx:245 +msgid "received the latest data from" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:252 msgid "Hello World!" msgstr "" -#: frontend/src/metabase/home/components/Activity.jsx:132 -msgid "Metabase is up and running." +#: frontend/src/metabase/home/components/Activity.jsx:253 +msgid "Metabase is up and running." +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:259 +#: frontend/src/metabase/home/components/Activity.jsx:289 +msgid "added the metric " +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:273 +#: frontend/src/metabase/home/components/Activity.jsx:363 +msgid " to the " +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:283 +#: frontend/src/metabase/home/components/Activity.jsx:323 +#: frontend/src/metabase/home/components/Activity.jsx:373 +#: frontend/src/metabase/home/components/Activity.jsx:414 +msgid " table" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:299 +#: frontend/src/metabase/home/components/Activity.jsx:329 +msgid "made changes to the metric " +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:313 +#: frontend/src/metabase/home/components/Activity.jsx:404 +msgid " in the " +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:336 +msgid "removed the metric " +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:339 +msgid "created a pulse" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:342 +msgid "deleted a pulse" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:348 +msgid "added the filter " +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:379 +msgid "added the filter" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:389 +msgid "made changes to the filter " +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:420 +msgid "made changes to the filter" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:427 +msgid "removed the filter {0}" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:430 +msgid "joined!" +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:530 +msgid "Hmmm, looks like nothing has happened yet." +msgstr "" + +#: frontend/src/metabase/home/components/Activity.jsx:533 +msgid "Save a question and get this baby going!" +msgstr "" + +#: frontend/src/metabase/home/components/NewUserOnboardingModal.jsx:18 +msgid "Ask questions and explore" +msgstr "" + +#: frontend/src/metabase/home/components/NewUserOnboardingModal.jsx:19 +msgid "" +"Click on charts or tables to explore, or ask a new question using the easy " +"interface or the powerful SQL editor." +msgstr "" + +#: frontend/src/metabase/home/components/NewUserOnboardingModal.jsx:29 +msgid "Make your own charts" +msgstr "" + +#: frontend/src/metabase/home/components/NewUserOnboardingModal.jsx:30 +msgid "Create line charts, scatter plots, maps, and more." +msgstr "" + +#: frontend/src/metabase/home/components/NewUserOnboardingModal.jsx:40 +msgid "Share what you find" +msgstr "" + +#: frontend/src/metabase/home/components/NewUserOnboardingModal.jsx:41 +msgid "" +"Create powerful and flexible dashboards, and send regular updates via email " +"or Slack." +msgstr "" + +#: frontend/src/metabase/home/components/NewUserOnboardingModal.jsx:96 +msgid "Let's go" +msgstr "" + +#: frontend/src/metabase/home/components/NextStep.jsx:34 +msgid "Setup Tip" +msgstr "" + +#: frontend/src/metabase/home/components/NextStep.jsx:40 +msgid "View all" +msgstr "" + +#: frontend/src/metabase/home/components/RecentViews.jsx:39 +msgid "Recently Viewed" +msgstr "" + +#: frontend/src/metabase/home/components/RecentViews.jsx:73 +msgid "You haven't looked at any dashboards or questions recently" +msgstr "" + +#: frontend/src/metabase/home/containers/HomepageApp.jsx:84 +msgid "Activity" +msgstr "" + +#: frontend/src/metabase/lib/core.js:7 +msgid "Entity Key" +msgstr "" + +#: frontend/src/metabase/lib/core.js:9 +msgid "The primary key for this table." +msgstr "" + +#: frontend/src/metabase/lib/core.js:13 +msgid "Entity Name" +msgstr "" + +#: frontend/src/metabase/lib/core.js:15 +msgid "" +"The \"name\" of each record. Usually a column called \"name\", \"title\", " +"etc." +msgstr "" + +#: frontend/src/metabase/lib/core.js:19 +msgid "Foreign Key" +msgstr "" + +#: frontend/src/metabase/lib/core.js:21 +msgid "Points to another table to make a connection." +msgstr "" + +#: frontend/src/metabase/lib/core.js:25 +msgid "Avatar Image URL" +msgstr "" + +#: frontend/src/metabase/lib/core.js:30 +#: frontend/src/metabase/meta/Dashboard.js:82 +#: frontend/src/metabase/qb/components/actions/PivotByCategoryAction.jsx:9 +msgid "Category" +msgstr "" + +#: frontend/src/metabase/lib/core.js:35 +#: frontend/src/metabase/meta/Dashboard.js:62 +msgid "City" +msgstr "" + +#: frontend/src/metabase/lib/core.js:40 +#: frontend/src/metabase/meta/Dashboard.js:74 +msgid "Country" +msgstr "" + +#: frontend/src/metabase/lib/core.js:55 +msgid "Enum" +msgstr "" + +#: frontend/src/metabase/lib/core.js:60 +msgid "Image URL" +msgstr "" + +#: frontend/src/metabase/lib/core.js:65 +msgid "Field containing JSON" +msgstr "" + +#: frontend/src/metabase/lib/core.js:70 +msgid "Latitude" +msgstr "" + +#: frontend/src/metabase/lib/core.js:75 +msgid "Longitude" +msgstr "" + +#: frontend/src/metabase/lib/core.js:80 +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx:146 +#: frontend/src/metabase/visualizations/visualizations/Scalar.jsx:22 +msgid "Number" +msgstr "" + +#: frontend/src/metabase/lib/core.js:85 +#: frontend/src/metabase/meta/Dashboard.js:66 +msgid "State" +msgstr "" + +#: frontend/src/metabase/lib/core.js:90 +msgid "UNIX Timestamp (Seconds)" +msgstr "" + +#: frontend/src/metabase/lib/core.js:95 +msgid "UNIX Timestamp (Milliseconds)" +msgstr "" + +#: frontend/src/metabase/lib/core.js:105 +msgid "Zip Code" +msgstr "" + +#: frontend/src/metabase/lib/core.js:118 +msgid "Everywhere" +msgstr "" + +#: frontend/src/metabase/lib/core.js:119 +msgid "" +"The default setting. This field will be displayed normally in tables and " +"charts." +msgstr "" + +#: frontend/src/metabase/lib/core.js:123 +msgid "Only in Detail Views" +msgstr "" + +#: frontend/src/metabase/lib/core.js:124 +msgid "" +"This field will only be displayed when viewing the details of a single " +"record. Use this for information that's lengthy or that isn't useful in a " +"table or chart." +msgstr "" + +#: frontend/src/metabase/lib/core.js:128 +msgid "Do Not Include" +msgstr "" + +#: frontend/src/metabase/lib/core.js:129 +msgid "" +"Metabase will never retrieve this field. Use this for sensitive or " +"irrelevant information." +msgstr "" + +#: frontend/src/metabase/lib/expressions/config.js:7 +#: frontend/src/metabase/lib/query.js:609 +#: frontend/src/metabase/visualizations/lib/utils.js:113 +#: frontend/src/metabase/xray/components/XRayComparison.jsx:193 +msgid "Count" +msgstr "" + +#: frontend/src/metabase/lib/expressions/config.js:8 +msgid "CumulativeCount" +msgstr "" + +#: frontend/src/metabase/lib/expressions/config.js:9 +#: frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js:17 +#: frontend/src/metabase/visualizations/lib/utils.js:114 +msgid "Sum" +msgstr "" + +#: frontend/src/metabase/lib/expressions/config.js:10 +msgid "CumulativeSum" +msgstr "" + +#: frontend/src/metabase/lib/expressions/config.js:11 +#: frontend/src/metabase/visualizations/lib/utils.js:115 +msgid "Distinct" +msgstr "" + +#: frontend/src/metabase/lib/expressions/config.js:12 +msgid "StandardDeviation" +msgstr "" + +#: frontend/src/metabase/lib/expressions/config.js:13 +#: frontend/src/metabase/visualizations/lib/utils.js:112 +msgid "Average" +msgstr "" + +#: frontend/src/metabase/lib/expressions/config.js:14 +#: frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js:25 +#: frontend/src/metabase/visualizations/lib/settings/graph.js:335 +msgid "Min" +msgstr "" + +#: frontend/src/metabase/lib/expressions/config.js:15 +#: frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js:29 +#: frontend/src/metabase/visualizations/lib/settings/graph.js:343 +msgid "Max" +msgstr "" + +#: frontend/src/metabase/lib/expressions/parser.js:384 +msgid "sad sad panda, lexing errors detected" +msgstr "" + +#: frontend/src/metabase/lib/greeting.js:4 +msgid "Hey there" +msgstr "" + +#: frontend/src/metabase/lib/greeting.js:5 +#: frontend/src/metabase/lib/greeting.js:29 +msgid "How's it going" +msgstr "" + +#: frontend/src/metabase/lib/greeting.js:6 +msgid "Howdy" +msgstr "" + +#: frontend/src/metabase/lib/greeting.js:7 +msgid "Greetings" +msgstr "" + +#: frontend/src/metabase/lib/greeting.js:8 +msgid "Good to see you" +msgstr "" + +#: frontend/src/metabase/lib/greeting.js:12 +msgid "What do you want to know?" +msgstr "" + +#: frontend/src/metabase/lib/greeting.js:13 +msgid "What's on your mind?" +msgstr "" + +#: frontend/src/metabase/lib/greeting.js:14 +msgid "What do you want to find out?" +msgstr "" + +#: frontend/src/metabase/lib/query.js:607 +#: frontend/src/metabase/lib/schema_metadata.js:412 +#: frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx:244 +msgid "Raw data" +msgstr "" + +#: frontend/src/metabase/lib/query.js:611 +msgid "Cumulative count" +msgstr "" + +#: frontend/src/metabase/lib/query.js:614 +msgid "Average of " +msgstr "" + +#: frontend/src/metabase/lib/query.js:619 +msgid "Distinct values of " +msgstr "" + +#: frontend/src/metabase/lib/query.js:624 +msgid "Standard deviation of " +msgstr "" + +#: frontend/src/metabase/lib/query.js:629 +msgid "Sum of " +msgstr "" + +#: frontend/src/metabase/lib/query.js:634 +msgid "Cumulative sum of " +msgstr "" + +#: frontend/src/metabase/lib/query.js:639 +msgid "Maximum of " +msgstr "" + +#: frontend/src/metabase/lib/query.js:644 +msgid "Minimum of " +msgstr "" + +#: frontend/src/metabase/lib/query.js:658 +msgid "Grouped by " +msgstr "" + +#: frontend/src/metabase/lib/query.js:672 +msgid "Filtered by " +msgstr "" + +#: frontend/src/metabase/lib/query.js:700 +msgid "Sorted by " +msgstr "" + +#: frontend/src/metabase/lib/request.js:216 +msgid "Background job result isn't available for an unknown reason" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:198 +msgid "True" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:198 +msgid "False" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:273 +msgid "Select longitude field" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:274 +msgid "Enter upper latitude" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:275 +msgid "Enter left longitude" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:276 +msgid "Enter lower latitude" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:277 +msgid "Enter right longitude" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:306 +#: frontend/src/metabase/lib/schema_metadata.js:326 +#: frontend/src/metabase/lib/schema_metadata.js:336 +#: frontend/src/metabase/lib/schema_metadata.js:342 +#: frontend/src/metabase/lib/schema_metadata.js:350 +#: frontend/src/metabase/lib/schema_metadata.js:356 +#: frontend/src/metabase/lib/schema_metadata.js:361 +msgid "Is" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:307 +#: frontend/src/metabase/lib/schema_metadata.js:327 +#: frontend/src/metabase/lib/schema_metadata.js:337 +#: frontend/src/metabase/lib/schema_metadata.js:351 +#: frontend/src/metabase/lib/schema_metadata.js:357 +msgid "Is not" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:308 +#: frontend/src/metabase/lib/schema_metadata.js:322 +#: frontend/src/metabase/lib/schema_metadata.js:330 +#: frontend/src/metabase/lib/schema_metadata.js:338 +#: frontend/src/metabase/lib/schema_metadata.js:346 +#: frontend/src/metabase/lib/schema_metadata.js:352 +#: frontend/src/metabase/lib/schema_metadata.js:362 +msgid "Is empty" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:309 +#: frontend/src/metabase/lib/schema_metadata.js:323 +#: frontend/src/metabase/lib/schema_metadata.js:331 +#: frontend/src/metabase/lib/schema_metadata.js:339 +#: frontend/src/metabase/lib/schema_metadata.js:347 +#: frontend/src/metabase/lib/schema_metadata.js:353 +#: frontend/src/metabase/lib/schema_metadata.js:363 +msgid "Not empty" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:315 +msgid "Equal" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:316 +msgid "Not equal" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:317 +msgid "Greater than" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:318 +msgid "Less than" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:319 +#: frontend/src/metabase/lib/schema_metadata.js:345 +#: frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx:289 +#: frontend/src/metabase/query_builder/components/filters/pickers/TimePicker.jsx:96 +msgid "Between" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:320 +msgid "Greater than or equal to" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:321 +msgid "Less than or equal to" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:328 +msgid "Contains" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:329 +msgid "Does not contain" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:332 +msgid "Starts with" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:333 +msgid "Ends with" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:343 +#: frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx:268 +#: frontend/src/metabase/query_builder/components/filters/pickers/TimePicker.jsx:74 +msgid "Before" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:344 +#: frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx:275 +#: frontend/src/metabase/query_builder/components/filters/pickers/TimePicker.jsx:85 +msgid "After" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:358 +msgid "Inside" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:414 +msgid "Just a table with the rows in the answer, no additional operations." +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:420 +msgid "Count of rows" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:422 +msgid "Total number of rows in the answer." +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:428 +msgid "Sum of ..." +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:430 +msgid "Sum of all the values of a column." +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:436 +msgid "Average of ..." +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:438 +msgid "Average of all the values of a column" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:444 +msgid "Number of distinct values of ..." +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:446 +msgid "Number of unique values of a column among all the rows in the answer." +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:452 +msgid "Cumulative sum of ..." +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:454 +msgid "" +"Additive sum of all the values of a column.\\ne.x. total revenue over time." +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:460 +msgid "Cumulative count of rows" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:462 +msgid "" +"Additive count of the number of rows.\\ne.x. total number of sales over time." +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:468 +msgid "Standard deviation of ..." +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:470 +msgid "" +"Number which expresses how much the values of a column vary among all rows " +"in the answer." +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:476 +msgid "Minimum of ..." +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:478 +msgid "Minimum value of a column" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:484 +msgid "Maximum of ..." +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:486 +msgid "Maximum value of a column" +msgstr "" + +#: frontend/src/metabase/lib/schema_metadata.js:494 +msgid "Break out by dimension" +msgstr "" + +#: frontend/src/metabase/lib/settings.js:87 +msgid "lower case letter" +msgstr "" + +#: frontend/src/metabase/lib/settings.js:89 +msgid "upper case letter" +msgstr "" + +#: frontend/src/metabase/lib/settings.js:91 +msgid "number" +msgstr "" + +#: frontend/src/metabase/lib/settings.js:93 +msgid "special character" +msgstr "" + +#: frontend/src/metabase/lib/settings.js:99 +msgid "must be" +msgstr "" + +#: frontend/src/metabase/lib/settings.js:99 +#: frontend/src/metabase/lib/settings.js:100 +msgid "characters long" +msgstr "" + +#: frontend/src/metabase/lib/settings.js:100 +msgid "Must be" +msgstr "" + +#: frontend/src/metabase/lib/settings.js:116 +msgid "and include" +msgstr "" + +#: frontend/src/metabase/lib/utils.js:54 +msgid "zero" +msgstr "" + +#: frontend/src/metabase/lib/utils.js:55 +msgid "one" +msgstr "" + +#: frontend/src/metabase/lib/utils.js:56 +msgid "two" +msgstr "" + +#: frontend/src/metabase/lib/utils.js:57 +msgid "three" +msgstr "" + +#: frontend/src/metabase/lib/utils.js:58 +msgid "four" +msgstr "" + +#: frontend/src/metabase/lib/utils.js:59 +msgid "five" +msgstr "" + +#: frontend/src/metabase/lib/utils.js:60 +msgid "six" +msgstr "" + +#: frontend/src/metabase/lib/utils.js:61 +msgid "seven" +msgstr "" + +#: frontend/src/metabase/lib/utils.js:62 +msgid "eight" +msgstr "" + +#: frontend/src/metabase/lib/utils.js:63 +msgid "nine" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:31 +msgid "Month and Year" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:32 +msgid "Like January, 2016" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:36 +msgid "Quarter and Year" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:37 +msgid "Like Q1, 2016" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:41 +msgid "Single Date" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:42 +msgid "Like January 31, 2016" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:46 +msgid "Date Range" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:47 +msgid "Like December 25, 2015 - February 14, 2016" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:51 +msgid "Relative Date" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:52 +msgid "Like \"the last 7 days\" or \"this month\"" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:56 +msgid "Date Filter" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:57 +msgid "All Options" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:58 +msgid "Contains all of the above" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:70 +msgid "ZIP or Postal Code" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:78 +#: frontend/src/metabase/meta/Dashboard.js:108 +msgid "ID" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:96 +#: frontend/src/metabase/qb/components/actions/PivotByTimeAction.jsx:8 +msgid "Time" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:97 +msgid "Date range, relative date, time of day, etc." +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:102 +#: frontend/src/metabase/qb/components/actions/PivotByLocationAction.jsx:8 +msgid "Location" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:103 +msgid "City, State, Country, ZIP code." +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:109 +msgid "User ID, product ID, event ID, etc." +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:114 +msgid "Other Categories" +msgstr "" + +#: frontend/src/metabase/meta/Dashboard.js:115 +msgid "Category, Type, Model, Rating, etc." +msgstr "" + +#: frontend/src/metabase/nav/components/ProfileLink.jsx:106 +msgid "Account Settings" +msgstr "" + +#: frontend/src/metabase/nav/components/ProfileLink.jsx:121 +msgid "Admin Panel" +msgstr "" + +#: frontend/src/metabase/nav/components/ProfileLink.jsx:134 +msgid "Exit Admin" +msgstr "" + +#: frontend/src/metabase/nav/components/ProfileLink.jsx:146 +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.jsx:105 +msgid "Help" +msgstr "" + +#: frontend/src/metabase/nav/components/ProfileLink.jsx:159 +msgid "Logs" +msgstr "" + +#: frontend/src/metabase/nav/components/ProfileLink.jsx:170 +msgid "About Metabase" +msgstr "" + +#: frontend/src/metabase/nav/components/ProfileLink.jsx:180 +msgid "Sign out" +msgstr "" + +#: frontend/src/metabase/nav/components/ProfileLink.jsx:201 +msgid "Thanks for using" +msgstr "" + +#: frontend/src/metabase/nav/components/ProfileLink.jsx:205 +msgid "You're on version" +msgstr "" + +#: frontend/src/metabase/nav/components/ProfileLink.jsx:208 +msgid "Built on" +msgstr "" + +#: frontend/src/metabase/nav/components/ProfileLink.jsx:227 +msgid "is a Trademark of" +msgstr "" + +#: frontend/src/metabase/nav/components/ProfileLink.jsx:229 +msgid "and is built with care in San Francisco, CA" +msgstr "" + +#: frontend/src/metabase/nav/containers/Navbar.jsx:91 +msgid "Metabase Admin" +msgstr "" + +#: frontend/src/metabase/nav/containers/Navbar.jsx:171 +#: frontend/src/metabase/routes.jsx:243 +msgid "Questions" +msgstr "" + +#: frontend/src/metabase/nav/containers/Navbar.jsx:179 +#: frontend/src/metabase/pulse/components/PulseList.jsx:46 +#: frontend/src/metabase/routes.jsx:362 +msgid "Pulses" +msgstr "" + +#: frontend/src/metabase/nav/containers/Navbar.jsx:187 +#: frontend/src/metabase/query_builder/components/dataref/MainPane.jsx:12 +#: frontend/src/metabase/reference/databases/DatabaseSidebar.jsx:20 +#: frontend/src/metabase/reference/databases/FieldSidebar.jsx:34 +#: frontend/src/metabase/reference/databases/TableSidebar.jsx:23 +#: frontend/src/metabase/reference/guide/BaseSidebar.jsx:17 +#: frontend/src/metabase/reference/guide/BaseSidebar.jsx:19 +#: frontend/src/metabase/reference/metrics/MetricSidebar.jsx:20 +#: frontend/src/metabase/reference/segments/SegmentFieldSidebar.jsx:24 +#: frontend/src/metabase/reference/segments/SegmentSidebar.jsx:20 +msgid "Data Reference" +msgstr "" + +#: frontend/src/metabase/nav/containers/Navbar.jsx:199 +#: frontend/src/metabase/routes.jsx:231 +msgid "New Question" +msgstr "" + +#: frontend/src/metabase/new_query/containers/MetricSearch.jsx:73 +msgid "Which metric?" +msgstr "" + +#: frontend/src/metabase/new_query/containers/MetricSearch.jsx:88 +#: frontend/src/metabase/reference/metrics/MetricList.jsx:24 +msgid "" +"Defining common metrics for your team makes it even easier to ask questions" +msgstr "" + +#: frontend/src/metabase/new_query/containers/MetricSearch.jsx:92 +msgid "How to create metrics" +msgstr "" + +#: frontend/src/metabase/new_query/containers/NewQueryOptions.jsx:140 +msgid "" +"See data over time, as a map, or pivoted to help you understand trends or " +"changes." +msgstr "" + +#: frontend/src/metabase/new_query/containers/NewQueryOptions.jsx:151 +msgid "Custom" +msgstr "" + +#: frontend/src/metabase/new_query/containers/NewQueryOptions.jsx:152 +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:530 +msgid "New question" +msgstr "" + +#: frontend/src/metabase/new_query/containers/NewQueryOptions.jsx:154 +msgid "" +"Use the simple question builder to see trends, lists of things, or to create " +"your own metrics." +msgstr "" + +#: frontend/src/metabase/new_query/containers/NewQueryOptions.jsx:163 +msgid "Native query" +msgstr "" + +#: frontend/src/metabase/new_query/containers/NewQueryOptions.jsx:164 +msgid "" +"For more complicated questions, you can write your own SQL or native query." +msgstr "" + +#: frontend/src/metabase/parameters/components/ParameterValueWidget.jsx:243 +msgid "Select a default value…" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateAllOptionsWidget.jsx:147 +#: frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx:383 +msgid "Update filter" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:9 +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:144 +msgid "Today" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:14 +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:148 +msgid "Yesterday" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:18 +msgid "Past 7 days" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:19 +msgid "Past 30 days" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:24 +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:29 +msgid "Week" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:25 +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:30 +msgid "Month" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:26 +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:31 +msgid "Year" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:152 +msgid "Past 7 Days" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:156 +msgid "Past 30 Days" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:160 +msgid "Last Week" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:164 +msgid "Last Month" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:168 +msgid "Last Year" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:172 +msgid "This Week" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:176 +msgid "This Month" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx:180 +msgid "This Year" +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx:88 +#: frontend/src/metabase/parameters/components/widgets/TextWidget.jsx:54 +msgid "Enter a value..." +msgstr "" + +#: frontend/src/metabase/parameters/components/widgets/TextWidget.jsx:88 +msgid "Enter a default value..." +msgstr "" + +#: frontend/src/metabase/public/components/PublicError.jsx:18 +msgid "An error occurred" +msgstr "" + +#: frontend/src/metabase/public/components/PublicNotFound.jsx:11 +msgid "Not found" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedEmbedPane.jsx:82 +msgid "" +"You’ve made changes that need to be published before they will be reflected " +"in your application embed." +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedEmbedPane.jsx:83 +msgid "" +"You will need to publish this {0} before you can embed it in another " +"application." +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedEmbedPane.jsx:92 +msgid "Discard Changes" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedEmbedPane.jsx:99 +msgid "Updating..." +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedEmbedPane.jsx:100 +msgid "Updated" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedEmbedPane.jsx:101 +msgid "Failed!" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedEmbedPane.jsx:102 +msgid "Publish" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedEmbedPane.jsx:111 +msgid "Code" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedSettingsPane.jsx:71 +#: frontend/src/metabase/visualizations/lib/settings/graph.js:148 +msgid "Style" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedSettingsPane.jsx:79 +msgid "Parameters" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedSettingsPane.jsx:81 +msgid "Which parameters can users of this embed use?" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedSettingsPane.jsx:84 +msgid "This {0} doesn't have any parameters to configure yet." +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedSettingsPane.jsx:105 +msgid "Editable" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedSettingsPane.jsx:106 +msgid "Locked" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedSettingsPane.jsx:114 +msgid "Preview Locked Parameters" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedSettingsPane.jsx:116 +msgid "" +"Try passing some values to your locked parameters here. Your server will " +"have to provide the actual values in the signed token when using this for " +"real." +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedSettingsPane.jsx:127 +msgid "Danger zone" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedSettingsPane.jsx:128 +msgid "This will disable embedding for this {0}." +msgstr "" + +#: frontend/src/metabase/public/components/widgets/AdvancedSettingsPane.jsx:129 +msgid "Unpublish" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/DisplayOptionsPane.jsx:17 +msgid "Light" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/DisplayOptionsPane.jsx:18 +msgid "Dark" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/DisplayOptionsPane.jsx:37 +msgid "Border" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/DisplayOptionsPane.jsx:49 +#: frontend/src/metabase/visualizations/lib/settings.js:159 +msgid "Title" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/EmbedCodePane.jsx:62 +msgid "To embed this {0} in your application:" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/EmbedCodePane.jsx:64 +msgid "" +"Insert this code snippet in your server code to generate the signed " +"embedding URL " +msgstr "" + +#: frontend/src/metabase/public/components/widgets/EmbedCodePane.jsx:87 +msgid "Then insert this code snippet in your HTML template or single page app." +msgstr "" + +#: frontend/src/metabase/public/components/widgets/EmbedCodePane.jsx:94 +msgid "Embed code snippet for your HTML or Frontend Application" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/EmbedCodePane.jsx:101 +msgid "More {0}" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/SharingPane.jsx:72 +msgid "Enable sharing" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/SharingPane.jsx:76 +msgid "Disable this public link?" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/SharingPane.jsx:77 +msgid "" +"This will cause the existing link to stop working. You can re-enable it, but " +"when you do it will be a different link." +msgstr "" + +#: frontend/src/metabase/public/components/widgets/SharingPane.jsx:117 +msgid "Public link" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/SharingPane.jsx:118 +msgid "" +"Share this {0} with people who don't have a Metabase account using the URL " +"below:" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/SharingPane.jsx:158 +msgid "Public embed" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/SharingPane.jsx:159 +msgid "" +"Embed this {0} in blog posts or web pages by copying and pasting this " +"snippet:" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/SharingPane.jsx:176 +msgid "Embed this {0} in an application" +msgstr "" + +#: frontend/src/metabase/public/components/widgets/SharingPane.jsx:177 +msgid "" +"By integrating with your application server code, you can provide a secure " +"stats {0} limited to a specific user, customer, organization, etc." +msgstr "" + +#: frontend/src/metabase/pulse/components/CardPicker.jsx:63 +msgid "Raw data cannot be included in pulses" +msgstr "" + +#: frontend/src/metabase/pulse/components/CardPicker.jsx:72 +msgid "Maps cannot be included in pulses" +msgstr "" + +#: frontend/src/metabase/pulse/components/CardPicker.jsx:118 +#: frontend/src/metabase/questions/containers/AddToDashboard.jsx:65 +msgid "Everything else" +msgstr "" + +#: frontend/src/metabase/pulse/components/CardPicker.jsx:143 +msgid "Type a question name to filter" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseCardPreview.jsx:86 +msgid "Remove attachment" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseCardPreview.jsx:87 +msgid "Attach file with results" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseCardPreview.jsx:119 +msgid "This question will be added as a file attachment" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseCardPreview.jsx:120 +msgid "This question won't be included in your Pulse" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:89 +msgid "This pulse will no longer be emailed to {0} {1}" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:97 +msgid "Slack channel {0} will no longer get this pulse {1}" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:105 +msgid "Channel {0} will no longer receive this pulse {1}" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:122 +msgid "Edit pulse" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:122 +msgid "New pulse" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:126 +msgid "What's a Pulse?" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:136 +msgid "Got it" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:151 +msgid "Where should this data go?" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:167 +msgid "Delete this pulse" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:169 +msgid "Stop delivery and delete this pulse. There's no undo, so be careful." +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:173 +msgid "Delete this Pulse" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:194 +msgid "Create pulse" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEdit.jsx:195 +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:268 +msgid "Saving…" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditCards.jsx:77 +msgid "Attachment" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditCards.jsx:91 +msgid "Heads up" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditCards.jsx:92 +msgid "Raw data questions can only be included as email attachments" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditCards.jsx:99 +msgid "Looks like this pulse is getting big" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditCards.jsx:100 +msgid "" +"We recommend keeping pulses small and focused to help keep them digestable " +"and useful to the whole team." +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditCards.jsx:140 +msgid "Pick your data" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditCards.jsx:142 +msgid "Choose questions you'd like to send in this pulse" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditChannels.jsx:29 +msgid "Emails" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditChannels.jsx:30 +msgid "Slack messages" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditChannels.jsx:221 +msgid "Sent" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditChannels.jsx:222 +msgid "{0} will be sent at" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditChannels.jsx:224 +msgid "Messages" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditChannels.jsx:235 +msgid "Send email now" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditChannels.jsx:236 +msgid "Send to {0} now" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditChannels.jsx:238 +msgid "Sending…" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditChannels.jsx:239 +msgid "Sending failed" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditChannels.jsx:242 +msgid "Didn’t send because the pulse has no results." +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditChannels.jsx:243 +msgid "Pulse sent" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditChannels.jsx:282 +msgid "{0} needs to be set up by an administrator." +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditChannels.jsx:297 +msgid "Slack" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditName.jsx:35 +msgid "Name your pulse" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditName.jsx:37 +msgid "Give your pulse a name to help others understand what it's about" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditName.jsx:49 +msgid "Important metrics" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditSkip.jsx:22 +msgid "Skip if no results" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseEditSkip.jsx:24 +msgid "Skip a scheduled Pulse if none of its questions have any results" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseList.jsx:50 +#: frontend/src/metabase/pulse/components/PulseList.jsx:79 +msgid "Create a pulse" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseList.jsx:91 +msgid "pulses" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseListChannel.jsx:65 +msgid "Emailed" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseListChannel.jsx:68 +msgid "Slack'd" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseListChannel.jsx:70 +#: frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx:243 +msgid "No channel" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseListChannel.jsx:81 +msgid "to" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseListChannel.jsx:105 +msgid "You get this {0}" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseListChannel.jsx:121 +msgid "Get this {0}" +msgstr "" + +#: frontend/src/metabase/pulse/components/PulseListItem.jsx:47 +msgid "Pulse by {0}" +msgstr "" + +#: frontend/src/metabase/pulse/components/RecipientPicker.jsx:61 +msgid "Enter email addresses you'd like this data to go to" +msgstr "" + +#: frontend/src/metabase/pulse/components/WhatsAPulse.jsx:16 +msgid "Help everyone on your team stay in sync with your data." +msgstr "" + +#: frontend/src/metabase/pulse/components/WhatsAPulse.jsx:29 +msgid "" +"Pulses let you send data from Metabase to email or Slack on the schedule of " +"your choice." +msgstr "" + +#: frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx:100 +msgid "After {0}" +msgstr "" + +#: frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx:102 +msgid "Before {0}" +msgstr "" + +#: frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx:104 +#: frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx:299 +msgid "Is Empty" +msgstr "" + +#: frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx:106 +#: frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx:305 +msgid "Not Empty" +msgstr "" + +#: frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx:109 +#: frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx:216 +msgid "All Time" +msgstr "" + +#: frontend/src/metabase/qb/components/actions/CommonMetricsAction.jsx:21 +msgid "View {0}" +msgstr "" + +#: frontend/src/metabase/qb/components/actions/CompoundQueryAction.jsx:14 +msgid "Analyze the results of this Query" +msgstr "" + +#: frontend/src/metabase/qb/components/actions/CountByTimeAction.jsx:29 +msgid "Count of rows by time" +msgstr "" + +#: frontend/src/metabase/qb/components/actions/PivotByAction.jsx:55 +msgid "Break out by {0}" +msgstr "" + +#: frontend/src/metabase/qb/components/actions/SummarizeBySegmentMetricAction.jsx:34 +msgid "Summarize this segment" +msgstr "" + +#: frontend/src/metabase/qb/components/actions/UnderlyingDataAction.jsx:14 +msgid "View this as a table" +msgstr "" + +#: frontend/src/metabase/qb/components/actions/UnderlyingRecordsAction.jsx:22 +msgid "View the underlying {0} records" +msgstr "" + +#: frontend/src/metabase/qb/components/actions/XRayCard.jsx:19 +msgid "X-ray this question" +msgstr "" + +#: frontend/src/metabase/qb/components/actions/XRaySegment.jsx:23 +msgid "X-ray {0}" +msgstr "" + +#: frontend/src/metabase/qb/components/drill/CountByColumnDrill.js:29 +#: frontend/src/metabase/xray/containers/FieldXray.jsx:148 +msgid "Distribution" +msgstr "" + +#: frontend/src/metabase/qb/components/drill/ObjectDetailDrill.jsx:38 +msgid "View details" +msgstr "" + +#: frontend/src/metabase/qb/components/drill/QuickFilterDrill.jsx:54 +msgid "View this {0}'s {1}" +msgstr "" + +#: frontend/src/metabase/qb/components/drill/SortAction.jsx:45 +msgid "Ascending" +msgstr "" + +#: frontend/src/metabase/qb/components/drill/SortAction.jsx:57 +msgid "Descending" +msgstr "" + +#: frontend/src/metabase/qb/components/drill/SummarizeColumnByTimeDrill.js:44 +msgid "by time" +msgstr "" + +#: frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js:21 +msgid "Avg" +msgstr "" + +#: frontend/src/metabase/qb/components/drill/SummarizeColumnDrill.js:33 +msgid "Distincts" +msgstr "" + +#: frontend/src/metabase/qb/components/drill/UnderlyingRecordsDrill.jsx:30 +msgid "View {0} {1}" +msgstr "" + +#: frontend/src/metabase/qb/components/drill/UnderlyingRecordsDrill.jsx:30 +msgid "these" +msgstr "" + +#: frontend/src/metabase/qb/components/drill/UnderlyingRecordsDrill.jsx:30 +msgid "this" +msgstr "" + +#: frontend/src/metabase/qb/components/drill/ZoomDrill.jsx:26 +msgid "Zoom in" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AggregationPopover.jsx:19 +msgid "Custom Expression" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AggregationPopover.jsx:20 +msgid "Common Metrics" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AggregationPopover.jsx:182 +msgid "Metabasics" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AggregationPopover.jsx:290 +msgid "Name (optional)" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AggregationWidget.jsx:153 +msgid "Choose an aggregation" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx:113 +msgid "Set up your own alert" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx:153 +msgid "Unsubscribing..." +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx:158 +msgid "Failed to unsubscribe" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx:212 +msgid "Unsubscribe" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx:270 +msgid "Okay, you're unsubscribed" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx:342 +msgid "You're receiving {0}'s alerts" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx:343 +msgid "{0} set up an alert" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:149 +msgid "alerts" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:172 +msgid "Let's set up your alert" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:203 +msgid "The wide world of alerts" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:204 +msgid "There are a few different kinds of alerts you can get" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:217 +msgid "When a raw data question {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:228 +msgid "When a line or bar {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:239 +msgid "When a progress bar {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:246 +msgid "Set up an alert" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:309 +msgid "Edit your alert" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:309 +msgid "Edit alert" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:352 +msgid "This alert will no longer be emailed to {0}." +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:358 +msgid "Slack channel {0} will no longer get this alert." +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:362 +msgid "Channel {0} will no longer receive this alert." +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:379 +msgid "Delete this alert" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:381 +msgid "Stop delivery and delete this alert. There's no undo, so be careful." +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:389 +msgid "Delete this alert?" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:473 +msgid "Alert me when the line…" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:474 +msgid "Alert me when the progress bar…" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:477 +msgid "Goes above the goal line" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:477 +msgid "Reaches the goal" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:480 +msgid "Goes below the goal line" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:480 +msgid "Goes below the goal" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:488 +msgid "The first time it crosses, or every time?" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:489 +msgid "The first time it reaches the goal, or every time?" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:491 +msgid "The first time" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:492 +msgid "Every time" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:595 +msgid "Where do you want to send these alerts?" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:606 +msgid "Email alerts to:" +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:648 +msgid "" +"{0} Goal-based alerts aren't yet supported for charts with more than one " +"line, so this alert will be sent whenever the chart has {1}." +msgstr "" + +#: frontend/src/metabase/query_builder/components/AlertModals.jsx:655 +msgid "" +"{0} This kind of alert is most useful when your saved question doesn’t {1} " +"return any results, but you want to know when it does." +msgstr "" + +#: frontend/src/metabase/query_builder/components/DataSelector.jsx:57 +msgid "Pick a segment or table" +msgstr "" + +#: frontend/src/metabase/query_builder/components/DataSelector.jsx:73 +msgid "Select a database" +msgstr "" + +#: frontend/src/metabase/query_builder/components/DataSelector.jsx:88 +#: frontend/src/metabase/reference/components/GuideDetailEditor.jsx:87 +#: frontend/src/metabase/reference/components/GuideDetailEditor.jsx:187 +#: frontend/src/metabase/reference/components/MetricImportantFieldsDetail.jsx:35 +msgid "Select..." +msgstr "" + +#: frontend/src/metabase/query_builder/components/DataSelector.jsx:128 +msgid "Select a table" +msgstr "" + +#: frontend/src/metabase/query_builder/components/DataSelector.jsx:784 +msgid "No tables found in this database." +msgstr "" + +#: frontend/src/metabase/query_builder/components/DataSelector.jsx:821 +msgid "Is a question missing?" +msgstr "" + +#: frontend/src/metabase/query_builder/components/DataSelector.jsx:825 +msgid "Learn more about nested queries" +msgstr "" + +#: frontend/src/metabase/query_builder/components/DataSelector.jsx:857 +msgid "Fields" +msgstr "" + +#: frontend/src/metabase/query_builder/components/DataSelector.jsx:935 +msgid "No segments were found." +msgstr "" + +#: frontend/src/metabase/query_builder/components/DataSelector.jsx:958 +msgid "Find a segment" +msgstr "" + +#: frontend/src/metabase/query_builder/components/ExpandableString.jsx:44 +msgid "View less" +msgstr "" + +#: frontend/src/metabase/query_builder/components/ExpandableString.jsx:54 +msgid "View more" +msgstr "" + +#: frontend/src/metabase/query_builder/components/ExtendedOptions.jsx:111 +msgid "Pick a field to sort by" +msgstr "" + +#: frontend/src/metabase/query_builder/components/ExtendedOptions.jsx:124 +msgid "Sort" +msgstr "" + +#: frontend/src/metabase/query_builder/components/ExtendedOptions.jsx:189 +msgid "Row limit" +msgstr "" + +#: frontend/src/metabase/query_builder/components/FieldName.jsx:76 +msgid "Unknown Field" +msgstr "" + +#: frontend/src/metabase/query_builder/components/FieldName.jsx:79 +msgid "field" +msgstr "" + +#: frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx:150 +#: frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx:158 +msgid "Add filters to narrow your answer" +msgstr "" + +#: frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx:235 +#: frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx:288 +msgid "and" +msgstr "" + +#: frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx:282 +msgid "Add a grouping" +msgstr "" + +#: frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx:320 +#: frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx:102 +#: frontend/src/metabase/visualizations/visualizations/Funnel.jsx:58 +#: frontend/src/metabase/visualizations/visualizations/PieChart.jsx:54 +msgid "Data" +msgstr "" + +#: frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx:350 +msgid "Filtered by" +msgstr "" + +#: frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx:367 +msgid "View" +msgstr "" + +#: frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx:384 +msgid "Grouped By" +msgstr "" + +#: frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx:332 +msgid "This question is written in {0}." +msgstr "" + +#: frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx:340 +msgid "Hide Editor" +msgstr "" + +#: frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx:341 +msgid "Hide Query" +msgstr "" + +#: frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx:346 +msgid "Open Editor" +msgstr "" + +#: frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx:347 +msgid "Show Query" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryDefinitionTooltip.jsx:25 +msgid "This metric has been retired. It's no longer available for use." +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx:25 +#: frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx:32 +msgid "Download full results" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx:26 +msgid "Download this data" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx:34 +msgid "Warning" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx:35 +msgid "" +"Your answer has a large number of rows so it could take a while to download." +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx:36 +msgid "The maximum download size is 1 million rows." +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:250 +msgid "Edit question" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:267 +msgid "SAVE CHANGES" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:281 +msgid "CANCEL" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:296 +msgid "Move question" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:327 +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:110 +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.jsx:83 +msgid "Variables" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:445 +msgid "Learn about your data" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:473 +msgid "Alerts are on" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryHeader.jsx:535 +msgid "started from" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryModeButton.jsx:48 +msgid "SQL" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryModeButton.jsx:48 +msgid "native query" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryModeButton.jsx:52 +msgid "Not Supported" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryModeButton.jsx:58 +msgid "View the {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryModeButton.jsx:59 +msgid "Switch to {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryModeButton.jsx:62 +msgid "Switch to Builder" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryModeButton.jsx:87 +msgid "{0} for this question" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryModeButton.jsx:111 +msgid "Convert this question to {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryVisualization.jsx:122 +msgid "This question will take approximately {0} to refresh" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryVisualization.jsx:131 +msgid "Updated {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryVisualization.jsx:146 +msgid "Showing first {0} {1}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryVisualization.jsx:149 +msgid "Showing {0} {1}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryVisualization.jsx:281 +msgid "Doing science" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryVisualization.jsx:294 +msgid "If you give me some data I can show you something cool. Run a Query!" +msgstr "" + +#: frontend/src/metabase/query_builder/components/QueryVisualization.jsx:299 +msgid "How do I use this thing?" +msgstr "" + +#: frontend/src/metabase/query_builder/components/RunButton.jsx:28 +msgid "Get Answer" +msgstr "" + +#: frontend/src/metabase/query_builder/components/SavedQuestionIntroModal.jsx:12 +msgid "It's okay to play around with saved questions" +msgstr "" + +#: frontend/src/metabase/query_builder/components/SavedQuestionIntroModal.jsx:14 +msgid "" +"You won't make any permanent changes to a saved question unless you click " +"the edit icon in the top-right." +msgstr "" + +#: frontend/src/metabase/query_builder/components/SearchBar.jsx:28 +msgid "Search for" +msgstr "" + +#: frontend/src/metabase/query_builder/components/SelectionModule.jsx:158 +msgid "Advanced..." +msgstr "" + +#: frontend/src/metabase/query_builder/components/SelectionModule.jsx:167 +msgid "Sorry. Something went wrong." +msgstr "" + +#: frontend/src/metabase/query_builder/components/TimeGroupingPopover.jsx:40 +msgid "Group time by" +msgstr "" + +#: frontend/src/metabase/query_builder/components/VisualizationError.jsx:45 +msgid "Your question took too long" +msgstr "" + +#: frontend/src/metabase/query_builder/components/VisualizationError.jsx:46 +msgid "" +"We didn't get an answer back from your database in time, so we had to stop. " +"You can try again in a minute, or if the problem persists, you can email an " +"admin to let them know." +msgstr "" + +#: frontend/src/metabase/query_builder/components/VisualizationError.jsx:54 +msgid "We're experiencing server issues" +msgstr "" + +#: frontend/src/metabase/query_builder/components/VisualizationError.jsx:55 +msgid "" +"Try refreshing the page after waiting a minute or two. If the problem " +"persists we'd recommend you contact an admin." +msgstr "" + +#: frontend/src/metabase/query_builder/components/VisualizationError.jsx:87 +msgid "There was a problem with your question" +msgstr "" + +#: frontend/src/metabase/query_builder/components/VisualizationError.jsx:88 +msgid "" +"Most of the time this is caused by an invalid selection or bad input value. " +"Double check your inputs and retry your query." +msgstr "" + +#: frontend/src/metabase/query_builder/components/VisualizationError.jsx:93 +msgid "Show error details" +msgstr "" + +#: frontend/src/metabase/query_builder/components/VisualizationError.jsx:99 +msgid "Here's the full error message" +msgstr "" + +#: frontend/src/metabase/query_builder/components/VisualizationResult.jsx:61 +msgid "" +"This may be the answer you’re looking for. If not, try removing or changing " +"your filters to make them less specific." +msgstr "" + +#: frontend/src/metabase/query_builder/components/VisualizationResult.jsx:67 +msgid "You can also {0} when there are any results." +msgstr "" + +#: frontend/src/metabase/query_builder/components/VisualizationResult.jsx:78 +msgid "Back to last run" +msgstr "" + +#: frontend/src/metabase/query_builder/components/VisualizationSettings.jsx:53 +msgid "Visualization" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/DetailPane.jsx:17 +#: frontend/src/metabase/query_builder/components/dataref/TablePane.jsx:146 +msgid "No description set." +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/DetailPane.jsx:21 +msgid "Use for current question" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/DetailPane.jsx:33 +#: frontend/src/metabase/reference/components/UsefulQuestions.jsx:16 +msgid "Potentially useful questions" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx:156 +msgid "Group by {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx:165 +msgid "Sum of all values of {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx:173 +#: frontend/src/metabase/reference/databases/FieldDetail.jsx:63 +#: frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx:51 +msgid "All distinct values of {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx:177 +#: frontend/src/metabase/reference/databases/FieldDetail.jsx:39 +#: frontend/src/metabase/reference/databases/FieldDetail.jsx:51 +#: frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx:39 +msgid "Number of {0} grouped by {1}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/MainPane.jsx:14 +msgid "Learn more about your data structure to ask more useful questions" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/MetricPane.jsx:58 +#: frontend/src/metabase/query_builder/components/dataref/SegmentPane.jsx:84 +msgid "Could not find the table metadata prior to creating a new question" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/MetricPane.jsx:80 +msgid "See {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/MetricPane.jsx:94 +msgid "Metric Definition" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/SegmentPane.jsx:118 +msgid "Filter by {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/SegmentPane.jsx:127 +#: frontend/src/metabase/reference/segments/SegmentDetail.jsx:36 +msgid "Number of {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/SegmentPane.jsx:134 +#: frontend/src/metabase/reference/segments/SegmentDetail.jsx:46 +msgid "See all {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/SegmentPane.jsx:148 +msgid "Segment Definition" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/TablePane.jsx:47 +msgid "An error occurred loading the table" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/TablePane.jsx:71 +msgid "See the raw data for {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/dataref/TablePane.jsx:199 +msgid "More" +msgstr "" + +#: frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx:192 +msgid "Invalid expression" +msgstr "" + +#: frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx:266 +msgid "unknown error" +msgstr "" + +#: frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx:46 +msgid "Field formula" +msgstr "" + +#: frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx:57 +msgid "" +"Think of this as being kind of like writing a formula in a spreadsheet " +"program: you can use numbers, fields in this table, mathematical symbols " +"like +, and some functions. So you could type something like Subtotal " +"− Cost." +msgstr "" + +#: frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx:62 +#: frontend/src/metabase/questions/containers/QuestionIndex.jsx:40 +#: frontend/src/metabase/reference/components/GuideDetail.jsx:126 +msgid "Learn more" +msgstr "" + +#: frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx:66 +msgid "Give it a name" +msgstr "" + +#: frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx:72 +msgid "Something nice and descriptive" +msgstr "" + +#: frontend/src/metabase/query_builder/components/expressions/Expressions.jsx:60 +msgid "Add a custom field" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/FilterList.jsx:64 +msgid "Item" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/FilterOptions.jsx:17 +msgid "Include {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/FilterOptions.jsx:19 +msgid "Case sensitive" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/FilterOptions.jsx:23 +msgid "today" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/FilterOptions.jsx:24 +msgid "this week" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/FilterOptions.jsx:25 +msgid "this month" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/FilterOptions.jsx:26 +msgid "this year" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/FilterOptions.jsx:27 +msgid "this minute" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/FilterOptions.jsx:28 +msgid "this hour" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx:278 +msgid "not implemented {0}" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx:279 +msgid "true" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx:279 +msgid "false" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx:383 +msgid "Add filter" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/FilterWidget.jsx:140 +msgid "Matches" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx:224 +msgid "Previous" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx:255 +msgid "Current" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx:282 +msgid "On" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/pickers/NumberPicker.jsx:47 +msgid "Enter desired number" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/pickers/SelectPicker.jsx:83 +#: frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx:98 +msgid "Empty" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/pickers/SelectPicker.jsx:116 +msgid "Find a value" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/pickers/SpecificDatePicker.jsx:113 +msgid "Hide calendar" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/pickers/SpecificDatePicker.jsx:113 +msgid "Show calendar" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/pickers/TextPicker.jsx:97 +msgid "You can enter multiple values separated by commas" +msgstr "" + +#: frontend/src/metabase/query_builder/components/filters/pickers/TextPicker.jsx:38 +msgid "Enter desired text" +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:83 +msgid "Try it" +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:105 +msgid "What's this for?" +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:107 +msgid "" +"Variables in native queries let you dynamically replace values in your " +"queries using filter widgets or through the URL." +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:112 +msgid "" +"{0} creates a variable in this SQL template called \"variable_name\". " +"Variables can be given types in the side panel, which changes their " +"behavior. All variable types other than \"Field Filter\" will automatically " +"cause a filter widget to be placed on this question; with Field Filters, " +"this is optional. When this filter widget is filled in, that value replaces " +"the variable in the SQL template." +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:121 +msgid "Field Filters" +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:123 +msgid "" +"Giving a variable the \"Field Filter\" type allows you to link SQL cards to " +"dashboard filter widgets or use more types of filter widgets on your SQL " +"question. A Field Filter variable inserts SQL similar to that generated by " +"the GUI query builder when adding filters on existing columns." +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:126 +msgid "" +"When adding a Field Filter variable, you'll need to map it to a specific " +"field. You can then choose to display a filter widget on your question, but " +"even if you don't, you can now map your Field Filter variable to a dashboard " +"filter when adding this question to a dashboard. Field Filters should be " +"used inside of a \"WHERE\" clause." +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:130 +msgid "Optional Clauses" +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:132 +msgid "" +"brackets around a {0} create an optional clause in the template. If " +"\"variable\" is set, then the entire clause is placed into the template. If " +"not, then the entire clause is ignored." +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:142 +msgid "" +"To use multiple optional clauses you can include at least one non-optional " +"WHERE clause followed by optional clauses starting with \"AND\"." +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:154 +msgid "Read the full documentation" +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx:124 +msgid "Filter label" +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx:136 +msgid "Variable type" +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx:145 +msgid "Text" +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx:147 +msgid "Date" +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx:148 +msgid "Field Filter" +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx:154 +msgid "Field to map to" +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx:176 +msgid "Filter widget type" +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx:199 +msgid "Required?" +msgstr "" + +#: frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx:210 +msgid "Default filter widget value" +msgstr "" + +#: frontend/src/metabase/query_builder/containers/ArchiveQuestionModal.jsx:46 +msgid "Archive this question?" +msgstr "" + +#: frontend/src/metabase/query_builder/containers/ArchiveQuestionModal.jsx:56 +msgid "This question will be removed from any dashboards or pulses using it." +msgstr "" + +#: frontend/src/metabase/query_builder/containers/QueryBuilder.jsx:134 +msgid "Question" +msgstr "" + +#: frontend/src/metabase/questions/components/ActionHeader.jsx:25 +msgid "Select all {0}" +msgstr "" + +#: frontend/src/metabase/questions/components/ActionHeader.jsx:37 +msgid "{0} selected" +msgstr "" + +#: frontend/src/metabase/questions/components/ActionHeader.jsx:44 +msgid "Labels" +msgstr "" + +#: frontend/src/metabase/questions/components/CollectionButtons.jsx:58 +msgid "Set collection permissions" +msgstr "" + +#: frontend/src/metabase/questions/components/CollectionButtons.jsx:104 +#: frontend/src/metabase/questions/containers/CollectionEditorForm.jsx:37 +msgid "New collection" +msgstr "" + +#: frontend/src/metabase/questions/components/ExpandingSearchField.jsx:93 +msgid "Search for a question" +msgstr "" + +#: frontend/src/metabase/questions/components/Item.jsx:92 +msgid "Move to a collection" +msgstr "" + +#: frontend/src/metabase/questions/components/Item.jsx:209 +msgid "Created" +msgstr "" + +#: frontend/src/metabase/questions/components/Item.jsx:209 +msgid " by {0}" +msgstr "" + +#: frontend/src/metabase/questions/components/LabelIconPicker.jsx:47 +msgid "Colors" +msgstr "" + +#: frontend/src/metabase/questions/components/LabelPicker.jsx:17 +msgid "Apply labels to {0} questions" +msgstr "" + +#: frontend/src/metabase/questions/components/LabelPicker.jsx:17 +msgid "Label as" +msgstr "" + +#: frontend/src/metabase/questions/components/LabelPicker.jsx:51 +#: frontend/src/metabase/questions/containers/EditLabels.jsx:73 +msgid "Add and edit labels" +msgstr "" + +#: frontend/src/metabase/questions/components/LabelPicker.jsx:53 +#: frontend/src/metabase/questions/containers/EditLabels.jsx:77 +msgid "In an upcoming release, Labels will be removed in favor of Collections." +msgstr "" + +#: frontend/src/metabase/questions/containers/AddToDashboard.jsx:88 +msgid "Pick a question to add" +msgstr "" + +#: frontend/src/metabase/questions/containers/AddToDashboard.jsx:113 +msgid "Last modified" +msgstr "" + +#: frontend/src/metabase/questions/containers/AddToDashboard.jsx:114 +msgid "Alphabetical order" +msgstr "" + +#: frontend/src/metabase/questions/containers/ArchiveCollectionWidget.jsx:44 +msgid "Archive collection" +msgstr "" + +#: frontend/src/metabase/questions/containers/ArchiveCollectionWidget.jsx:48 +msgid "Archive this collection?" +msgstr "" + +#: frontend/src/metabase/questions/containers/ArchiveCollectionWidget.jsx:54 +msgid "The saved questions in this collection will also be archived." +msgstr "" + +#: frontend/src/metabase/questions/containers/CollectionEditorForm.jsx:21 +msgid "Name must be 100 characters or less" +msgstr "" + +#: frontend/src/metabase/questions/containers/CollectionEditorForm.jsx:24 +msgid "Color is required" +msgstr "" + +#: frontend/src/metabase/questions/containers/CollectionEditorForm.jsx:79 +msgid "My new fantastic collection" +msgstr "" + +#: frontend/src/metabase/questions/containers/CollectionEditorForm.jsx:91 +#: frontend/src/metabase/visualizations/visualizations/Progress.jsx:48 +msgid "Color" +msgstr "" + +#: frontend/src/metabase/questions/containers/CollectionPage.jsx:65 +msgid "Edit collection" +msgstr "" + +#: frontend/src/metabase/questions/containers/CollectionPage.jsx:75 +msgid "Set permissions" +msgstr "" + +#: frontend/src/metabase/questions/containers/CollectionPage.jsx:86 +msgid "No questions have been added to this collection yet." +msgstr "" + +#: frontend/src/metabase/questions/containers/EditLabels.jsx:75 +msgid "Heads up!" +msgstr "" + +#: frontend/src/metabase/questions/containers/EditLabels.jsx:83 +msgid "Create Label" +msgstr "" + +#: frontend/src/metabase/questions/containers/EditLabels.jsx:105 +msgid "Update Label" +msgstr "" + +#: frontend/src/metabase/questions/containers/EditLabels.jsx:121 +msgid "Delete label \"{0}\"" +msgstr "" + +#: frontend/src/metabase/questions/containers/EditLabels.jsx:140 +msgid "Create labels to group and manage questions." +msgstr "" + +#: frontend/src/metabase/questions/containers/EntityList.jsx:70 +#: frontend/src/metabase/questions/selectors.js:154 +msgid "All questions" +msgstr "" + +#: frontend/src/metabase/questions/containers/EntityList.jsx:72 +msgid "No questions have been saved yet." +msgstr "" + +#: frontend/src/metabase/questions/containers/EntityList.jsx:78 +msgid "You haven't favorited any questions yet." +msgstr "" + +#: frontend/src/metabase/questions/containers/EntityList.jsx:82 +#: frontend/src/metabase/questions/selectors.js:156 +msgid "Recently viewed" +msgstr "" + +#: frontend/src/metabase/questions/containers/EntityList.jsx:84 +msgid "You haven't viewed any questions recently." +msgstr "" + +#: frontend/src/metabase/questions/containers/EntityList.jsx:90 +msgid "You haven't saved any questions yet." +msgstr "" + +#: frontend/src/metabase/questions/containers/EntityList.jsx:94 +#: frontend/src/metabase/questions/selectors.js:158 +msgid "Most popular" +msgstr "" + +#: frontend/src/metabase/questions/containers/EntityList.jsx:96 +msgid "The most-viewed questions across your company will show up here." +msgstr "" + +#: frontend/src/metabase/questions/containers/EntityList.jsx:102 +msgid "If you no longer need a question, you can archive it." +msgstr "" + +#: frontend/src/metabase/questions/containers/EntityList.jsx:108 +msgid "There aren't any questions matching that criteria." +msgstr "" + +#: frontend/src/metabase/questions/containers/LabelEditorForm.jsx:21 +msgid "Icon is required" +msgstr "" + +#: frontend/src/metabase/questions/containers/MoveToCollection.jsx:52 +msgid "Which collection should this be in?" +msgstr "" + +#: frontend/src/metabase/questions/containers/QuestionIndex.jsx:33 +msgid "Create collections for your saved questions" +msgstr "" + +#: frontend/src/metabase/questions/containers/QuestionIndex.jsx:35 +msgid "" +"Collections help you organize your questions and allow you to decide who " +"gets to see what." +msgstr "" + +#: frontend/src/metabase/questions/containers/QuestionIndex.jsx:45 +msgid "Create a collection" +msgstr "" + +#: frontend/src/metabase/questions/containers/QuestionIndex.jsx:55 +msgid "Explore your data, create charts or maps, and save what you find." +msgstr "" + +#: frontend/src/metabase/questions/containers/QuestionIndex.jsx:58 +#: frontend/src/metabase/reference/databases/TableQuestions.jsx:36 +#: frontend/src/metabase/reference/metrics/MetricQuestions.jsx:37 +#: frontend/src/metabase/reference/segments/SegmentQuestions.jsx:37 +msgid "Ask a question" +msgstr "" + +#: frontend/src/metabase/questions/containers/QuestionIndex.jsx:89 +msgid "Set permissions for collections" +msgstr "" + +#: frontend/src/metabase/questions/containers/SearchResults.jsx:44 +msgid "Search results" +msgstr "" + +#: frontend/src/metabase/questions/containers/SearchResults.jsx:52 +msgid "No matching questions found" +msgstr "" + +#: frontend/src/metabase/questions/questions.js:127 +msgid "{0} question was {1}" +msgstr "" + +#: frontend/src/metabase/questions/questions.js:128 +msgid "{0} questions were {1}" +msgstr "" + +#: frontend/src/metabase/questions/questions.js:134 +msgid "to the" +msgstr "" + +#: frontend/src/metabase/questions/questions.js:138 +msgid "collection" +msgstr "" + +#: frontend/src/metabase/reference/components/EditHeader.jsx:19 +msgid "You are editing this page" +msgstr "" + +#: frontend/src/metabase/reference/components/EditableReferenceHeader.jsx:96 +#: frontend/src/metabase/reference/components/ReferenceHeader.jsx:59 +msgid "See this {0}" +msgstr "" + +#: frontend/src/metabase/reference/components/EditableReferenceHeader.jsx:115 +msgid "A subset of" +msgstr "" + +#: frontend/src/metabase/reference/components/Field.jsx:47 +#: frontend/src/metabase/reference/components/Field.jsx:86 +#: frontend/src/metabase/reference/components/FieldTypeDetail.jsx:32 +#: frontend/src/metabase/reference/components/FieldTypeDetail.jsx:68 +msgid "Select a field type" +msgstr "" + +#: frontend/src/metabase/reference/components/Field.jsx:56 +#: frontend/src/metabase/reference/components/Field.jsx:71 +#: frontend/src/metabase/reference/components/FieldTypeDetail.jsx:41 +#: frontend/src/metabase/reference/components/FieldTypeDetail.jsx:57 +msgid "No field type" +msgstr "" + +#: frontend/src/metabase/reference/components/FieldToGroupBy.jsx:22 +msgid "by" +msgstr "" + +#: frontend/src/metabase/reference/components/FieldTypeDetail.jsx:25 +#: frontend/src/metabase/reference/databases/FieldList.jsx:152 +#: frontend/src/metabase/reference/segments/SegmentFieldList.jsx:153 +msgid "Field type" +msgstr "" + +#: frontend/src/metabase/reference/components/FieldTypeDetail.jsx:72 +msgid "Select a Foreign Key" +msgstr "" + +#: frontend/src/metabase/reference/components/Formula.jsx:53 +msgid "View the {0} formula" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetail.jsx:80 +msgid "Why this {0} is important" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetail.jsx:81 +msgid "Why this {0} is interesting" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetail.jsx:87 +msgid "Nothing important yet" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetail.jsx:88 +#: frontend/src/metabase/reference/databases/DatabaseDetail.jsx:168 +#: frontend/src/metabase/reference/databases/FieldDetail.jsx:233 +#: frontend/src/metabase/reference/databases/TableDetail.jsx:211 +#: frontend/src/metabase/reference/metrics/MetricDetail.jsx:215 +#: frontend/src/metabase/reference/segments/SegmentDetail.jsx:219 +#: frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx:229 +msgid "Nothing interesting yet" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetail.jsx:93 +msgid "Things to be aware of about this {0}" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetail.jsx:97 +#: frontend/src/metabase/reference/databases/DatabaseDetail.jsx:178 +#: frontend/src/metabase/reference/databases/FieldDetail.jsx:243 +#: frontend/src/metabase/reference/databases/TableDetail.jsx:221 +#: frontend/src/metabase/reference/metrics/MetricDetail.jsx:225 +#: frontend/src/metabase/reference/segments/SegmentDetail.jsx:229 +#: frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx:239 +msgid "Nothing to be aware of yet" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetail.jsx:103 +msgid "Explore this metric" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetail.jsx:105 +msgid "View this metric" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetail.jsx:112 +msgid "By {0}" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetailEditor.jsx:146 +msgid "Remove item" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetailEditor.jsx:155 +msgid "Why is this dashboard the most important?" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetailEditor.jsx:156 +msgid "What is useful or interesting about this {0}?" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetailEditor.jsx:160 +#: frontend/src/metabase/reference/components/GuideDetailEditor.jsx:174 +msgid "Write something helpful here" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetailEditor.jsx:169 +msgid "Is there anything users of this dashboard should be aware of?" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetailEditor.jsx:170 +msgid "Anything users should be aware of about this {0}?" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideDetailEditor.jsx:182 +#: frontend/src/metabase/reference/components/MetricImportantFieldsDetail.jsx:26 +msgid "Which 2-3 fields do you usually group this metric by?" +msgstr "" + +#: frontend/src/metabase/reference/components/GuideHeader.jsx:14 +msgid "Start here." +msgstr "" + +#: frontend/src/metabase/reference/components/GuideHeader.jsx:24 +msgid "" +"This is the perfect place to start if you’re new to your company’s data, or " +"if you just want to check in on what’s going on." +msgstr "" + +#: frontend/src/metabase/reference/components/MetricImportantFieldsDetail.jsx:65 +msgid "Most useful fields to group this metric by" +msgstr "" + +#: frontend/src/metabase/reference/components/RevisionMessageModal.jsx:32 +msgid "Reason for changes" +msgstr "" + +#: frontend/src/metabase/reference/components/RevisionMessageModal.jsx:36 +msgid "" +"Leave a note to explain what changes you made and why they were required" +msgstr "" + +#: frontend/src/metabase/reference/databases/DatabaseDetail.jsx:166 +msgid "Why this database is interesting" +msgstr "" + +#: frontend/src/metabase/reference/databases/DatabaseDetail.jsx:176 +msgid "Things to be aware of about this database" +msgstr "" + +#: frontend/src/metabase/reference/databases/DatabaseList.jsx:46 +#: frontend/src/metabase/reference/guide/BaseSidebar.jsx:45 +msgid "Databases and tables" +msgstr "" + +#: frontend/src/metabase/reference/databases/DatabaseSidebar.jsx:27 +#: frontend/src/metabase/reference/databases/FieldSidebar.jsx:45 +#: frontend/src/metabase/reference/databases/TableDetail.jsx:170 +#: frontend/src/metabase/reference/databases/TableSidebar.jsx:31 +#: frontend/src/metabase/reference/metrics/MetricDetail.jsx:184 +#: frontend/src/metabase/reference/metrics/MetricSidebar.jsx:27 +#: frontend/src/metabase/reference/segments/SegmentDetail.jsx:188 +#: frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx:187 +#: frontend/src/metabase/reference/segments/SegmentFieldSidebar.jsx:31 +#: frontend/src/metabase/reference/segments/SegmentSidebar.jsx:27 +msgid "Details" +msgstr "" + +#: frontend/src/metabase/reference/databases/DatabaseSidebar.jsx:33 +#: frontend/src/metabase/reference/databases/TableList.jsx:111 +msgid "Tables in {0}" +msgstr "" + +#: frontend/src/metabase/reference/databases/FieldDetail.jsx:222 +#: frontend/src/metabase/reference/databases/TableDetail.jsx:200 +#: frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx:218 +msgid "Actual name in database" +msgstr "" + +#: frontend/src/metabase/reference/databases/FieldDetail.jsx:231 +#: frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx:227 +msgid "Why this field is interesting" +msgstr "" + +#: frontend/src/metabase/reference/databases/FieldDetail.jsx:241 +#: frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx:237 +msgid "Things to be aware of about this field" +msgstr "" + +#: frontend/src/metabase/reference/databases/FieldDetail.jsx:253 +#: frontend/src/metabase/reference/databases/FieldList.jsx:155 +#: frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx:249 +#: frontend/src/metabase/reference/segments/SegmentFieldList.jsx:156 +msgid "Data type" +msgstr "" + +#: frontend/src/metabase/reference/databases/FieldList.jsx:39 +#: frontend/src/metabase/reference/segments/SegmentFieldList.jsx:39 +msgid "Fields in this table will appear here as they're added" +msgstr "" + +#: frontend/src/metabase/reference/databases/FieldList.jsx:134 +#: frontend/src/metabase/reference/segments/SegmentFieldList.jsx:135 +msgid "Fields in {0}" +msgstr "" + +#: frontend/src/metabase/reference/databases/FieldList.jsx:149 +#: frontend/src/metabase/reference/segments/SegmentFieldList.jsx:150 +msgid "Field name" +msgstr "" + +#: frontend/src/metabase/reference/databases/FieldSidebar.jsx:52 +msgid "X-ray this Field" +msgstr "" + +#: frontend/src/metabase/reference/databases/NoDatabasesEmptyState.jsx:8 +msgid "Metabase is no fun without any data" +msgstr "" + +#: frontend/src/metabase/reference/databases/NoDatabasesEmptyState.jsx:9 +msgid "Your databases will appear here once you connect one" +msgstr "" + +#: frontend/src/metabase/reference/databases/NoDatabasesEmptyState.jsx:10 +msgid "Databases will appear here once your admins have added some" +msgstr "" + +#: frontend/src/metabase/reference/databases/NoDatabasesEmptyState.jsx:12 +msgid "Connect a database" +msgstr "" + +#: frontend/src/metabase/reference/databases/TableDetail.jsx:38 +msgid "Count of {0}" +msgstr "" + +#: frontend/src/metabase/reference/databases/TableDetail.jsx:47 +msgid "See raw data for {0}" +msgstr "" + +#: frontend/src/metabase/reference/databases/TableDetail.jsx:209 +msgid "Why this table is interesting" +msgstr "" + +#: frontend/src/metabase/reference/databases/TableDetail.jsx:219 +msgid "Things to be aware of about this table" +msgstr "" + +#: frontend/src/metabase/reference/databases/TableList.jsx:30 +msgid "Tables in this database will appear here as they're added" +msgstr "" + +#: frontend/src/metabase/reference/databases/TableQuestions.jsx:34 +msgid "Questions about this table will appear here as they're added" +msgstr "" + +#: frontend/src/metabase/reference/databases/TableQuestions.jsx:71 +#: frontend/src/metabase/reference/metrics/MetricQuestions.jsx:75 +#: frontend/src/metabase/reference/metrics/MetricSidebar.jsx:33 +#: frontend/src/metabase/reference/segments/SegmentQuestions.jsx:74 +msgid "Questions about {0}" +msgstr "" + +#: frontend/src/metabase/reference/databases/TableQuestions.jsx:95 +#: frontend/src/metabase/reference/metrics/MetricQuestions.jsx:99 +#: frontend/src/metabase/reference/segments/SegmentQuestions.jsx:98 +msgid "Created {0} by {1}" +msgstr "" + +#: frontend/src/metabase/reference/databases/TableSidebar.jsx:37 +msgid "Fields in this table" +msgstr "" + +#: frontend/src/metabase/reference/databases/TableSidebar.jsx:45 +msgid "Questions about this table" +msgstr "" + +#: frontend/src/metabase/reference/databases/TableSidebar.jsx:52 +msgid "X-ray this table" +msgstr "" + +#: frontend/src/metabase/reference/guide/BaseSidebar.jsx:27 +msgid "Start here" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:160 +msgid "Help your team get started with your data." +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:162 +msgid "" +"Show your team what’s most important by choosing your top dashboard, " +"metrics, and segments." +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:168 +msgid "Get started" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:176 +msgid "Our most important dashboard" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:191 +msgid "Numbers that we pay attention to" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:216 +msgid "" +"Metrics are important numbers your company cares about. They often represent " +"a core indicator of how the business is performing." +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:224 +msgid "See all metrics" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:238 +msgid "Segments and tables" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:239 +msgid "Tables" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:265 +msgid "" +"Segments and tables are the building blocks of your company's data. Tables " +"are collections of the raw information while segments are specific slices " +"with specific meanings, like {0}" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:270 +msgid "Tables are the building blocks of your company's data." +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:280 +msgid "See all segments" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:296 +msgid "See all tables" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:304 +msgid "Other things to know about our data" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:305 +msgid "Find out more" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:310 +msgid "" +"A good way to get to know your data is by spending a bit of time exploring " +"the different tables and other info available to you. It may take a while, " +"but you'll start to recognize names and meanings over time." +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:316 +msgid "Explore our data" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:324 +msgid "Have questions?" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuide.jsx:329 +msgid "Contact {0}" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:250 +msgid "Help new Metabase users find their way around." +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:253 +msgid "" +"The Getting Started guide highlights the dashboard, metrics, segments, and " +"tables that matter most, and informs your users of important things they " +"should know before digging into the data." +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:260 +msgid "Is there an important dashboard for your team?" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:262 +msgid "Create a dashboard now" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:268 +msgid "What is your most important dashboard?" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:287 +msgid "Do you have any commonly referenced metrics?" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:289 +msgid "Learn how to define a metric" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:302 +msgid "What are your 3-5 most commonly referenced metrics?" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:346 +msgid "Add another metric" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:359 +msgid "Do you have any commonly referenced segments or tables?" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:361 +msgid "Learn how to create a segment" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:374 +msgid "" +"What are 3-5 commonly referenced segments or tables that would be useful for " +"this audience?" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:420 +msgid "Add another segment or table" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:429 +msgid "" +"Is there anything your users should understand or know before they start " +"accessing the data?" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:435 +msgid "What should a user of this data know before they start accessing it?" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:439 +msgid "" +"E.g., expectations around data privacy and use, common pitfalls or " +"misunderstandings, information about data warehouse performance, legal " +"notices, etc." +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:450 +msgid "" +"Is there someone your users could contact for help if they're confused about " +"this guide?" +msgstr "" + +#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:459 +msgid "Who should users contact for help if they're confused about this data?" +msgstr "" + +#: frontend/src/metabase/reference/metrics/MetricDetail.jsx:75 +#: frontend/src/metabase/reference/segments/SegmentDetail.jsx:95 +msgid "Please enter a revision message" +msgstr "" + +#: frontend/src/metabase/reference/metrics/MetricDetail.jsx:213 +msgid "Why this Metric is interesting" +msgstr "" + +#: frontend/src/metabase/reference/metrics/MetricDetail.jsx:223 +msgid "Things to be aware of about this Metric" +msgstr "" + +#: frontend/src/metabase/reference/metrics/MetricDetail.jsx:233 +msgid "How this Metric is calculated" +msgstr "" + +#: frontend/src/metabase/reference/metrics/MetricDetail.jsx:235 +msgid "Nothing on how it's calculated yet" +msgstr "" + +#: frontend/src/metabase/reference/metrics/MetricDetail.jsx:293 +msgid "Other fields you can group this metric by" +msgstr "" + +#: frontend/src/metabase/reference/metrics/MetricDetail.jsx:294 +msgid "Fields you can group this metric by" +msgstr "" + +#: frontend/src/metabase/reference/metrics/MetricList.jsx:23 +msgid "Metrics are the official numbers that your team cares about" +msgstr "" + +#: frontend/src/metabase/reference/metrics/MetricList.jsx:25 +msgid "Metrics will appear here once your admins have created some" +msgstr "" + +#: frontend/src/metabase/reference/metrics/MetricList.jsx:27 +msgid "Learn how to create metrics" +msgstr "" + +#: frontend/src/metabase/reference/metrics/MetricQuestions.jsx:35 +msgid "Questions about this metric will appear here as they're added" +msgstr "" + +#: frontend/src/metabase/reference/metrics/MetricRevisions.jsx:29 +msgid "There are no revisions for this metric" +msgstr "" + +#: frontend/src/metabase/reference/metrics/MetricRevisions.jsx:88 +#: frontend/src/metabase/reference/metrics/MetricSidebar.jsx:41 +#: frontend/src/metabase/reference/segments/SegmentRevisions.jsx:88 +msgid "Revision history for {0}" +msgstr "" + +#: frontend/src/metabase/reference/segments/SegmentDetail.jsx:217 +msgid "Why this Segment is interesting" +msgstr "" + +#: frontend/src/metabase/reference/segments/SegmentDetail.jsx:227 +msgid "Things to be aware of about this Segment" +msgstr "" + +#: frontend/src/metabase/reference/segments/SegmentList.jsx:23 +msgid "Segments are interesting subsets of tables" +msgstr "" + +#: frontend/src/metabase/reference/segments/SegmentList.jsx:24 +msgid "" +"Defining common segments for your team makes it even easier to ask questions" +msgstr "" + +#: frontend/src/metabase/reference/segments/SegmentList.jsx:25 +msgid "Segments will appear here once your admins have created some" +msgstr "" + +#: frontend/src/metabase/reference/segments/SegmentList.jsx:27 +msgid "Learn how to create segments" +msgstr "" + +#: frontend/src/metabase/reference/segments/SegmentQuestions.jsx:35 +msgid "Questions about this segment will appear here as they're added" +msgstr "" + +#: frontend/src/metabase/reference/segments/SegmentRevisions.jsx:29 +msgid "There are no revisions for this segment" +msgstr "" + +#: frontend/src/metabase/reference/segments/SegmentSidebar.jsx:33 +#: frontend/src/metabase/xray/containers/SegmentXRay.jsx:170 +msgid "Fields in this segment" +msgstr "" + +#: frontend/src/metabase/reference/segments/SegmentSidebar.jsx:39 +msgid "Questions about this segment" +msgstr "" + +#: frontend/src/metabase/reference/segments/SegmentSidebar.jsx:45 +msgid "X-ray this segment" +msgstr "" + +#: frontend/src/metabase/routes.jsx:193 +msgid "Login" +msgstr "" + +#: frontend/src/metabase/routes.jsx:221 +msgid "Dashboard" +msgstr "" + +#: frontend/src/metabase/routes.jsx:247 +msgid "Search" +msgstr "" + +#: frontend/src/metabase/routes.jsx:346 +#: frontend/src/metabase/xray/containers/TableXRay.jsx:119 +msgid "XRay" +msgstr "" + +#: frontend/src/metabase/routes.jsx:382 +msgid "Data Model" +msgstr "" + +#: frontend/src/metabase/setup/components/DatabaseConnectionStep.jsx:141 +msgid "Add your data" +msgstr "" + +#: frontend/src/metabase/setup/components/DatabaseConnectionStep.jsx:145 +msgid "I'll add my own data later" +msgstr "" + +#: frontend/src/metabase/setup/components/DatabaseConnectionStep.jsx:146 +msgid "Connecting to {0}" +msgstr "" + +#: frontend/src/metabase/setup/components/DatabaseConnectionStep.jsx:165 +msgid "" +"You’ll need some info about your database, like the username and password. " +"If you don’t have that right now, Metabase also comes with a sample dataset " +"you can get started with." +msgstr "" + +#: frontend/src/metabase/setup/components/DatabaseConnectionStep.jsx:196 +msgid "I'll add my data later" +msgstr "" + +#: frontend/src/metabase/setup/components/DatabaseSchedulingStep.jsx:41 +msgid "Control automatic scans" +msgstr "" + +#: frontend/src/metabase/setup/components/PreferencesStep.jsx:53 +msgid "Usage data preferences" +msgstr "" + +#: frontend/src/metabase/setup/components/PreferencesStep.jsx:56 +msgid "Thanks for helping us improve" +msgstr "" + +#: frontend/src/metabase/setup/components/PreferencesStep.jsx:57 +msgid "We won't collect any usage events" +msgstr "" + +#: frontend/src/metabase/setup/components/PreferencesStep.jsx:76 +msgid "" +"In order to help us improve Metabase, we'd like to collect certain data " +"about usage through Google Analytics." +msgstr "" + +#: frontend/src/metabase/setup/components/PreferencesStep.jsx:85 +msgid "Here's a full list of everything we track and why." +msgstr "" + +#: frontend/src/metabase/setup/components/PreferencesStep.jsx:98 +msgid "Allow Metabase to anonymously collect usage events" +msgstr "" + +#: frontend/src/metabase/setup/components/PreferencesStep.jsx:105 +msgid "Metabase {0} collects anything about your data or question results." +msgstr "" + +#: frontend/src/metabase/setup/components/PreferencesStep.jsx:108 +msgid "All collection is completely anonymous." +msgstr "" + +#: frontend/src/metabase/setup/components/PreferencesStep.jsx:110 +msgid "Collection can be turned off at any point in your admin settings." +msgstr "" + +#: frontend/src/metabase/setup/components/Setup.jsx:45 +msgid "If you feel stuck" +msgstr "" + +#: frontend/src/metabase/setup/components/Setup.jsx:50 +msgid "our getting started guide" +msgstr "" + +#: frontend/src/metabase/setup/components/Setup.jsx:51 +msgid "is just a click away." +msgstr "" + +#: frontend/src/metabase/setup/components/Setup.jsx:93 +msgid "Welcome to Metabase" +msgstr "" + +#: frontend/src/metabase/setup/components/Setup.jsx:94 +msgid "" +"Looks like everything is working. Now let’s get to know you, connect to your " +"data, and start finding you some answers!" +msgstr "" + +#: frontend/src/metabase/setup/components/Setup.jsx:98 +msgid "Let's get started" +msgstr "" + +#: frontend/src/metabase/setup/components/Setup.jsx:143 +msgid "You're all set up!" +msgstr "" + +#: frontend/src/metabase/setup/components/Setup.jsx:154 +msgid "Take me to Metabase" +msgstr "" + +#: frontend/src/metabase/setup/components/UserStep.jsx:152 +msgid "What should we call you?" +msgstr "" + +#: frontend/src/metabase/setup/components/UserStep.jsx:153 +msgid "Hi, {0}. nice to meet you!" +msgstr "" + +#: frontend/src/metabase/setup/components/UserStep.jsx:238 +msgid "Create a password" +msgstr "" + +#: frontend/src/metabase/setup/components/UserStep.jsx:254 +#: frontend/src/metabase/user/components/SetUserPassword.jsx:112 +msgid "Shhh..." +msgstr "" + +#: frontend/src/metabase/setup/components/UserStep.jsx:264 +msgid "Confirm password" +msgstr "" + +#: frontend/src/metabase/setup/components/UserStep.jsx:273 +msgid "Shhh... but one more time so we get it right" +msgstr "" + +#: frontend/src/metabase/setup/components/UserStep.jsx:282 +msgid "Your company or team name" +msgstr "" + +#: frontend/src/metabase/setup/components/UserStep.jsx:291 +msgid "Department of awesome" +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:20 +msgid "Welcome to the Query Builder!" +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:22 +msgid "" +"The Query Builder lets you assemble questions (or \"queries\") to ask about " +"your data." +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:26 +msgid "Tell me more" +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:43 +msgid "" +"Start by picking the table with the data that you have a question about." +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:45 +msgid "Go ahead and select the \"Orders\" table from the dropdown menu." +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:78 +msgid "Filter your data to get just what you want." +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:79 +msgid "Click the plus button and select the \"Created At\" field." +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:93 +msgid "Here we can pick how many days we want to see data for, try 10" +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:116 +msgid "" +"Here's where you can choose to add or average your data, count the number of " +"rows in the table, or just view the raw data." +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:118 +msgid "" +"Try it: click on <strong>Raw Data</strong> to change it to <strong>Count of " +"rows</strong> so we can count how many orders there are in this table." +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:142 +msgid "" +"Add a grouping to break out your results by category, day, month, and more." +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:144 +msgid "" +"Let's do it: click on <strong>Add a grouping</strong>, and choose " +"<strong>Created At: by Week</strong>." +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:152 +msgid "Click on \"by day\" to change it to \"Week.\"" +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:173 +msgid "Run Your Query." +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:175 +msgid "" +"You're doing so well! Click <strong>Run query</strong> to get your results!" +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:192 +msgid "You can view your results as a chart instead of a table." +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:194 +msgid "" +"Everbody likes charts! Click the <strong>Visualization</strong> dropdown and " +"select <strong>Line</strong>." +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:216 +msgid "Well done!" +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:218 +msgid "That's all! If you still have questions, check out our" +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:223 +msgid "User's Guide" +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:223 +msgid "Have fun exploring your data!" +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:226 +msgid "Thanks" +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:235 +msgid "Save Your Questions" +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:237 +msgid "" +"By the way, you can save your questions so you can refer to them later. " +"Saved Questions can also be put into dashboards or Pulses." +msgstr "" + +#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:241 +msgid "Sounds good" +msgstr "" + +#: frontend/src/metabase/tutorial/Tutorial.jsx:240 +msgid "Whoops!" +msgstr "" + +#: frontend/src/metabase/tutorial/Tutorial.jsx:241 +msgid "" +"Sorry, it looks like something went wrong. Please try restarting the " +"tutorial in a minute." +msgstr "" + +#: frontend/src/metabase/user/actions.js:34 +msgid "Password updated successfully!" +msgstr "" + +#: frontend/src/metabase/user/actions.js:53 +msgid "Account updated successfully!" +msgstr "" + +#: frontend/src/metabase/user/components/SetUserPassword.jsx:103 +msgid "Current password" +msgstr "" + +#: frontend/src/metabase/user/components/UpdateUserDetails.jsx:135 +msgid "Sign in with Google Email address" +msgstr "" + +#: frontend/src/metabase/user/components/UserSettings.jsx:54 +msgid "Account settings" +msgstr "" + +#: frontend/src/metabase/user/components/UserSettings.jsx:65 +msgid "User Details" +msgstr "" + +#: frontend/src/metabase/visualizations/components/ChartSettings.jsx:150 +msgid "Customize this {0}" +msgstr "" + +#: frontend/src/metabase/visualizations/components/ChartSettings.jsx:192 +msgid "Reset to defaults" +msgstr "" + +#: frontend/src/metabase/visualizations/components/ChoroplethMap.jsx:120 +msgid "unknown map" +msgstr "" + +#: frontend/src/metabase/visualizations/components/LeafletGridHeatMap.jsx:25 +msgid "Grid map requires binned longitude/latitude." +msgstr "" + +#: frontend/src/metabase/visualizations/components/LegendVertical.jsx:112 +msgid "more" +msgstr "" + +#: frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx:101 +msgid "Which fields do you want to use for the X and Y axes?" +msgstr "" + +#: frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx:103 +#: frontend/src/metabase/visualizations/visualizations/Funnel.jsx:59 +msgid "Choose fields" +msgstr "" + +#: frontend/src/metabase/visualizations/components/PinMap.jsx:206 +msgid "Save as default view" +msgstr "" + +#: frontend/src/metabase/visualizations/components/PinMap.jsx:228 +msgid "Draw box to filter" +msgstr "" + +#: frontend/src/metabase/visualizations/components/PinMap.jsx:228 +msgid "Cancel filter" +msgstr "" + +#: frontend/src/metabase/visualizations/components/PinMap.jsx:47 +msgid "Pin Map" +msgstr "" + +#: frontend/src/metabase/visualizations/components/TableInteractive.jsx:310 +msgid "Unset" +msgstr "" + +#: frontend/src/metabase/visualizations/components/TableSimple.jsx:214 +msgid "Rows {0}-{1} of {2}" +msgstr "" + +#: frontend/src/metabase/visualizations/components/Visualization.jsx:175 +msgid "Data truncated to {0} rows." +msgstr "" + +#: frontend/src/metabase/visualizations/components/Visualization.jsx:338 +msgid "Could not find visualization" +msgstr "" + +#: frontend/src/metabase/visualizations/components/Visualization.jsx:345 +msgid "Could not display this chart with this data." +msgstr "" + +#: frontend/src/metabase/visualizations/components/Visualization.jsx:442 +msgid "No results!" +msgstr "" + +#: frontend/src/metabase/visualizations/components/Visualization.jsx:463 +msgid "Still Waiting..." +msgstr "" + +#: frontend/src/metabase/visualizations/components/Visualization.jsx:466 +msgid "This usually takes an average of {0}." +msgstr "" + +#: frontend/src/metabase/visualizations/components/Visualization.jsx:472 +msgid "(This is a bit long for a dashboard)" +msgstr "" + +#: frontend/src/metabase/visualizations/components/Visualization.jsx:476 +msgid "This is usually pretty fast, but seems to be taking awhile right now." +msgstr "" + +#: frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx:14 +msgid "Select a field" +msgstr "" + +#: frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx:15 +msgid "No valid fields" +msgstr "" + +#: frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx:42 +msgid "error" +msgstr "" + +#: frontend/src/metabase/visualizations/index.js:32 +msgid "Visualization must define an 'identifier' static variable: " +msgstr "" + +#: frontend/src/metabase/visualizations/index.js:38 +msgid "Visualization with that identifier is already registered: " +msgstr "" + +#: frontend/src/metabase/visualizations/index.js:66 +msgid "No visualization for {0}" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js:72 +msgid "" +"\"{0}\" is an unaggregated field: if it has more than one value at a point " +"on the x-axis, the values will be summed." +msgstr "" + +#: frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js:88 +msgid "This chart type requires at least 2 columns." +msgstr "" + +#: frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js:93 +msgid "This chart type doesn't support more than {0} series of data." +msgstr "" + +#: frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js:499 +#: frontend/src/metabase/visualizations/visualizations/Progress.jsx:42 +msgid "Goal" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/errors.js:10 +msgid "" +"Doh! The data from your query doesn't fit the chosen display choice. This " +"visualization requires at least {0} {1} of data." +msgstr "" + +#: frontend/src/metabase/visualizations/lib/errors.js:21 +msgid "" +"No dice. We have {0} data {1} to show and that's not enough for this " +"visualization." +msgstr "" + +#: frontend/src/metabase/visualizations/lib/errors.js:32 +msgid "" +"Bummer. We can't actually do a pin map for this data because we require both " +"a latitude and longitude column." +msgstr "" + +#: frontend/src/metabase/visualizations/lib/errors.js:41 +msgid "Please configure this chart in the chart settings" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/errors.js:43 +msgid "Edit Settings" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/fill_data.js:38 +msgid "\"xValues missing!" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:47 +#: frontend/src/metabase/visualizations/visualizations/RowChart.jsx:31 +msgid "X-axis" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:73 +msgid "Add a series breakout..." +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:84 +#: frontend/src/metabase/visualizations/visualizations/RowChart.jsx:35 +msgid "Y-axis" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:109 +msgid "Add another series..." +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:123 +msgid "Bubble size" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:152 +#: frontend/src/metabase/visualizations/visualizations/LineChart.jsx:17 +msgid "Line" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:153 +msgid "Curve" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:154 +#: frontend/src/metabase/visualizations/visualizations/Funnel.jsx:67 +msgid "Step" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:161 +msgid "Show point markers on lines" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:169 +msgid "Stacking" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:173 +msgid "Don't stack" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:174 +msgid "Stack" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:175 +msgid "Stack - 100%" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:192 +msgid "Show goal" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:198 +msgid "Goal value" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:209 +msgid "Replace missing values with" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:214 +msgid "Zero" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:215 +msgid "Nothing" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:216 +msgid "Linear Interpolated" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:271 +msgid "X-axis scale" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:288 +msgid "Timeseries" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:291 +#: frontend/src/metabase/visualizations/lib/settings/graph.js:309 +msgid "Linear" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:293 +#: frontend/src/metabase/visualizations/lib/settings/graph.js:310 +msgid "Power" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:294 +#: frontend/src/metabase/visualizations/lib/settings/graph.js:311 +msgid "Log" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:296 +msgid "Histogram" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:298 +msgid "Ordinal" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:304 +msgid "Y-axis scale" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:317 +msgid "Show x-axis line and marks" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:323 +msgid "Show y-axis line and marks" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:329 +msgid "Auto y-axis range" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:373 +msgid "Use a split y-axis when necessary" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:380 +msgid "Show label on x-axis" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:386 +msgid "X-axis label" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:395 +msgid "Show label on y-axis" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/settings/graph.js:401 +msgid "Y-axis label" +msgstr "" + +#: frontend/src/metabase/visualizations/lib/utils.js:116 +msgid "Standard Deviation" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/AreaChart.jsx:18 +msgid "Area" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/AreaChart.jsx:21 +msgid "area chart" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/BarChart.jsx:16 +msgid "Bar" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/BarChart.jsx:19 +msgid "bar chart" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Funnel.jsx:57 +msgid "Which fields do you want to use?" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Funnel.jsx:31 +#: frontend/src/metabase/visualizations/visualizations/Funnel.jsx:85 +msgid "Funnel" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Funnel.jsx:74 +#: frontend/src/metabase/visualizations/visualizations/PieChart.jsx:67 +msgid "Measure" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Funnel.jsx:80 +msgid "Funnel type" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Funnel.jsx:86 +msgid "Bar chart" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/LineChart.jsx:20 +msgid "line chart" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:206 +msgid "Please select longitude and latitude columns in the chart settings." +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:212 +msgid "Please select a region map." +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:216 +msgid "Please select region and metric columns in the chart settings." +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:33 +msgid "Map" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:47 +msgid "Map type" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:51 +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:141 +msgid "Region map" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:52 +msgid "Pin map" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:91 +msgid "Pin type" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:96 +msgid "Tiles" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:97 +msgid "Markers" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:113 +msgid "Latitude field" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:123 +msgid "Longitude field" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:133 +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:160 +msgid "Metric field" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:165 +msgid "Region field" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:174 +msgid "Radius" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:180 +msgid "Blur" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:186 +msgid "Min Opacity" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Map.jsx:192 +msgid "Max Zoom" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx:169 +msgid "No relationships found." +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx:207 +msgid "via {0}" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx:284 +msgid "This {0} is connected to:" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx:47 +msgid "Object Detail" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx:50 +msgid "object" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/PieChart.jsx:234 +msgid "Total" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/PieChart.jsx:53 +msgid "Which columns do you want to use?" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/PieChart.jsx:40 +msgid "Pie" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/PieChart.jsx:62 +msgid "Dimension" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/PieChart.jsx:72 +msgid "Show legend" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/PieChart.jsx:77 +msgid "Show percentages in legend" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/PieChart.jsx:83 +msgid "Minimum slice percentage" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Progress.jsx:135 +msgid "Goal met" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Progress.jsx:137 +msgid "Goal exceeded" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Progress.jsx:207 +msgid "Goal {0}" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Progress.jsx:35 +msgid "Progress visualization requires a number." +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Progress.jsx:23 +msgid "Progress" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/RowChart.jsx:13 +msgid "Row Chart" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/RowChart.jsx:16 +msgid "row chart" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Scalar.jsx:75 +msgid "Separator style" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Scalar.jsx:88 +msgid "Number of decimal places" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Scalar.jsx:92 +msgid "Add a prefix" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Scalar.jsx:96 +msgid "Add a suffix" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Scalar.jsx:100 +msgid "Multiply by a number" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/ScatterPlot.jsx:16 +msgid "Scatter" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/ScatterPlot.jsx:19 +msgid "scatter plot" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Table.jsx:56 +msgid "Pivot the table" +msgstr "" + +#: frontend/src/metabase/visualizations/visualizations/Table.jsx:67 +msgid "Fields to include" +msgstr "" + +#: frontend/src/metabase/xray/components/ComparisonHeader.jsx:10 +msgid "Comparing" +msgstr "" + +#: frontend/src/metabase/xray/components/ComparisonHeader.jsx:13 +#: frontend/src/metabase/xray/containers/FieldXray.jsx:128 +#: frontend/src/metabase/xray/containers/SegmentXRay.jsx:155 +msgid "Fidelity" +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:15 +msgid "Was this helpful?" +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:42 +msgid "Most of the values for {0} are between {1} and {2}." +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:35 +msgid "Normal range of values" +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:70 +msgid "You have {0} missing (null) values in your data" +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:51 +msgid "Missing data" +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:103 +msgid "" +"You have {0} zeros in your data. They may be stand-ins for missing data, or " +"might indicate some other abnormality." +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:84 +msgid "Zeros in your data" +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:115 +msgid "" +"Noisy data is highly variable, jumping all over the place with changes " +"carrying relatively little information." +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:120 +msgid "Noisy data" +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:142 +msgid "A measure of how much changes in previous values predict future values." +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:147 +msgid "Autocorrelation" +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:168 +msgid "How variance in your data is changing over time." +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:173 +msgid "Trending variation" +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:204 +msgid "Your data has a {0} seasonal component." +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:196 +msgid "Seasonality" +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:210 +msgid "Data distribution with multiple peaks (modes)." +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:215 +msgid "Multimodal" +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:236 +msgid "Outliers" +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:269 +msgid "Structural breaks" +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:301 +msgid "The mean does not change over time." +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:306 +msgid "Stationary data" +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:333 +msgid "Your data seems to be {0} {1}." +msgstr "" + +#: frontend/src/metabase/xray/components/InsightCard.jsx:326 +#: frontend/src/metabase/xray/containers/CardXRay.jsx:157 +#: frontend/src/metabase/xray/containers/CardXRay.jsx:189 +msgid "Trend" +msgstr "" + +#: frontend/src/metabase/xray/components/PreviewBanner.jsx:15 +msgid "Welcome to the x-ray preview! We'd love {0}" +msgstr "" + +#: frontend/src/metabase/xray/components/XRayComparison.jsx:191 +msgid "Overview" msgstr "" -#: frontend/src/metabase/home/components/Activity.jsx:176 -msgid "removed the filter {item.details.name}" +#: frontend/src/metabase/xray/components/XRayComparison.jsx:207 +msgid "Potentially interesting differences" msgstr "" -#: frontend/src/metabase/home/components/Activity.jsx:179 -msgid "joined!" +#: frontend/src/metabase/xray/components/XRayComparison.jsx:222 +msgid "Full breakdown" msgstr "" -#: frontend/src/metabase/home/components/NextStep.jsx:31 -msgid "Setup Tip" +#: frontend/src/metabase/xray/components/XRayComparison.jsx:238 +#: frontend/src/metabase/xray/containers/FieldXray.jsx:68 +msgid "Field" msgstr "" -#: frontend/src/metabase/home/components/NextStep.jsx:32 -msgid "View all" +#: frontend/src/metabase/xray/containers/CardXRay.jsx:134 +msgid "Growth Trend" msgstr "" -#: frontend/src/metabase/home/components/RecentViews.jsx:38 -msgid "Recently Viewed" +#: frontend/src/metabase/xray/containers/CardXRay.jsx:197 +msgid "Seasonal" msgstr "" -#: frontend/src/metabase/home/components/RecentViews.jsx:60 -msgid "You haven't looked at any dashboards or questions recently" +#: frontend/src/metabase/xray/containers/CardXRay.jsx:206 +msgid "Residual" msgstr "" -#: frontend/src/metabase/home/containers/HomepageApp.jsx:93 -msgid "Activity" +#: frontend/src/metabase/xray/containers/FieldXray.jsx:125 +#: frontend/src/metabase/xray/containers/SegmentXRay.jsx:148 +msgid "X-ray" msgstr "" -#: frontend/src/metabase/lib/greeting.js:2 -msgid "Hey there" +#: frontend/src/metabase/xray/containers/FieldXray.jsx:163 +msgid "Values overview" msgstr "" -#: frontend/src/metabase/lib/greeting.js:3 -#: frontend/src/metabase/lib/greeting.js:25 -msgid "How's it going" +#: frontend/src/metabase/xray/containers/FieldXray.jsx:169 +msgid "Statistical overview" msgstr "" -#: frontend/src/metabase/lib/greeting.js:4 -msgid "Howdy" +#: frontend/src/metabase/xray/containers/FieldXray.jsx:176 +msgid "Robots" msgstr "" -#: frontend/src/metabase/lib/greeting.js:5 -msgid "Greetings" +#: frontend/src/metabase/xray/containers/SegmentXRay.jsx:64 +msgid "Segment" msgstr "" -#: frontend/src/metabase/lib/greeting.js:6 -msgid "Good to see you" +#: frontend/src/metabase/xray/containers/TableXRay.jsx:126 +msgid "Fidelity:" msgstr "" -#: frontend/src/metabase/lib/greeting.js:10 -msgid "What do you want to know?" +#: frontend/src/metabase/xray/costs.js:8 +msgid "Approximate" msgstr "" -#: frontend/src/metabase/lib/greeting.js:11 -msgid "What's on your mind?" +#: frontend/src/metabase/xray/costs.js:9 +msgid "" +"Get a sense for this data by looking at a sample.\n" +"This is faster but less precise." msgstr "" -#: frontend/src/metabase/lib/greeting.js:12 -msgid "What do you want to find out?" +#: frontend/src/metabase/xray/costs.js:21 +msgid "Exact" msgstr "" -#: frontend/src/metabase/nav/containers/Navbar.jsx:129 -msgid "Dashboards" +#: frontend/src/metabase/xray/costs.js:22 +msgid "" +"Go deeper into this data by performing a full scan.\n" +"This is more precise but slower." msgstr "" -#: frontend/src/metabase/nav/containers/Navbar.jsx:132 -msgid "Questions" +#: frontend/src/metabase/xray/costs.js:34 +msgid "Extended" msgstr "" -#: frontend/src/metabase/nav/containers/Navbar.jsx:135 -msgid "Pulses" +#: frontend/src/metabase/xray/costs.js:35 +msgid "" +"Adds additional info about this entity by including related objects.\n" +"This is the slowest but highest fidelity method." msgstr "" -#: frontend/src/metabase/nav/containers/Navbar.jsx:138 -msgid "Data Reference" +#: frontend/src/metabase/xray/utils.js:7 +msgid "Very different" msgstr "" -#: frontend/src/metabase/nav/containers/Navbar.jsx:142 -msgid "New Question" +#: frontend/src/metabase/xray/utils.js:9 +msgid "Somewhat different" +msgstr "" + +#: frontend/src/metabase/xray/utils.js:11 +msgid "Somewhat similar" +msgstr "" + +#: frontend/src/metabase/xray/utils.js:13 +msgid "Very similar" +msgstr "" + +#: frontend/src/metabase/xray/utils.js:27 +msgid "Generating your x-ray..." +msgstr "" + +#: frontend/src/metabase/xray/utils.js:28 +#: frontend/src/metabase/xray/utils.js:33 +msgid "Still working..." +msgstr "" + +#: frontend/src/metabase/xray/utils.js:32 +msgid "Generating your comparison..." msgstr "" #: src/metabase/api/setup.clj @@ -123,16 +7140,416 @@ msgstr "" msgid "Connect to your data so your whole team can start to explore." msgstr "" -#. This is the very first log message that will get printed. -#. It's here because this is one of the very first namespaces that gets loaded, and the first that has access to the logger -#. It shows up a solid 10-15 seconds before the "Starting Metabase in STANDALONE mode" message because so many other namespaces need to get loaded -#: src/metabase/util.clj -msgid "Loading {0}..." +#: src/metabase/api/setup.clj +msgid "Set up email" msgstr "" -#. This is the very first log message that will get printed. -#. It's here because this is one of the very first namespaces that gets loaded, and the first that has access to the logger -#. It shows up a solid 10-15 seconds before the "Starting Metabase in STANDALONE mode" message because so many other namespaces need to get loaded -#: src/metabase/util.clj +#: src/metabase/api/setup.clj +msgid "" +"Add email credentials so you can more easily invite team members and get " +"updates via Pulses." +msgstr "" + +#: src/metabase/api/setup.clj +msgid "Set Slack credentials" +msgstr "" + +#: src/metabase/api/setup.clj +msgid "" +"Does your team use Slack? If so, you can send automated updates via pulses " +"and ask questions with MetaBot." +msgstr "" + +#: src/metabase/api/setup.clj +msgid "Invite team members" +msgstr "" + +#: src/metabase/api/setup.clj +msgid "Share answers and data with the rest of your team." +msgstr "" + +#: src/metabase/api/setup.clj +msgid "Hide irrelevant tables" +msgstr "" + +#: src/metabase/api/setup.clj +msgid "Curate your data" +msgstr "" + +#: src/metabase/api/setup.clj +msgid "If your data contains technical or irrelevant info you can hide it." +msgstr "" + +#: src/metabase/api/setup.clj +msgid "Organize questions" +msgstr "" + +#: src/metabase/api/setup.clj +msgid "" +"Have a lot of saved questions in {0}? Create collections to help manage them " +"and add context." +msgstr "" + +#: src/metabase/api/setup.clj msgid "Metabase" msgstr "" + +#: src/metabase/api/setup.clj +msgid "Create metrics" +msgstr "" + +#: src/metabase/api/setup.clj +msgid "" +"Define canonical metrics to make it easier for the rest of your team to get " +"the right answers." +msgstr "" + +#: src/metabase/api/setup.clj +msgid "Create segments" +msgstr "" + +#: src/metabase/api/setup.clj +msgid "" +"Keep everyone on the same page by creating canonical sets of filters anyone " +"can use while asking questions." +msgstr "" + +#: src/metabase/driver/googleanalytics.clj +msgid "" +"You must enable the Google Analytics API. Use this link to go to the Google " +"Developers Console: {0}" +msgstr "" + +#: src/metabase/driver/h2.clj +msgid "" +"Running SQL queries against H2 databases using the default (admin) database " +"user is forbidden." +msgstr "" + +#. CONFIG +#. TODO - smtp-port should be switched to type :integer +#: src/metabase/email.clj +msgid "Email address you want to use as the sender of Metabase." +msgstr "" + +#: src/metabase/email.clj +msgid "The address of the SMTP server that handles your emails." +msgstr "" + +#: src/metabase/email.clj +msgid "SMTP username." +msgstr "" + +#: src/metabase/email.clj +msgid "SMTP password." +msgstr "" + +#: src/metabase/email.clj +msgid "The port your SMTP server uses for outgoing emails." +msgstr "" + +#: src/metabase/email.clj +msgid "SMTP secure connection protocol. (tls, ssl, starttls, or none)" +msgstr "" + +#: src/metabase/email.clj +msgid "none" +msgstr "" + +#: src/metabase/email.clj +msgid "SMTP host is not set." +msgstr "" + +#: src/metabase/email.clj +msgid "Failed to send email" +msgstr "" + +#: src/metabase/email.clj +msgid "Error testing SMTP connection" +msgstr "" + +#: src/metabase/integrations/ldap.clj +msgid "Enable LDAP authentication." +msgstr "" + +#: src/metabase/integrations/ldap.clj +msgid "Server hostname." +msgstr "" + +#: src/metabase/integrations/ldap.clj +msgid "Server port, usually 389 or 636 if SSL is used." +msgstr "" + +#: src/metabase/integrations/ldap.clj +msgid "Use SSL, TLS or plain text." +msgstr "" + +#: src/metabase/integrations/ldap.clj +msgid "" +"The Distinguished Name to bind as (if any), this user will be used to lookup " +"information about other users." +msgstr "" + +#: src/metabase/integrations/ldap.clj +msgid "The password to bind with for the lookup user." +msgstr "" + +#: src/metabase/integrations/ldap.clj +msgid "Search base for users. (Will be searched recursively)" +msgstr "" + +#: src/metabase/integrations/ldap.clj +msgid "" +"User lookup filter, the placeholder '{login}' will be replaced by the user " +"supplied login." +msgstr "" + +#: src/metabase/integrations/ldap.clj +msgid "" +"Attribute to use for the user's email. (usually ''mail'', ''email'' or " +"''userPrincipalName'')" +msgstr "" + +#: src/metabase/integrations/ldap.clj +msgid "Attribute to use for the user''s first name. (usually ''givenName'')" +msgstr "" + +#: src/metabase/integrations/ldap.clj +msgid "Attribute to use for the user''s last name. (usually ''sn'')" +msgstr "" + +#: src/metabase/integrations/ldap.clj +msgid "Enable group membership synchronization with LDAP." +msgstr "" + +#: src/metabase/integrations/ldap.clj +msgid "" +"Search base for groups, not required if your LDAP directory provides a " +"''memberOf'' overlay. (Will be searched recursively)" +msgstr "" + +#. Should be in the form: {"cn=Some Group,dc=...": [1, 2, 3]} where keys are LDAP groups and values are lists of MB groups IDs +#: src/metabase/integrations/ldap.clj +msgid "JSON containing LDAP to Metabase group mappings." +msgstr "" + +#: src/metabase/metabot.clj +msgid "" +"Enable MetaBot, which lets you search for and view your saved questions " +"directly via Slack." +msgstr "" + +#: src/metabase/metabot.clj +msgid "Here''s what I can {0}:" +msgstr "" + +#: src/metabase/metabot.clj +msgid "I don''t know how to {0} `{1}`.n{2}" +msgstr "" + +#: src/metabase/metabot.clj +msgid "Uh oh! :cry:n>" +msgstr "" + +#: src/metabase/metabot.clj +msgid "Here''s your {0} most recent cards:n{1}" +msgstr "" + +#: src/metabase/metabot.clj +msgid "" +"Could you be a little more specific? I found these cards with names that " +"matched:n{0}" +msgstr "" + +#: src/metabase/metabot.clj +msgid "I don''t know what Card `{0}` is. Give me a Card ID or name." +msgstr "" + +#: src/metabase/metabot.clj +msgid "" +"Show which card? Give me a part of a card name or its ID and I can show it " +"to you. If you don''t know which card you want, try `metabot list`." +msgstr "" + +#: src/metabase/metabot.clj +msgid "Ok, just a second..." +msgstr "" + +#: src/metabase/metabot.clj +msgid "Not Found" +msgstr "" + +#: src/metabase/metabot.clj +msgid "Loading Kanye quotes..." +msgstr "" + +#: src/metabase/metabot.clj +msgid "Evaluating Metabot command:" +msgstr "" + +#: src/metabase/metabot.clj +msgid "Go home websocket, you're drunk." +msgstr "" + +#: src/metabase/metabot.clj +msgid "Error launching metabot:" +msgstr "" + +#: src/metabase/metabot.clj +msgid "MetaBot WebSocket is closed. Reconnecting now." +msgstr "" + +#: src/metabase/metabot.clj +msgid "Error connecting websocket:" +msgstr "" + +#: src/metabase/metabot.clj +msgid "Stopping MetaBot... 🤖" +msgstr "" + +#: src/metabase/metabot.clj +msgid "MetaBot already running. Killing the previous WebSocket listener first." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "Identify when new versions of Metabase are available." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "Information about available versions of Metabase." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "The name used for this instance of Metabase." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "" +"The base URL of this Metabase instance, e.g. \"http://metabase.my-company.com" +"\"." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "The default language for this Metabase instance." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "" +"This only applies to emails, Pulses, etc. Users'' browsers will specify the " +"language used in the user interface." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "" +"The email address users should be referred to if they encounter a problem." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "" +"Enable the collection of anonymous usage data in order to help Metabase " +"improve." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "" +"The map tile server URL template used in map visualizations, for example " +"from OpenStreetMaps or MapBox." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "" +"Enable admins to create publicly viewable links (and embeddable iframes) for " +"Questions and Dashboards?" +msgstr "" + +#: src/metabase/public_settings.clj +msgid "" +"Allow admins to securely embed questions and dashboards within other " +"applications?" +msgstr "" + +#: src/metabase/public_settings.clj +msgid "Allow using a saved question as the source for other queries?" +msgstr "" + +#: src/metabase/public_settings.clj +msgid "" +"Enabling caching will save the results of queries that take a long time to " +"run." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "The maximum size of the cache, per saved question, in kilobytes:" +msgstr "" + +#: src/metabase/public_settings.clj +msgid "The absoulte maximum time to keep any cached query results, in seconds." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "" +"Metabase will cache all saved questions with an average query execution time " +"longer than this many seconds:" +msgstr "" + +#: src/metabase/public_settings.clj +msgid "" +"To determine how long each saved question''s cached result should stick " +"around, we take the query''s average execution time and multiply that by " +"whatever you input here." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "" +"So if a query takes on average 2 minutes to run, and you input 10 for your " +"multiplier, its cache entry will persist for 20 minutes." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "" +"When using the default binning strategy and a number of bins is not " +"provided, this number will be used as the default." +msgstr "" + +#: src/metabase/public_settings.clj +msgid "" +"When using the default binning strategy for a field of type Coordinate (such " +"as Latitude and Longitude), this number will be used as the default bin " +"width (in degrees)." +msgstr "" + +#: src/metabase/pulse.clj +msgid "Unable to compare results to goal for alert." +msgstr "" + +#: src/metabase/pulse.clj +msgid "Question ID is ''{0}'' with visualization settings ''{1}''" +msgstr "" + +#: src/metabase/pulse.clj +msgid "Unrecognized alert with condition ''{0}''" +msgstr "" + +#: src/metabase/pulse.clj +msgid "Unrecognized channel type {0}" +msgstr "" + +#. returns `true` if successful -- see JavaDoc +#: src/metabase/pulse/render.clj +msgid "No approprate image writer found!" +msgstr "" + +#: src/metabase/pulse/render.clj +msgid "Card has errors: {0}" +msgstr "" + +#: src/metabase/pulse/render.clj +msgid "Pulse card render error" +msgstr "" + +#. This is the very first log message that will get printed. It's here because this is one of the very first +#. namespaces that gets loaded, and the first that has access to the logger It shows up a solid 10-15 seconds before +#. the "Starting Metabase in STANDALONE mode" message because so many other namespaces need to get loaded +#: src/metabase/util.clj +msgid "Loading Metabase..." +msgstr "" diff --git a/package.json b/package.json index 48056c026f4b35b132604f7d72774da620302c43..17b3dbfa908fb9048ff13c57bffbd1a5978ba2b4 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "dependencies": { "ace-builds": "^1.2.2", "babel-polyfill": "^6.6.1", - "c-3po": "^0.5.8", + "c-3po": "^0.7.3", "chevrotain": "0.21.0", "classnames": "^2.1.3", - "color": "^1.0.3", + "color": "^3.0.0", "crossfilter": "^1.3.12", "cxs": "^5.0.0", "d3": "^3.5.17", @@ -29,38 +29,36 @@ "isomorphic-fetch": "^2.2.1", "js-cookie": "^2.1.2", "jsrsasign": "^7.1.0", - "leaflet": "^1.0.1", + "leaflet": "^1.2.0", "leaflet-draw": "^0.4.9", "leaflet.heat": "^0.2.0", - "moment": "2.14.1", + "moment": "2.19.3", "node-libs-browser": "^2.0.0", "normalizr": "^3.0.2", "number-to-locale-string": "^1.0.1", "password-generator": "^2.0.1", "prop-types": "^15.5.7", - "react": "^15.5.4", - "react-addons-css-transition-group": "^15.5.2", - "react-addons-perf": "^15.2.1", + "react": "15", "react-addons-shallow-compare": "^15.2.1", "react-ansi-style": "^1.0.0", - "react-collapse": "^2.3.3", - "react-copy-to-clipboard": "^4.2.3", - "react-dom": "^15.5.4", + "react-collapse": "^4.0.3", + "react-copy-to-clipboard": "^5.0.1", + "react-dom": "15", "react-draggable": "^2.2.3", - "react-element-to-jsx-string": "^6.3.0", - "react-height": "^2.1.1", + "react-element-to-jsx-string": "^13.1.0", "react-hot-loader": "^1.3.0", "react-markdown": "^3.0.0-rc3", "react-motion": "^0.4.5", "react-redux": "^5.0.4", "react-resizable": "^1.0.1", - "react-retina-image": "^2.0.4", + "react-retina-image": "^2.0.5", "react-router": "3", "react-router-redux": "^4.0.8", - "react-sortable": "^1.2.0", - "react-textarea-autosize": "^4.0.5", + "react-sortable": "1.2", + "react-textarea-autosize": "^5.2.1", + "react-transition-group": "1", "react-virtualized": "^9.7.2", - "recompose": "^0.23.1", + "recompose": "^0.26.0", "redux": "^3.5.2", "redux-actions": "^2.0.1", "redux-auth-wrapper": "^1.0.0", @@ -86,7 +84,7 @@ "babel-eslint": "^7.1.1", "babel-loader": "^7.1.2", "babel-plugin-add-react-displayname": "^0.0.4", - "babel-plugin-c-3po": "^0.5.8", + "babel-plugin-c-3po": "0.8.0-0", "babel-plugin-syntax-trailing-function-commas": "^6.22.0", "babel-plugin-transform-builtin-extend": "^1.1.2", "babel-plugin-transform-decorators-legacy": "^1.3.4", @@ -99,7 +97,7 @@ "concurrently": "^3.1.0", "css-loader": "^0.28.7", "documentation": "^4.0.0-rc.1", - "enzyme": "^2.7.0", + "enzyme": "2", "eslint": "^3.5.0", "eslint-import-resolver-webpack": "^0.8.3", "eslint-loader": "^1.9.0", @@ -139,7 +137,8 @@ "postcss-url": "^6.0.4", "prettier": "^1.10.2", "promise-loader": "^1.0.0", - "react-test-renderer": "^15.5.4", + "raf": "^3.4.0", + "react-test-renderer": "15", "sauce-connect-launcher": "^1.1.1", "selenium-webdriver": "^2.53.3", "style-loader": "^0.19.0", @@ -151,31 +150,29 @@ "webpack-postcss-tools": "^1.1.2" }, "scripts": { - "dev": "yarn && concurrently --kill-others -p name -n 'backend,frontend' -c 'blue,green' 'lein ring server' 'yarn run build-hot'", - "lint": "yarn run lint-eslint && yarn run lint-prettier", - "lint-eslint": "eslint --ext .js --ext .jsx --max-warnings 0 frontend/src frontend/test", - "lint-prettier": "prettier -l 'frontend/**/*.{js,jsx,css}' || (echo '\nThese files are not formatted correctly. Did you forget to run \"yarn run prettier\"?' && false)", - "flow": "flow check", - "test": "yarn run test-unit && yarn run test-integrated && yarn run test-karma", - "test-integrated": "babel-node ./frontend/test/__runner__/run_integrated_tests.js", - "test-integrated-watch": "babel-node ./frontend/test/__runner__/run_integrated_tests.js --watch", - "test-unit": "jest --maxWorkers=8 --config jest.unit.conf.json --coverage", - "test-unit-watch": "jest --maxWorkers=10 --config jest.unit.conf.json --watch", - "test-unit-update-snapshot": "jest --maxWorkers=10 --config jest.unit.conf.json --updateSnapshot", - "test-karma": "karma start frontend/test/karma.conf.js --single-run", - "test-karma-watch": "karma start frontend/test/karma.conf.js --auto-watch --reporters nyan", - "test-e2e": "JASMINE_CONFIG_PATH=./frontend/test/e2e/support/jasmine.json jasmine", - "test-e2e-dev": "./frontend/test/e2e-with-persistent-browser.js", - "test-e2e-sauce": "USE_SAUCE=true yarn run test-e2e", - "build": "webpack --bail", - "build-watch": "webpack --watch", - "build-hot": "NODE_ENV=hot webpack-dev-server --progress", - "build-stats": "webpack --json > stats.json", - "start": "yarn run build && lein ring server", - "precommit": "lint-staged", - "preinstall": "echo $npm_execpath | grep -q yarn || echo '\\033[0;33mSorry, npm is not supported. Please use Yarn (https://yarnpkg.com/).\\033[0m'", - "prettier": "prettier --write 'frontend/**/*.{js,jsx,css}'", - "docs": "documentation build -f html -o frontend/docs frontend/src/metabase-lib/lib/**" + "dev": "concurrently --kill-others -p name -n 'backend,frontend' -c 'blue,green' 'lein ring server' 'yarn build-hot'", + "lint": "yarn lint-eslint && yarn lint-prettier", + "lint-eslint": "yarn && eslint --ext .js --ext .jsx --max-warnings 0 frontend/src frontend/test", + "lint-prettier": "yarn && prettier -l 'frontend/**/*.{js,jsx,css}' || (echo '\nThese files are not formatted correctly. Did you forget to \"yarn prettier\"?' && false)", + "flow": "yarn && flow check", + "test": "yarn test-unit && yarn test-integrated && yarn test-karma", + "test-integrated": "./bin/build-for-test && yarn test-integrated-no-build", + "test-integrated-watch": "yarn test-integrated --watch", + "test-integrated-no-build": "yarn && babel-node ./frontend/test/__runner__/run_integrated_tests.js", + "test-unit": "yarn && jest --maxWorkers=8 --config jest.unit.conf.json", + "test-unit-watch": "yarn test-unit --watch", + "test-unit-update-snapshot":"yarn test-unit --updateSnapshot", + "test-karma": "yarn && karma start frontend/test/karma.conf.js --single-run", + "test-karma-watch": "yarn && karma start frontend/test/karma.conf.js --auto-watch --reporters nyan", + "build": "yarn && webpack --bail", + "build-watch": "yarn && webpack --watch", + "build-hot": "yarn && NODE_ENV=hot webpack-dev-server --progress", + "build-stats": "yarn && webpack --json > stats.json", + "start": "yarn build && lein ring server", + "precommit": "lint-staged", + "preinstall": "echo $npm_execpath | grep -q yarn || echo '\\033[0;33mSorry, npm is not supported. Please use Yarn (https://yarnpkg.com/).\\033[0m'", + "prettier": "prettier --write 'frontend/**/*.{js,jsx,css}'", + "docs": "documentation build -f html -o frontend/docs frontend/src/metabase-lib/lib/**" }, "lint-staged": { "frontend/**/*.{js,jsx,css}": [ diff --git a/project.clj b/project.clj index 90e3f44fcf2ab3f4a95f2b56a579bb4014f74285..edcfda7174218098ead35195ed7b933301a722c3 100644 --- a/project.clj +++ b/project.clj @@ -18,6 +18,7 @@ [org.clojure/data.csv "0.1.3"] ; CSV parsing / generation [org.clojure/java.classpath "0.2.3"] ; examine the Java classpath from Clojure programs [org.clojure/java.jdbc "0.7.5"] ; basic JDBC access from Clojure + [org.clojure/math.combinatorics "0.1.4"] ; combinatorics functions [org.clojure/math.numeric-tower "0.0.4"] ; math functions like `ceil` [org.clojure/tools.logging "0.3.1"] ; logging framework [org.clojure/tools.namespace "0.2.10"] @@ -64,7 +65,9 @@ [hiccup "1.0.5"] ; HTML templating [honeysql "0.8.2"] ; Transform Clojure data structures to SQL [io.crate/crate-jdbc "2.1.6"] ; Crate JDBC driver - [instaparse "1.4.0"] ; Insaparse parser generator + [io.forward/yaml "1.0.6" ; Clojure wrapper for YAML library SnakeYAML (which we already use for liquidbase) + :exclusions [org.clojure/clojure + org.yaml/snakeyaml]] [kixi/stats "0.3.10" ; Various statistic measures implemented as transducers :exclusions [org.clojure/test.check ; test.check and AVL trees are used in kixi.stats.random. Remove exlusion if using. org.clojure/data.avl]] @@ -89,6 +92,16 @@ [org.liquibase/liquibase-core "3.5.3"] ; migration management (Java lib) [org.postgresql/postgresql "42.1.4.jre7"] ; Postgres driver [org.slf4j/slf4j-log4j12 "1.7.25"] ; abstraction for logging frameworks -- allows end user to plug in desired logging framework at deployment time + [org.spark-project.hive/hive-jdbc "1.2.1.spark2" ; JDBC Driver for Apache Spark + :exclusions [org.apache.curator/curator-framework + org.apache.curator/curator-recipes + org.apache.thrift/libfb303 + org.apache.zookeeper/zookeeper + org.eclipse.jetty.aggregate/jetty-all + org.spark-project.hive/hive-common + org.spark-project.hive/hive-metastore + org.spark-project.hive/hive-serde + org.spark-project.hive/hive-shims]] [org.tcrawley/dynapath "0.2.5"] ; Dynamically add Jars (e.g. Oracle or Vertica) to classpath [org.xerial/sqlite-jdbc "3.21.0.1"] ; SQLite driver [org.yaml/snakeyaml "1.18"] ; YAML parser (required by liquibase) @@ -150,7 +163,11 @@ org.clojure/tools.namespace]]] :env {:mb-run-mode "dev"} :jvm-opts ["-Dlogfile.path=target/log"] - :aot [metabase.logger]} ; Log appender class needs to be compiled for log4j to use it + ;; Log appender class needs to be compiled for log4j to use it, + ;; classes for fixed Hive driver in must be compiled for tests + :aot [metabase.logger + metabase.driver.FixedHiveConnection + metabase.driver.FixedHiveDriver]} :ci {:jvm-opts ["-Xmx3g"]} :reflection-warnings {:global-vars {*warn-on-reflection* true}} ; run `lein check-reflection-warnings` to check for reflection warnings :expectations {:injections [(require 'metabase.test-setup ; for test setup stuff @@ -163,7 +180,8 @@ "-Dmb.db.in.memory=true" "-Dmb.jetty.join=false" "-Dmb.jetty.port=3010" - "-Dmb.api.key=test-api-key"]} + "-Dmb.api.key=test-api-key" + "-Duser.language=en"]} ;; build the uberjar with `lein uberjar` :uberjar {:aot :all :jvm-opts ["-Dclojure.compiler.elide-meta=[:doc :added :file :line]" ; strip out metadata for faster load / smaller uberjar size diff --git a/resources/automagic_dashboards/field/Country.yaml b/resources/automagic_dashboards/field/Country.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f8689bf7b58c23fad867c5112ea5726df6d0ce5d --- /dev/null +++ b/resources/automagic_dashboards/field/Country.yaml @@ -0,0 +1,76 @@ +title: A look at your [[this]] per country +transient_title: Here's a closer look at your [[this]] per country +description: Which countries are represented the most and least. +applies_to: GenericTable.Country +metrics: +- Sum: [sum, [dimension, GenericNumber]] +- Avg: [avg, [dimension, GenericNumber]] +- Count: [count] +dimensions: + - GenericNumber: GenericTable.Number +# bind just so they don't get used + - LongLat: GenericTable.Coordinate + - ZipCode: GenericTable.ZipCode +# only used for filters + - Country: + field_type: GenericTable.Country + - State: + field_type: GenericTable.State + - GenericCategoryMedium: + field_type: GenericTable.Category + max_cardinality: 12 + score: 90 + - Timestamp: + field_type: CreationTimestamp + score: 100 + - Timestamp: + field_type: DateTime + score: 90 +dashboard_filters: + - Timestamp + - State + - Country + - GenericCategoryMedium +groups: + - Overview: + title: Overview + - Breakdowns: + title: "How different numbers are distributed across [[this]]" +cards: + - Distribution: + title: Distribution of [[this]] + visualization: + map: + map.type: region + map.region: world_countries + metrics: Count + dimensions: this + group: Overview + - Top5: + title: Top 5 [[this]] + visualization: row + metrics: Count + dimensions: this + limit: 5 + order_by: + - Count: descending + group: Overview + - Bottom5: + title: Bottom 5 [[this]] + visualization: row + metrics: Count + dimensions: this + limit: 5 + order_by: + - Count: ascending + group: Overview + - ByNumber: + title: "Sum of [[GenericNumber]] by [[this]]" + visualization: + map: + map.type: region + map.region: world_countries + metrics: + - Sum + dimensions: this + group: Breakdowns diff --git a/resources/automagic_dashboards/field/DateTime.yaml b/resources/automagic_dashboards/field/DateTime.yaml new file mode 100644 index 0000000000000000000000000000000000000000..45d84411d271d56ac4b913c3b471b9a4e5be746b --- /dev/null +++ b/resources/automagic_dashboards/field/DateTime.yaml @@ -0,0 +1,100 @@ +title: A look at your [[this]] over time +transient_title: Here's a closer look at your [[this]] over time +description: How [[this]] is distributed, and how different numbers change across it. +applies_to: GenericTable.DateTime +metrics: +- Sum: [sum, [dimension, GenericNumber]] +- Avg: [avg, [dimension, GenericNumber]] +- Count: [count] +dimensions: + - GenericNumber: GenericTable.Number + - GenericCategoryMedium: + field_type: GenericTable.Category + max_cardinality: 10 + score: 90 +# bind just so they don't get used + - LongLat: GenericTable.Coordinate + - ZipCode: GenericTable.ZipCode +# only used for filters + - Country: + field_type: GenericTable.Country + - State: + field_type: GenericTable.State +dashboard_filters: + - this + - State + - Country + - GenericCategoryMedium +groups: + - Overview: + title: Overview + - Breakdowns: + title: Different numbers across [[this]] + - Seasonality: + title: How [[this]] is distributed +cards: + - Distribution: + title: "[[GenericTable]] by [[this]]" + visualization: line + metrics: Count + dimensions: this + width: 18 + group: Overview + - ByNumber: + title: "[[GenericNumber]] by [[this]]" + visualization: line + metrics: + - Sum + - Avg + dimensions: this + width: 9 + group: Breakdowns + - ByCategory: + title: "Count of [[GenericCategoryMedium]] by [[this]]" + visualization: area + metrics: Count + dimensions: + - this + - GenericCategoryMedium + width: 9 + group: Breakdowns + - DayOfWeek: + title: "[[this]] by day of the week" + visualization: bar + dimensions: + - this: + aggregation: day-of-week + metrics: Count + score: 60 + group: Seasonality + width: 9 + - HourOfDay: + title: "[[this]] by hour of the day" + visualization: bar + dimensions: + - this: + aggregation: hour-of-day + metrics: Count + score: 50 + group: Seasonality + width: 9 + - MonthOfYear: + title: "[[this]] by month of the year" + visualization: bar + dimensions: + - this: + aggregation: month-of-year + metrics: Count + score: 40 + group: Seasonality + width: 9 + - QuarterOfYear: + title: "[[this]] by quarter of the year" + visualization: bar + dimensions: + - this: + aggregation: quarter-of-year + metrics: Count + score: 40 + group: Seasonality + width: 9 diff --git a/resources/automagic_dashboards/field/GenericField.yaml b/resources/automagic_dashboards/field/GenericField.yaml new file mode 100644 index 0000000000000000000000000000000000000000..26b02be077ffe8f6d01d7616b82063c7d03e25f4 --- /dev/null +++ b/resources/automagic_dashboards/field/GenericField.yaml @@ -0,0 +1,54 @@ +title: A look at your [[this]] +transient_title: Here's an overview of your [[this]] +description: How [[this]] is distributed and more. +applies_to: GenericTable.* +metrics: +- Sum: [sum, [dimension, GenericNumber]] +- Avg: [avg, [dimension, GenericNumber]] +- Count: [count] +dimensions: + - GenericNumber: GenericTable.Number +# bind just so they don't get used + - LongLat: GenericTable.Coordinate + - ZipCode: GenericTable.ZipCode +# only used for filters + - Country: + field_type: GenericTable.Country + - State: + field_type: GenericTable.State + - GenericCategoryMedium: + field_type: GenericTable.Category + max_cardinality: 12 + score: 90 + - Timestamp: + field_type: CreationTimestamp + score: 100 + - Timestamp: + field_type: DateTime + score: 90 +dashboard_filters: + - Timestamp + - State + - Country + - GenericCategoryMedium +groups: + - Overview: + title: Overview + - Breakdowns: + title: "How different numbers are distributed across [[this]]" +cards: + - Distribution: + title: How [[this]] is distributed + visualization: bar + metrics: Count + dimensions: this + group: Overview + width: 12 + - ByNumber: + title: "[[GenericNumber]] by [[this]]" + visualization: line + metrics: + - Sum + - Avg + dimensions: this + group: Breakdowns diff --git a/resources/automagic_dashboards/field/State.yaml b/resources/automagic_dashboards/field/State.yaml new file mode 100644 index 0000000000000000000000000000000000000000..bc9f68f7fb9be0b42b6ef99833df69757af07611 --- /dev/null +++ b/resources/automagic_dashboards/field/State.yaml @@ -0,0 +1,76 @@ +title: A look at your [[this]] per state +transient_title: Here's a closer look at your [[this]] per state +description: How [[this]] is distributed, and how different numbers are represented per state. +applies_to: GenericTable.State +metrics: +- Sum: [sum, [dimension, GenericNumber]] +- Avg: [avg, [dimension, GenericNumber]] +- Count: [count] +dimensions: + - GenericNumber: GenericTable.Number +# bind just so they don't get used + - LongLat: GenericTable.Coordinate + - ZipCode: GenericTable.ZipCode +# only used for filters + - Country: + field_type: GenericTable.Country + - State: + field_type: GenericTable.State + - GenericCategoryMedium: + field_type: GenericTable.Category + max_cardinality: 12 + score: 90 + - Timestamp: + field_type: CreationTimestamp + score: 100 + - Timestamp: + field_type: DateTime + score: 90 +dashboard_filters: + - Timestamp + - State + - Country + - GenericCategoryMedium +groups: + - Overview: + title: Overview + - Breakdowns: + title: Different numbers per state +cards: + - Distribution: + title: "[[GenericTable]] per [[this]]" + visualization: + map: + map.type: region + map.region: us_states + metrics: Count + dimensions: this + group: Overview + - Top5: + title: Top 5 [[this]] + visualization: row + metrics: Count + dimensions: this + limit: 5 + order_by: + - Count: descending + group: Overview + - Bottom5: + title: Bottom 5 [[this]] + visualization: row + metrics: Count + dimensions: this + limit: 5 + order_by: + - Count: ascending + group: Overview + - ByNumber: + title: "Sum of [[GenericNumber]] per [[this]]" + visualization: + map: + map.type: region + map.region: us_states + metrics: + - Sum + dimensions: this + group: Breakdowns diff --git a/resources/automagic_dashboards/metric/GenericMetric.yaml b/resources/automagic_dashboards/metric/GenericMetric.yaml new file mode 100644 index 0000000000000000000000000000000000000000..881093dc073037a258997dcda7fb75c21e167b7f --- /dev/null +++ b/resources/automagic_dashboards/metric/GenericMetric.yaml @@ -0,0 +1,170 @@ +title: A look at your [[this]] +transient_title: Here's a quick look at your [[this]] +description: How it's distributed across time and other categories. +applies_to: GenericTable +metrics: +# Placeholder that will get overloaded by reference to the actual metric, so we can +# refer to the metric in card definition. +dimensions: + - Country: + field_type: GenericTable.Country + - State: + field_type: GenericTable.State + - GenericNumber: + field_type: GenericTable.Number + - GenericCategorySmall: + field_type: GenericTable.Category + max_cardinality: 5 + score: 100 + - GenericCategoryMedium: + field_type: GenericTable.Category + max_cardinality: 12 + score: 90 + - GenericCategoryLarge: + field_type: GenericTable.Category + score: 80 + - Timestamp: + field_type: CreationTimestamp + score: 100 + - Timestamp: + field_type: DateTime + score: 90 + - LongLat: + field_type: GenericTable.Coordinate + - ZipCode: + field_type: GenericTable.ZipCode +groups: + - Periodicity: + title: "[[this]] over time" + - Geographical: + title: "[[this]] by location" + - Categories: + title: How [[this]] is distributed + - Numbers: + title: How [[this]] is distributed across different numbers + - LargeCategories: + title: Top and bottom [[this]] +dashboard_filters: + - Timestamp + - State + - Country + - GenericCategorySmall + - GenericCategoryMedium + - GenericCategoryLarge +cards: + - ByTime: + group: Periodicity + title: Over time + visualization: line + metrics: this + dimensions: Timestamp + width: 18 + - DayOfWeek: + group: Periodicity + title: "[[this]] per day of the week" + visualization: bar + metrics: this + dimensions: + - Timestamp: + aggregation: day-of-week + - HourOfDay: + group: Periodicity + title: "[[this]] per hour of the day" + visualization: bar + metrics: this + dimensions: + - Timestamp: + aggregation: hour-of-day + - DayOfMonth: + group: Periodicity + title: "[[this]] per day of the month" + visualization: bar + metrics: this + dimensions: + - Timestamp: + aggregation: day-of-month + - MonthOfYear: + group: Periodicity + title: "[[this]] per month of the year" + visualization: bar + metrics: this + dimensions: + - Timestamp: + aggregation: month-of-year + - QuerterOfYear: + group: Periodicity + title: "[[this]] per quarter of the year" + visualization: bar + metrics: this + dimensions: + - Timestamp: + aggregation: quarter-of-year + - ByCountry: + group: Geographical + title: "[[this]] per country" + metrics: this + dimensions: Country + visualization: + map: + map.type: region + map.region: world_countries + - ByState: + group: Geographical + title: "[[this]] per state" + metrics: this + dimensions: State + visualization: + map: + map.type: region + map.region: us_states + - ByNumber: + group: Numbers + title: How [[this]] is distributed across [[GenericNumber]] + metrics: this + dimensions: GenericNumber + visualization: bar + - ByCategoryMedium: + group: Categories + title: "[[this]] per [[GenericCategoryMedium]]" + metrics: this + dimensions: GenericCategoryMedium + visualization: row + order_by: + this: descending + height: 8 + - ByCategoryAndTime: + group: Categories + title: "[[this]] per [[GenericCategorySmall]] over time" + metrics: this + dimensions: + - GenericCategorySmall + - Timestamp + visualization: + area: + stackable.stack_type: stacked + - ByCategorySmall: + group: Categories + title: "[[this]] per [[GenericCategorySmall]]" + metrics: this + dimensions: GenericCategorySmall + visualization: row + order_by: + this: descending + - ByCategoryLargeTop: + group: LargeCategories + title: "[[this]] per [[GenericCategoryLarge]], top 5" + metrics: this + dimensions: GenericCategoryLarge + visualization: row + order_by: + this: descending + limit: 5 + - ByCategoryLargeBottom: + group: LargeCategories + title: "[[this]] per [[GenericCategoryLarge]], bottom 5" + metrics: this + dimensions: GenericCategoryLarge + visualization: row + limit: 5 + order_by: + this: ascending diff --git a/resources/automagic_dashboards/table/EventTable.yaml b/resources/automagic_dashboards/table/EventTable.yaml new file mode 100644 index 0000000000000000000000000000000000000000..915e780355bc1fa2637b770ed39ab72f9faa79c9 --- /dev/null +++ b/resources/automagic_dashboards/table/EventTable.yaml @@ -0,0 +1,181 @@ +title: A look at your [[this]] table +transient_title: "A summary of the events in your [[this]] table" +description: A look at your events over time and by several categories. +metrics: + - Count: ["count"] +dimensions: + - Timestamp: + field_type: CreationTimestamp + score: 100 + - Timestamp: + field_type: DateTime + score: 90 + - GenericCategoryMedium: + field_type: GenericTable.Category + score: 75 + max_cardinality: 10 + - GenericCategoryLarge: + field_type: GenericTable.Category + score: 70 + - State: GenericTable.State + - Country: GenericTable.Country + - Long: GenericTable.Longitude + - Lat: GenericTable.Latitude + - GenericNumber: + field_type: GenericTable.Number + score: 80 +filters: + - Last30Days: + filter: ["time-interval", [dimension, Timestamp], -30, day] +groups: + - Overview: + title: Overview + - Periodicity: + title: Events over time + - Geographical: + title: Where these events are happening + - General: + title: Events by different categories +dashboard_filters: +- Timestamp +- GenericCategoryMedium +- Country +- State +cards: +# Overview + - Rowcount: + title: Total events + visualization: scalar + metrics: Count + score: 30 + group: Overview + width: 5 + height: 3 + - RowcountLast30Days: + title: Events in the last 30 days + visualization: scalar + metrics: Count + score: 25 + filters: Last30Days + group: Overview + width: 5 + height: 3 + - EventsByTime: + title: Events over time + metrics: Count + dimensions: Timestamp + visualization: line + group: Overview + width: 18 + score: 90 +# General + - NumberDistribution: + title: Events by [[GenericNumber]] + dimensions: + - GenericNumber: + aggregation: default + metrics: Count + visualization: bar + score: 90 + group: General + - CountByCategoryMedium: + title: Events per [[GenericCategoryMedium]] + dimensions: GenericCategoryMedium + metrics: Count + visualization: row + score: 80 + height: 8 + group: General + order_by: + - Count: descending + - CountByCategoryLarge: + title: Events per [[GenericCategoryLarge]] + dimensions: GenericCategoryLarge + metrics: Count + visualization: table + height: 8 + score: 70 + group: General + order_by: + - Count: descending +# Geographical + - EventsByCountry: + title: Events per country + metrics: Count + dimensions: Country + score: 80 + visualization: + map: + map.type: region + map.region: world_countries + group: Geographical + - EventsByState: + title: Events per state + metrics: Count + dimensions: State + score: 70 + visualization: + map: + map.type: region + map.region: us_state + group: Geographical + - EventsByCoords: + title: Events by coordinates + metrics: Count + dimensions: + - Lat + - Long + score: 80 + visualization: map + group: Geographical +# Periodicity + - DayOfWeekTimestamp: + title: Events per day of the week + visualization: bar + dimensions: + - Timestamp: + aggregation: day-of-week + metrics: Count + score: 60 + group: Periodicity + x_label: "[[Timestamp]] by day of the week" + - HourOfDayTimestamp: + title: Events per hour of the day + visualization: bar + dimensions: + - Timestamp: + aggregation: hour-of-day + metrics: Count + score: 50 + group: Periodicity + x_label: "[[Timestamp]] by hour of the day" + - DayOfMonthTimestamp: + title: Events per day of the month + visualization: bar + dimensions: + - Timestamp: + aggregation: day-of-month + metrics: Count + score: 40 + group: Periodicity + x_label: "[[Timestamp]] by day of the month" + - MonthOfYearTimestamp: + title: Events per month of the year + visualization: bar + dimensions: + - Timestamp: + aggregation: month-of-year + metrics: Count + score: 40 + group: Periodicity + x_label: "[[Timestamp]] by month of the year" + - QuerterOfYearTimestamp: + title: Events per quarter of the year + visualization: bar + dimensions: + - Timestamp: + aggregation: quarter-of-year + metrics: Count + score: 40 + group: Periodicity + x_label: "[[Timestamp]] by quarter of the year" diff --git a/resources/automagic_dashboards/table/GenericTable.yaml b/resources/automagic_dashboards/table/GenericTable.yaml new file mode 100644 index 0000000000000000000000000000000000000000..77c905f8e83b2537db6416a28d5f86a1a48027db --- /dev/null +++ b/resources/automagic_dashboards/table/GenericTable.yaml @@ -0,0 +1,364 @@ +title: "A look at your [[this]]" +transient_title: "Here's a quick look at your [[this]]" +description: An overview of your [[this]] and how it's distributed across time, place, and categories. +metrics: + - Count: ["count"] + - CountDistinctFKs: [distinct, [dimension, FK]] + - Sum: [sum, [dimension, GenericNumber]] + - Avg: + metric: [avg, [dimension, GenericNumber]] +dimensions: + - Country: + field_type: GenericTable.Country + score: 100 + - State: + field_type: GenericTable.State + score: 100 + - GenericNumber: + field_type: GenericTable.Number + score: 80 + - Source: + field_type: GenericTable.Source + score: 100 + - GenericCategorySmall: + field_type: GenericTable.Category + score: 80 + max_cardinality: 5 + - GenericCategoryMedium: + field_type: GenericTable.Category + score: 75 + max_cardinality: 10 + - GenericCategoryLarge: + field_type: GenericTable.Category + score: 70 + - Singleton: + field_type: GenericTable.Category + max_cardinality: 1 + score: 100 + - Timestamp: + field_type: DateTime + score: 60 + - JoinDate: + field_type: GenericTable.JoinTimestamp + score: 50 + - CreateDate: + field_type: CreationTimestamp + score: 80 + - FK: FK + - Long: GenericTable.Longitude + - Lat: GenericTable.Latitude +# ignore + - Birthdate: Birthdate + - ZIP: ZipCode +filters: + - Last30Days: + filter: ["time-interval", [dimension, CreateDate], -30, day] + score: 100 + - Last30Days: + filter: ["time-interval", [dimension, JoinDate], -30, day] + score: 90 + - Last30Days: + filter: ["time-interval", [dimension, Timestamp], -30, day] + score: 80 +groups: +- Overview: + title: Summary +- Singletons: + title: These are the same for all your [[this]] +- ByTime: + title: "[[this]] across time" +- Geographical: + title: Where your [[this]] are +- General: + title: How [[this]] are distributed +dashboard_filters: +- Timestamp +- JoinDate +- CreateDate +- GenericCategoryMedium +- Source +- Country +- State +cards: +# Overview + - Rowcount: + title: Total [[this]] + visualization: scalar + metrics: Count + score: 100 + group: Overview + - RowcountLast30Days: + title: New [[this]] in the last 30 days + visualization: scalar + metrics: Count + score: 100 + filters: Last30Days + group: Overview + - DistinctFKCounts: + title: Distinct [[FK]] + visualization: scalar + metrics: CountDistinctFKs + score: 100 + group: Overview +# General + - NumberDistribution: + title: How [[this]] are distributed across [[GenericNumber]] + dimensions: + - GenericNumber: + aggregation: default + metrics: Count + visualization: bar + score: 90 + group: General + - CountByCategoryMedium: + title: "[[this]] per [[GenericCategoryMedium]]" + dimensions: GenericCategoryMedium + metrics: Count + visualization: row + score: 80 + height: 8 + group: General + order_by: + - Count: descending + - CountByCategoryLarge: + title: "[[this]] per [[GenericCategoryLarge]]" + dimensions: GenericCategoryLarge + metrics: Count + visualization: table + height: 8 + score: 70 + group: General + order_by: + - Count: descending +# Geographical + - CountByCountry: + title: "[[this]] per country" + metrics: Count + dimensions: Country + score: 90 + visualization: + map: + map.type: region + map.region: world_countries + group: Geographical + height: 6 + - CountByState: + title: "[[this]] per state" + metrics: Count + dimensions: State + score: 90 + visualization: + map: + map.type: region + map.region: us_states + group: Geographical + height: 6 + - CountByCoords: + title: "[[this]] by coordinates" + metrics: Count + dimensions: + - Long + - Lat + visualization: map + score: 80 + group: Geographical + height: 6 +# By Time + - CountByJoinDate: + title: "[[this]] that have joined over time" + visualization: line + dimensions: JoinDate + metrics: Count + score: 90 + group: ByTime + - CountByCreateDate: + title: New [[this]] over time + visualization: line + dimensions: CreateDate + metrics: Count + score: 90 + group: ByTime + - CountByTimestamp: + title: "[[this]] by [[Timestamp]]" + visualization: line + dimensions: Timestamp + metrics: Count + score: 20 + group: ByTime + - NumberOverTime: + title: "[[GenericNumber]] over time" + visualization: line + dimensions: Timestamp + metrics: + - Sum + - Avg + score: 70 + group: ByTime + - NumberOverJoinDate: + title: "[[GenericNumber]] by join date" + visualization: line + dimensions: JoinDate + metrics: + - Sum + - Avg + score: 80 + group: ByTime + - NumberOverCreateDate: + title: "[[GenericNumber]] over time" + visualization: line + dimensions: CreateDate + metrics: + - Sum + - Avg + score: 90 + group: ByTime + - DayOfWeekTimestamp: + title: "[[Timestamp]] by day of the week" + visualization: bar + dimensions: + - Timestamp: + aggregation: day-of-week + metrics: Count + score: 60 + group: ByTime + x_label: "[[Timestamp]]" + - HourOfDayTimestamp: + title: "[[Timestamp]] by hour of the day" + visualization: bar + dimensions: + - Timestamp: + aggregation: hour-of-day + metrics: Count + score: 50 + group: ByTime + x_label: "[[Timestamp]]" + - MonthOfYearTimestamp: + title: "[[Timestamp]] by month of the year" + visualization: bar + dimensions: + - Timestamp: + aggregation: month-of-year + metrics: Count + score: 40 + group: ByTime + x_label: "[[Timestamp]]" + - QuarterOfYearTimestamp: + title: "[[Timestamp]] by quarter of the year" + visualization: bar + dimensions: + - Timestamp: + aggregation: quarter-of-year + metrics: Count + score: 40 + group: ByTime + x_label: "[[Timestamp]]" + - DayOfWeekCreateDate: + title: Weekdays when new [[this]] were added + visualization: bar + dimensions: + - CreateDate: + aggregation: day-of-week + metrics: Count + score: 60 + group: ByTime + x_label: Created At by day of the week + - HourOfDayCreateDate: + title: Hours when new [[this]] were added + visualization: bar + dimensions: + - CreateDate: + aggregation: hour-of-day + metrics: Count + score: 50 + group: ByTime + x_label: Created At by hour of the day + - DayOfMonthCreateDate: + title: Days when new [[this]] were added + visualization: bar + dimensions: + - CreateDate: + aggregation: day-of-month + metrics: Count + score: 40 + group: ByTime + x_label: Created At by day of the month + - MonthOfYearCreateDate: + title: Months when new [[this]] were added + visualization: bar + dimensions: + - CreateDate: + aggregation: month-of-year + metrics: Count + score: 40 + group: ByTime + x_label: Created At by month of the year + - QuerterOfYearCreateDate: + title: Quarters when new [[this]] were added + visualization: bar + dimensions: + - CreateDate: + aggregation: quarter-of-year + metrics: Count + score: 40 + group: ByTime + x_label: Created At by quarter of the year + - DayOfWeekJoinDate: + title: Weekdays when [[this]] joined + visualization: bar + dimensions: + - JoinDate: + aggregation: day-of-week + metrics: Count + score: 60 + group: ByTime + x_label: Join date by day of the week + - HourOfDayJoinDate: + title: Hours when [[this]] joined + visualization: bar + dimensions: + - JoinDate: + aggregation: hour-of-day + metrics: Count + score: 50 + group: ByTime + x_label: Join date by hour of the day + - DayOfMonthJoinDate: + title: Days of the month when [[this]] joined + visualization: bar + dimensions: + - JoinDate: + aggregation: day-of-month + metrics: Count + score: 40 + group: ByTime + x_label: Join date by day of the month + - MonthOfYearJoinDate: + title: Months when [[this]] joined + visualization: bar + dimensions: + - JoinDate: + aggregation: month-of-year + metrics: Count + score: 40 + group: ByTime + x_label: Join date by month of the year + - QuerterOfYearJoinDate: + title: Quarters when [[this]] joined + visualization: bar + dimensions: + - JoinDate: + aggregation: quarter-of-year + metrics: Count + score: 40 + group: ByTime + x_label: Join date by quarter of the year +# Special + - Singleton: + title: "[[Singleton]]" + visualization: scalar + metrics: Count + dimensions: Singleton + score: 30 + height: 3 + width: 3 + group: Singletons diff --git a/resources/automagic_dashboards/table/GenericTable/Correlations.yaml b/resources/automagic_dashboards/table/GenericTable/Correlations.yaml new file mode 100644 index 0000000000000000000000000000000000000000..7cf9f42f41012ff6041ac39180ea4b427512f3fe --- /dev/null +++ b/resources/automagic_dashboards/table/GenericTable/Correlations.yaml @@ -0,0 +1,19 @@ +title: "[[this]] comparisons and correlations" +transient_title: "How some of the numbers in [[this]] relate to each other" +description: If you're into correlations, this is the x-ray for you. +applies_to: GenericTable + +dimensions: + - Number1: + field_type: Number + - Number2: + field_type: Number +cards: +- Correlation: + title: How [[Number1]] is correlated with [[Number2]] + visualization: scatter + dimensions: + - Number1: + aggregation: default + - Number2: + aggregation: default diff --git a/resources/automagic_dashboards/table/GoogleAnalyticsTable.yaml b/resources/automagic_dashboards/table/GoogleAnalyticsTable.yaml new file mode 100644 index 0000000000000000000000000000000000000000..07830be9a19bb1c2371e3978db8884d3b41d7c84 --- /dev/null +++ b/resources/automagic_dashboards/table/GoogleAnalyticsTable.yaml @@ -0,0 +1,134 @@ +title: Overview of your [[this]] data from Google Analytics +transient_title: Here's an overview of your [[this]] data from Google Analytics +description: Some interesting metrics about your GA stats to get you started. +metrics: + - Sessions: + metric: [METRIC, "ga:sessions"] + - Pageviews: + metric: [METRIC, "ga:pageviews"] + - 1DayActiveUsers: + metric: [METRIC, "ga:1dayUsers"] +# This metric would be used by the cohort table if we could create it. +# - Retention: +# metric: ["ga:cohortRetentionRate"] +# score: 100 +filters: + - Last30Days: + filter: ["time-interval", [dimension, Day], -30, day] +# It doesn't appear that ga-metadata.js includes user type as a dimension, so I've included +# new and returning users as filters. In the metadata file, they're shown as segments. +# - NewUsers +# [gaid::-2] +# - ReturningUsers +# [gaid::-3] +dimensions: + - Day: + field_type: ga:date + - Country: + field_type: ga:countryIsoCode + - DeviceType: + field_type: ga:deviceCategory + - LandingPage: + field_type: ga:landingPagePath + - Page: + field_type: ga:pagePath + - Channel: + field_type: ga:channelGrouping + - Source: + field_type: ga:source +dashboard_filters: + - Day + # - Country + # - DeviceType + # - Channel +groups: +- Overview: + title: Summary +- SessionsBreakdown: + title: Sessions +cards: + - SessionsOverTime: + title: Sessions and unique users per day + description: How many total sessions vs. how many individual users you had each day. + metrics: + - Sessions + - 1DayActiveUsers + dimensions: Day + visualization: line + score: 100 + width: 18 + height: 7 + group: Overview + # I don't know if I can do this, but I'd like to have a multi-metric card here + # made up of two scalars, new user sessions and returning user sessions, so that + # it's displayed as a bar chart. +# - SessionsByNewVsReturningUser: +# title: Sessions, new vs. returning users +# description: Percent of total sessions that were from new vs. returning users +# metrics: Sessions +# dimensions: +# filters: NewUsers +# visualization: +# score: 80 + - SessionsByCountry: + title: Sessions by Country + description: Total sessions in each country + metrics: Sessions + dimensions: Country + visualization: + map: + map.type: region + map.region: world_countries + score: 90 + group: SessionsBreakdown + - SessionsByTopChannels: + title: Top acquisition channels + description: Where most of your sessions originate from + metrics: Sessions + dimensions: Channel + visualization: bar + group: SessionsBreakdown + score: 80 + - SessionsByDeviceType: + title: Sessions by device type + description: Total sessions by desktop, mobile, or tablet + metrics: Sessions + dimensions: DeviceType + visualization: bar + score: 80 + group: SessionsBreakdown + - PageviewsByPage: + title: Most-viewed pages + description: The pages with the most pageviews + metrics: Pageviews + dimensions: Page + visualization: table + height: 8 + score: 70 + - SessionsByLandingPage: + title: Top landing pages + description: Sessions by page where the session began + metrics: Sessions + dimensions: LandingPage + visualization: table + height: 8 + score: 70 +# MBQL does not support ordering by GA metric +# order_by: +# - Sessions: descending + - SessionsBySource: + title: Top referral pages + description: The top external pages that brought users to your site + metrics: Sessions + dimensions: Source + visualization: table + height: 8 + score: 70 +# - CohortRetention: +# title: +# description: +# metric: Retention +# dimensions: Day, # The other dimension I want is called Acquisition Date in GA + # but I can't find this in ga-metadata.js +# visualization: table +# score: 100 diff --git a/resources/automagic_dashboards/table/TransactionTable.yaml b/resources/automagic_dashboards/table/TransactionTable.yaml new file mode 100644 index 0000000000000000000000000000000000000000..52560480689a3f670604a30a08ed09e7b0d7707c --- /dev/null +++ b/resources/automagic_dashboards/table/TransactionTable.yaml @@ -0,0 +1,258 @@ +title: A look at your [[this]] +transient_title: "It looks like your [[this]] has transactions, so here's a look at them" +description: Some metrics we found about your transactions. +metrics: +- AvgDiscount: + metric: [/, [sum, [dimension, Discount]], [sum, [dimension, Income]]] + name: Average discount % +- TotalIncome: + metric: [sum, [dimension, Income]] + name: Total income +- AvgIncome: + metric: [avg, [dimension, Income]] + name: Average income per transaction +- AvgQuantity: + metric: [avg, [dimension, Quantity]] + name: Average quantity +- TotalOrders: + metric: [count] + name: Number of orders +dimensions: +- Timestamp: + field_type: CreationTimestamp +- Timestamp: + field_type: DateTime +- State: + field_type: GenericTable.State +- Country: GenericTable.Country +- Income: Income +- Discount: Discount +- Quantity: + field_type: Quantity +- SourceSmall: + field_type: GenericTable.Source + max_cardinality: 5 +- SourceMedium: + field_type: GenericTable.Source + max_cardinality: 10 + score: 90 +- SourceLarge: + field_type: GenericTable.Source +- Cohort: + field_type: UserTable.JoinTimestamp + score: 100 +- Cohort: + field_type: UserTable.CreationTimestamp + score: 90 +- Cohort: + field_type: UserTable.DateTime + score: 80 +- ProductMedium: + field_type: Product + score: 90 + max_cardinality: 10 +- ProductMedium: + field_type: ProductTable.Name + max_cardinality: 10 +- ProductMedium: + field_type: ProductTable.Title + max_cardinality: 10 +- ProductLarge: + field_type: Product + score: 90 +- ProductLarge: + field_type: ProductTable.Name +- ProductLarge: + field_type: ProductTable.Title +- ProductCategoryMedium: + field_type: ProductTable.Category + named: category + max_cardinality: 10 + score: 90 +- ProductCategoryLarge: + field_type: ProductTable.Category + named: category +- Long: GenericTable.Longitude +- Lat: GenericTable.Latitude +filters: + - Last30Days: + filter: ["time-interval", [dimension, Timestamp], -30, day] + score: 80 +groups: + - Overview: + title: Overview + - Geographical: + title: Where these transactions happened + - General: + title: How these transactions are distributed +dashboard_filters: + - Timestamp + - SourceSmall + - SourceMedium + - Country + - State + - ProductMedium + - ProductCategoryMedium +cards: +# Overview + - Rowcount: + title: Total transactions + visualization: scalar + metrics: TotalOrders + score: 100 + group: Overview + width: 5 + height: 3 + - RowcountLast30Days: + title: Transactions in the last 30 days + visualization: scalar + metrics: TotalOrders + score: 100 + filters: Last30Days + group: Overview + width: 5 + height: 3 + - IncomeByMonth: + visualization: line + title: Sales per month + description: Total income per month + dimensions: Timestamp + metrics: + - TotalIncome + - TotalOrders + width: 18 + height: 6 + group: Overview +# General + - AverageQuantityByMonth: + visualization: line + title: Average quantity per month + description: Average item quantity per month + dimensions: Timestamp + metrics: AvgQuantity + group: General + - AverageIncomeByMonth: + visualization: line + title: Average transaction income per month + dimensions: Timestamp + metrics: AvgIncome + group: General + - AverageDiscountByMonth: + visualization: line + title: Average discount per month + dimensions: Timestamp + metrics: AvgDiscount + score: 70 + group: General + - OrdersByProduct: + title: Sales per product + visualization: row + dimensions: + - ProductMedium + metrics: TotalOrders + order_by: + - TotalOrders: descending + score: 90 + height: 8 + group: General + - OrdersByProduct: + title: Sales per product + visualization: table + dimensions: + - ProductLarge + metrics: TotalOrders + order_by: + - TotalOrders: descending + score: 90 + height: 8 + group: General + - OrdersByProductCategory: + title: Sales for each product category + visualization: row + dimensions: + - ProductCategoryMedium + metrics: TotalOrders + order_by: + - TotalOrders: descending + score: 90 + height: 8 + group: General + - OrdersByProductCategory: + title: Sales for each product category + visualization: table + dimensions: + - ProductCategoryLarge + metrics: TotalOrders + order_by: + - TotalOrders: descending + score: 90 + height: 8 + group: General + - OrdersBySource: + title: Sales per source + visualization: + area: + stackable.stack_type: stacked + dimensions: + - Timestamp + - SourceSmall + metrics: TotalOrders + width: 12 + height: 8 + score: 100 + group: General + - OrdersBySource: + title: Sales per source + visualization: row + dimensions: SourceMedium + metrics: TotalOrders + order_by: + - TotalOrders: descending + score: 90 + height: 8 + group: General + - OrdersBySource: + title: Sales per source + visualization: table + dimensions: SourceLarge + metrics: TotalOrders + order_by: + - TotalOrders: descending + score: 90 + group: General +# Geographical + - CountByCountry: + title: Sales per country + metrics: TotalOrders + dimensions: Country + score: 90 + visualization: + map: + map.type: region + map.region: world_countries + group: Geographical + - CountByState: + title: Sales per state + metrics: TotalOrders + dimensions: State + score: 90 + height: 10 + width: 7 + visualization: + map: + map.type: region + map.region: us_states + group: Geographical + - CountByCoords: + title: Sales by coordinates + metrics: TotalOrders + dimensions: + - Long: + aggregation: default + - Lat: + aggregation: default + visualization: map + score: 80 + height: 10 + width: 11 + group: Geographical diff --git a/resources/automagic_dashboards/table/TransactionTable/ByCountry.yaml b/resources/automagic_dashboards/table/TransactionTable/ByCountry.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d88b1eaccbb32e5a06802a1311e044463ad5fec8 --- /dev/null +++ b/resources/automagic_dashboards/table/TransactionTable/ByCountry.yaml @@ -0,0 +1,162 @@ +title: "[[this]] per country" +transient_title: Here's a closer look at your [[this]] per country +description: A deeper look at how different countries are performing for you. +applies_to: TransactionTable +metrics: +- TotalIncome: + metric: [sum, [dimension, Income]] +- AvgIncome: [avg, [dimension, Income]] +- AvgQuantity: + metric: [avg, [dimension, Quantity]] +- TotalOrders: [count] +- TotalUsers: [distinct, [dimension, User]] +dimensions: +- Timestamp: + field_type: CreationTimestamp +- Timestamp: + field_type: DateTime +- Country: + field_type: UserTable.Country +- Income: Income +- Quantity: + field_type: Quantity +- User: + field_type: User + score: 100 +- User: + field_type: FK + links_to: UserTable + score: 100 +- Cohort: + field_type: UserTable.JoinTimestamp + score: 100 +- Cohort: + field_type: UserTable.CreationTimestamp + score: 90 +- Cohort: + field_type: UserTable.DateTime + score: 80 +- Product: + field_type: Product + score: 90 + max_cardinality: 10 +- Product: + field_type: ProductTable.Name + max_cardinality: 10 +- Product: + field_type: ProductTable.Title + max_cardinality: 10 +- ProductCategory: + field_type: ProductTable.Category + named: category + max_cardinality: 10 + score: 90 +- Source: + field_type: GenericTable.Source + max_cardinality: 10 + score: 90 +- State: GenericTable.State +filters: + - Last30Days: + filter: ["time-interval", [dimension, Timestamp], -30, day] + - NewUsers: + filter: ["time-interval", [dimension, Cohort], -30, day] +groups: +- Top10: + title: Top performers +- Transactions: + title: Transactions per country +- Users: + title: User acquisition by country +dashboard_filters: + - Timestamp + - Source + - Country + - State + - Product + - ProductCategory +cards: +- TopCountrysBySales: + title: Top 10 countries by sales + visualization: row + dimensions: Country + metrics: TotalOrders + order_by: + - TotalOrders: descending + limit: 10 + score: 100 + height: 8 + group: Top10 +- TopCountrysBySalesLast30Days: + title: Top 10 countries by sales in the last 30 days + visualization: row + dimensions: Country + metrics: TotalOrders + order_by: + - TotalOrders: descending + filters: Last30Days + limit: 10 + score: 100 + height: 8 + group: Top10 +- OrdersByCountry: + visualization: + map: + map.type: region + map.region: world_countries + title: Sales per country + dimensions: Country + metrics: TotalOrders + score: 90 + group: Transactions +- QuantityByCountry: + visualization: + map: + map.type: region + map.region: world_countries + title: Average quantity per country + dimensions: Country + metrics: AvgQuantity + score: 90 + group: Transactions +- IncomeByCountry: + visualization: + map: + map.type: region + map.region: world_countries + title: Income per country + dimensions: Country + metrics: TotalIncome + score: 90 + group: Transactions +- AverageIncomeByCountry: + visualization: + map: + map.type: region + map.region: world_countries + title: Average income per country + dimensions: Country + metrics: AvgIncome + score: 90 + group: Transactions +- UsersByCountry: + visualization: + map: + map.type: region + map.region: world_countries + title: Users in each country + dimensions: Country + metrics: TotalUsers + score: 90 + group: Users +- NewUsersByCountry: + visualization: + map: + map.type: region + map.region: world_countries + title: New users per country in the last 30 days + dimensions: Country + metrics: TotalUsers + filters: NewUsers + score: 90 + group: Users diff --git a/resources/automagic_dashboards/table/TransactionTable/ByProduct.yaml b/resources/automagic_dashboards/table/TransactionTable/ByProduct.yaml new file mode 100644 index 0000000000000000000000000000000000000000..771e6b06cb2ba6fa14f74da9d0096dcf1ff16701 --- /dev/null +++ b/resources/automagic_dashboards/table/TransactionTable/ByProduct.yaml @@ -0,0 +1,57 @@ +title: "[[this]] per product" +transient_title: Here's a closer look at your [[this]] by products +description: How your different products are performing. +applies_to: TransactionTable +metrics: +- TotalOrders: [count] +dimensions: +- ProductCategoryMedium: + field_type: ProductTable.Category + max_cardinality: 10 +- ProductCategoryLarge: + field_type: ProductTable.Category +- Rating: ProductTable.Score +- Country: GenericTable.Country +- State: GenericTable.State +- Timestamp: + field_type: CreationTimestamp +- Timestamp: + field_type: DateTime +- Source: + field_type: UserTable.Source + max_cardinality: 10 +dashboard_filters: +- Source +- Timestamp +- State +- Country +- ProductCategoryMedium +cards: +- OrdersVsRating: + title: Sales vs. rating + visualization: + scatter: + graph.metrics: TotalOrders + graph.dimensions: Rating + dimensions: Rating + metrics: TotalOrders + width: 12 + height: 8 +- OrdersByProductCategoryMedium: + title: Sales per product [[ProductCategoryMedium]] + visualization: row + dimensions: + - ProductCategoryMedium + metrics: TotalOrders + order_by: + - TotalOrders: descending + height: 8 +- OrdersByProductCategoryLarge: + title: Sales per product [[ProductCategoryLarge]] + visualization: table + dimensions: + - ProductCategoryLarge + metrics: TotalOrders + order_by: + - TotalOrders: descending + height: 8 diff --git a/resources/automagic_dashboards/table/TransactionTable/BySource.yaml b/resources/automagic_dashboards/table/TransactionTable/BySource.yaml new file mode 100644 index 0000000000000000000000000000000000000000..982a60080bdf278fb87373107c995b35147fab97 --- /dev/null +++ b/resources/automagic_dashboards/table/TransactionTable/BySource.yaml @@ -0,0 +1,204 @@ +title: "[[this]] per source" +transient_title: Here's a closer look at your [[this]] per source +description: Where most of your traffic is coming from. +applies_to: TransactionTable +metrics: +- TotalIncome: + metric: [sum, [dimension, Income]] +- AvgIncome: [avg, [dimension, Income]] +- AvgQuantity: + metric: [avg, [dimension, Quantity]] +- TotalOrders: [count] +- TotalUsers: [distinct, [dimension, User]] +dimensions: +- Timestamp: + field_type: CreationTimestamp +- Timestamp: + field_type: DateTime +- SourceMedium: + field_type: UserTable.Source + max_cardinality: 10 +- SourceLarge: + field_type: UserTable.Source +- Income: Income +- Quantity: + field_type: Quantity +- User: + field_type: User + score: 100 +- User: + field_type: FK + links_to: UserTable + score: 100 +- Cohort: + field_type: UserTable.JoinTimestamp + score: 100 +- Cohort: + field_type: UserTable.CreationTimestamp + score: 90 +- Cohort: + field_type: UserTable.DateTime + score: 80 +- Product: + field_type: Product + score: 90 + max_cardinality: 10 +- Product: + field_type: ProductTable.Name + max_cardinality: 10 +- Product: + field_type: ProductTable.Title + max_cardinality: 10 +- ProductCategory: + field_type: ProductTable.Category + named: category + max_cardinality: 10 + score: 90 +- Country: GenericTable.Country +- State: GenericTable.State +filters: + - Last30Days: + filter: ["time-interval", [dimension, Timestamp], -30, day] + - NewUsers: + filter: ["time-interval", [dimension, Cohort], -30, day] +groups: +- Overview: + title: Transactions per source over time +- Financial: + title: Orders and income per source +- UserAcquisition: + title: Where users are coming from +dashboard_filters: +- SourceMedium +- SourceLarge +- Timestamp +- Country +- State +- Product +- ProductCategory +cards: +- OrdersBySourceTimeseries: + group: Overview + title: Transactions per source + visualization: + area: + stackable.stack_type: stacked + dimensions: + - Timestamp + - SourceMedium + metrics: TotalOrders + width: 18 + score: 100 + height: 8 +- OrderBySource: + group: Financial + title: Total orders per source + visualization: row + dimensions: SourceLarge + metrics: TotalOrders + order_by: + - TotalOrders: descending + score: 80 + height: 8 +- IncomeBySource: + group: Financial + title: Total income per source + visualization: row + dimensions: SourceMedium + metrics: TotalIncome + order_by: + - TotalIncome: descending + score: 80 + height: 8 +- AvgQuantityBySource: + group: Financial + title: Average quantity per source + visualization: row + dimensions: SourceMedium + metrics: AvgQuantity + order_by: + - AvgQuantity: descending + score: 80 + height: 8 +- AvgIncomeBySource: + group: Financial + title: Average income per source + visualization: row + dimensions: SourceMedium + metrics: AvgIncome + order_by: + - AvgIncome: descending + score: 80 + height: 8 +- UsersBySource: + group: UserAcquisition + visualization: row + title: Number of users per source + dimensions: SourceMedium + metrics: TotalUsers + order_by: + - TotalUsers: descending + score: 90 + height: 8 +- NewUsersBySource: + group: UserAcquisition + visualization: row + title: New users per source in the last 30 days + dimensions: SourceMedium + metrics: TotalUsers + filters: NewUsers + score: 90 + height: 8 + order_by: + - TotalUsers: descending +- IncomeBySource: + group: Financial + title: Total income per source + visualization: table + dimensions: SourceLarge + metrics: TotalIncome + order_by: + - TotalIncome: descending + score: 80 + height: 8 +- AvgQuantityBySource: + group: Financial + title: Average qunatity per source + visualization: table + dimensions: SourceLarge + metrics: AvgQuantity + order_by: + - AvgQuantity: descending + score: 80 + height: 8 +- AvgIncomeBySource: + group: Financial + title: Average income per source + visualization: table + dimensions: SourceLarge + metrics: AvgIncome + order_by: + - AvgIncome: descending + score: 80 + height: 8 +- UsersBySource: + group: UserAcquisition + visualization: table + title: Users per source + dimensions: SourceLarge + metrics: TotalUsers + order_by: + - TotalUsers: descending + score: 90 + height: 8 +- NewUsersBySource: + group: UserAcquisition + visualization: table + title: New users per source in the last 30 + dimensions: SourceLarge + metrics: TotalUsers + filters: NewUsers + score: 90 + height: 8 + order_by: + - TotalUsers: descending diff --git a/resources/automagic_dashboards/table/TransactionTable/ByState.yaml b/resources/automagic_dashboards/table/TransactionTable/ByState.yaml new file mode 100644 index 0000000000000000000000000000000000000000..900f865249b52c3ddd6b8f97c528d0dfd041ba2c --- /dev/null +++ b/resources/automagic_dashboards/table/TransactionTable/ByState.yaml @@ -0,0 +1,162 @@ +title: "[[this]] per state" +transient_title: Here's a closer look at your [[this]] per state +description: Which US states are bringing you the most business. +applies_to: TransactionTable +metrics: +- TotalIncome: + metric: [sum, [dimension, Income]] +- AvgIncome: [avg, [dimension, Income]] +- AvgQuantity: + metric: [avg, [dimension, Quantity]] +- TotalOrders: [count] +- TotalUsers: [distinct, [dimension, User]] +dimensions: +- Timestamp: + field_type: CreationTimestamp +- Timestamp: + field_type: DateTime +- State: + field_type: UserTable.State +- Income: Income +- Quantity: + field_type: Quantity +- User: + field_type: User + score: 100 +- User: + field_type: FK + links_to: UserTable + score: 100 +- Cohort: + field_type: UserTable.JoinTimestamp + score: 100 +- Cohort: + field_type: UserTable.CreationTimestamp + score: 90 +- Cohort: + field_type: UserTable.DateTime + score: 80 +- Product: + field_type: Product + score: 90 + max_cardinality: 10 +- Product: + field_type: ProductTable.Name + max_cardinality: 10 +- Product: + field_type: ProductTable.Title + max_cardinality: 10 +- ProductCategory: + field_type: ProductTable.Category + named: category + max_cardinality: 10 + score: 90 +- Source: + field_type: GenericTable.Source + max_cardinality: 10 + score: 90 +- Country: GenericTable.Country +filters: + - Last30Days: + filter: ["time-interval", [dimension, Timestamp], -30, day] + - NewUsers: + filter: ["time-interval", [dimension, Cohort], -30, day] +groups: +- Top10: + title: States that are performing best +- Transactions: + title: Transactions per state +- Users: + title: Where you've acquired your users +dashboard_filters: + - Timestamp + - Source + - Country + - State + - Product + - ProductCategory +cards: +- TopStatesBySales: + title: Top 10 states by sales + visualization: row + dimensions: State + metrics: TotalOrders + order_by: + - TotalOrders: descending + limit: 10 + score: 100 + height: 8 + group: Top10 +- TopStatesBySalesLast30Days: + title: Top 10 states by sales in the last 30 days + visualization: row + dimensions: State + metrics: TotalOrders + order_by: + - TotalOrders: descending + filters: Last30Days + limit: 10 + score: 100 + height: 8 + group: Top10 +- OrdersByState: + visualization: + map: + map.type: region + map.region: us_states + title: Sales per state + dimensions: State + metrics: TotalOrders + score: 90 + group: Transactions +- QuantityByState: + visualization: + map: + map.type: region + map.region: us_states + title: Average quantity per state + dimensions: State + metrics: AvgQuantity + score: 90 + group: Transactions +- IncomeByState: + visualization: + map: + map.type: region + map.region: us_states + title: Income per state + dimensions: State + metrics: TotalIncome + score: 90 + group: Transactions +- AverageIncomeByState: + visualization: + map: + map.type: region + map.region: us_states + title: Average income per state + dimensions: State + metrics: AvgIncome + score: 90 + group: Transactions +- UsersByState: + visualization: + map: + map.type: region + map.region: us_states + title: Users per state + dimensions: State + metrics: TotalUsers + score: 90 + group: Users +- NewUsersByState: + visualization: + map: + map.type: region + map.region: us_states + title: New users per state in the last 30 days + dimensions: State + metrics: TotalUsers + filters: NewUsers + score: 90 + group: Users diff --git a/resources/automagic_dashboards/table/TransactionTable/Seasonality.yaml b/resources/automagic_dashboards/table/TransactionTable/Seasonality.yaml new file mode 100644 index 0000000000000000000000000000000000000000..25fc5780f43c89d1fb469d0c7c6b810e35882c1a --- /dev/null +++ b/resources/automagic_dashboards/table/TransactionTable/Seasonality.yaml @@ -0,0 +1,78 @@ +title: "[[this]] over time" +transient_title: Here's a closer look at your [[this]] over time +description: Whether or not there are any patterns to when they happen. +applies_to: TransactionTable +metrics: + - Count: ["count"] +dimensions: +- Timestamp: + field_type: CreationTimestamp +- Timestamp: + field_type: DateTime +- Country: GenericTable.Country +- State: GenericTable.State +- Source: + field_type: UserTable.Source + max_cardinality: 10 +- ProductCategory: + field_type: ProductTable.Category + max_cardinality: 10 +dashboard_filters: +- Source +- Timestamp +- State +- Country +- ProductCategory +cards: + - CountByTimestamp: + title: Transactions over time + visualization: line + dimensions: Timestamp + metrics: Count + score: 100 + width: 18 + height: 6 + - DayOfWeekTimestamp: + title: Transactions per day of the week + visualization: bar + dimensions: + - Timestamp: + aggregation: day-of-week + metrics: Count + score: 60 + width: 9 + height: 6 + x_label: "[[Timestamp]] by day of week" + - HourOfDayTimestamp: + title: Transactions per hour of the day + visualization: bar + dimensions: + - Timestamp: + aggregation: hour-of-day + metrics: Count + score: 50 + width: 9 + height: 6 + x_label: "[[Timestamp]] by hour of day" + - MonthOfYearTimestamp: + title: Transactions per month of the year + visualization: bar + dimensions: + - Timestamp: + aggregation: month-of-year + metrics: Count + score: 40 + width: 9 + height: 6 + x_label: "[[Timestamp]] by month of year" + - QuarterOfYearTimestamp: + title: Transactions per quarter of the year + visualization: bar + dimensions: + - Timestamp: + aggregation: quarter-of-year + metrics: Count + score: 40 + width: 9 + height: 6 + x_label: "[[Timestamp]] by quarter of year" diff --git a/resources/automagic_dashboards/table/UserTable.yaml b/resources/automagic_dashboards/table/UserTable.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e4de9a775eb570b53b79959a10a463a612ab951a --- /dev/null +++ b/resources/automagic_dashboards/table/UserTable.yaml @@ -0,0 +1,147 @@ +title: A look at your [[this]] +transient_title: "Here's an overview of the people in your [[this]]" +description: An exploration of your users to get you started. +metrics: +- Count: + metric: [count] +dimensions: +- JoinDate: + field_type: JoinTimestamp + score: 100 +- JoinDate: + field_type: CreationTimestamp + score: 90 +- JoinDate: + field_type: DateTime + score: 30 +- Source: + field_type: Source +- GenericNumber: + field_type: GenericTable.Number + score: 80 +- Source: + field_type: GenericTable.Source + score: 100 +- GenericCategoryMedium: + field_type: GenericTable.Category + score: 75 + max_cardinality: 10 +- GenericCategoryLarge: + field_type: GenericTable.Category + score: 70 +- State: + field_type: State +- Country: Country +- Long: GenericTable.Longitude +- Lat: GenericTable.Latitude +# Ignore +- Name: Name +- Birthdate: Birthdate +- ZIP: ZipCode +filters: + - Last30Days: + filter: ["time-interval", [dimension, JoinDate], -30, day] +groups: +- Overview: + title: Overview +- Geographical: + title: Where these [[this]] are +- General: + title: How these [[this]] are distributed +dashboard_filters: +- JoinDate +- GenericCategoryMedium +- Source +- Country +- State +cards: +# Overview + - Rowcount: + title: Total [[this]] + visualization: scalar + metrics: Count + score: 100 + group: Overview + width: 5 + height: 3 + - RowcountLast30Days: + title: New [[this]] in the last 30 days + visualization: scalar + metrics: Count + score: 100 + filters: Last30Days + group: Overview + width: 5 + height: 3 + - NewUsersByMonth: + visualization: line + title: New [[this]] per month + description: The number of new [[this]] each month + dimensions: JoinDate + metrics: Count + score: 100 + group: Overview + width: 18 + height: 7 +# Geographical + - CountByCountry: + title: Number of [[this]] per country + metrics: Count + dimensions: Country + score: 90 + visualization: + map: + map.type: region + map.region: world_countries + group: Geographical + - CountByState: + title: "[[this]] per state" + metrics: Count + dimensions: State + score: 90 + height: 8 + visualization: + map: + map.type: region + map.region: us_states + group: Geographical + - CountByCoords: + title: "[[this]] by coordinates" + metrics: Count + dimensions: + - Long + - Lat + visualization: map + score: 80 + height: 8 + group: Geographical +# General + - NumberDistribution: + title: How [[GenericNumber]] is distributed + dimensions: + - GenericNumber: + aggregation: default + metrics: Count + visualization: bar + score: 90 + group: General + - CountByCategoryMedium: + title: "[[this]] per [[GenericCategoryMedium]]" + dimensions: GenericCategoryMedium + metrics: Count + visualization: row + score: 80 + height: 8 + group: General + order_by: + - Count: descending + - CountByCategoryLarge: + title: "[[this]] per [[GenericCategoryLarge]]" + dimensions: GenericCategoryLarge + metrics: Count + visualization: table + height: 8 + score: 70 + group: General + order_by: + - Count: descending diff --git a/resources/automagic_dashboards/table/example.yaml b/resources/automagic_dashboards/table/example.yaml new file mode 100644 index 0000000000000000000000000000000000000000..69036528ca3b3a57e4baaeb0ce0b47f83dfb3495 --- /dev/null +++ b/resources/automagic_dashboards/table/example.yaml @@ -0,0 +1,180 @@ +# Each automagic dashboard heuristic produces one dashboard and is primarily drawing +# from a single table (the "root table"). Other tables can be accessed via joins +# though. +# +# Heuristics are comprised of 3 sections: dimensions (defines fields to be used), +# metrics (aggregations), filters, and cards (cards to be displayed on the dashboard). + +# Name of the dashboard +# You can reference the selected table in the title and description. A reference +#like this will be replaced by the table's (display) name. +title: Example exploration +description: Autogenerated metrics about [[GenericTable]]. +# In general the file name of the heuristic is used to determine the type of table +# it applies to, but this can be overwritten by table_type field. For a list of +# available types see the namespace metabase.type. +applies_to: "*" + +# Metrics +# +# Only aggregations ("metrics") defined here can be used in cards. +# Each metric is a map of metric name to metric definition which is again a map with +# two keys: metric (a MBQL expression) and score (between 0 and 100) used to +# determine the metric's importance. If score is omitted, 100 is assmumed. +# To reference dimensions defined within this heuristic use the form +# [dimension, DimensionName] (this is an extenssion to MBQL that is not available +# outside these heuristics definitions). +# One can also use shorthand where instead of a map, the definition is directly +# MBQL. +# Metrics can be overloaded. The highest scoring definition from among those that +# reference dimensions that match at least one field will be used. + +metrics: +- TotalIncome: + metric: [sum, [dimension, Income]] + score: 90 +# Shorthand +- TotalOrders: [count] + +# Filters +# +# Each filter has the form of a map of filter name to filter definition, +# which is agian a map with two keys: filter (a MBQL expression) and score (between +# 0 and 100) used to determine the metric's importance. If score is omitted, 100 is +# assmumed. One can also use shorthand where instead of a map, the +# definition is directly filter MBQL. +# Filter can be overloaded. The highest scoring filter from among those that +# reference dimensions that match at least one field will be used. + +filters: + - Last30Days: + filter: ["time-interval", [dimension, Timestamp], -30, day] + +# Dimensions +# +# Dimensions define bindings to fields. A single dimension can match multiple +# fields. Primary mode of matching is by field type, but several refinements are +# available (name, where a foregin key points to). For a list of available field +# types see the namespace metabase.type. +# Each dimension has the form of a map of dimension name to dimension definition, +# which is agian a map. One can also use shorthand where instead of a map, the +# definition is directly field_type. +# Dimensions can be overloaded. The highest scoring definition from among those that +# match at least one field will be used. + +dimensions: +- Timestamp: + field_type: CreationTimestamp + score: 100 +# Overloaded definition +- Timestamp: + field_type: DateTime + score: 90 +# shorthand +- Income: Income +- Source: Source +- Long: Longitude +- Lat: Latitude +- UserFK: + field_type: FK + links_to: UserTable # used to disambiguate FKs +- UserPK: UserTable.PK +# Match fields of type State from a joined table of type UserTable +- State: UserTable.State +- ProductCategory: + field_type: ProductTable.Category + max_cardinality: 10 # only capture fields with 10 or less distinct values +- BatchNum: + field_type: Integer + named: batch # name pattern (regex) + +# Groups + +groups: +- OrderBreakdown: + title: Some breakdown + +# Cards +# +# Cards that go on the dashboard. +# If a dimension thath matches multiple fields is referenced, multiple cards will be +# produced. +# Cards can be overloaded. The highest scoring definition from among those for which +# all dimensions referenced match at least one field will be used. +# Only the 9 highest scoring cards are shown. By default cards are sized 6x4 grid +# units but this can be overridden. + +cards: +- IncomeByMonth: + visualization: line + title: Sales by month + description: Total income by month + dimensions: Timestamp + # 2 Y-axes + metrics: + - TotalIncome + - TotalOrders + score: 90 + width: 18 # full width of the dashboard +# Similar cards can be grouped together. Grouped cards will be displayed adjecent to +# one another and the highiest relevance score among them will be used for all. +- OrdersBySource: + group: OrderBreakdown + visualization: row + title: Sales by source + dimensions: Source + metrics: TotalOrders + filters: Last30Days + # shorthand order_by clause. Same as: + # order_by: + # - TotalOrders: descending + # Multiple ordering criteria can be defined that way. + order_by: TotalOrders +- OrdersByState: + group: OrderBreakdown + visualization: + map: + map.type: region + map.region: us_states #region can be one of: "world" or "us_states" + title: Sales by state + dimensions: State + metrics: TotalOrders + filters: Last30Days + order_by: TotalOrders +- OrdersByProductCategory: + # You can reference the selected field in the title. A reference like this will + # be replaced by field's (display) name. + title: Sales by product [[ProductCategory]] + visualization: row + group: OrderBreakdown + dimensions: + - ProductCategory + metrics: TotalOrders + order_by: TotalOrders + # Same as SQL limit + limit: 10 + score: 90 + height: 8 # double height + filters: Last30Days +- OrdersByCoords: + title: Distribution by coordinates + metrics: TotalOrders + # Multiple dimensions can be used + dimensions: + - Lat + - Long + score: 80 + visualization: + map: + map.type: pin + # explicitly bind dimensions + map.latitude_column: Lat + map.longitude_column: Long +- Native: + title: Native query + # Template interpolation works the same way as in title and description. Field + # names are automatically expanded into the full TableName.FieldName form. + query: select count(*), [[State]] + from [[GenericTable]] join [[UserTable]] on + [[UserFK]] = [[UserPK]] + visualization: bar diff --git a/resources/automagic_dashboards/table/example/indepth.yaml b/resources/automagic_dashboards/table/example/indepth.yaml new file mode 100644 index 0000000000000000000000000000000000000000..974b72df9cbea4e0374e1c8eb86cb11bdbdd4cf5 --- /dev/null +++ b/resources/automagic_dashboards/table/example/indepth.yaml @@ -0,0 +1,12 @@ +title: Indepth example +applies_to: GenericTable +metrics: + - Count: ["count"] +dimensions: + - GenericField: "*" +cards: + - Rowcount: + title: Total [[GenericTable]] + visualization: scalar + metrics: Count + score: 100 \ No newline at end of file diff --git a/resources/frontend_client/app/assets/img/metabot.svg b/resources/frontend_client/app/assets/img/metabot.svg new file mode 100644 index 0000000000000000000000000000000000000000..f67f1213cd20a75b6a9c35531c8483566a65e033 --- /dev/null +++ b/resources/frontend_client/app/assets/img/metabot.svg @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="50" height="35" viewBox="0 0 50 35" xmlns="http://www.w3.org/2000/svg"> + <title>metabot</title> + <g fill="none" fill-rule="evenodd"> + <rect fill="#509EE3" width="50" height="35" rx="4"/> + <rect fill="#D0E5F7" x="3" y="3" width="44" height="25" rx="2"/> + <ellipse fill="#509EE3" cx="14.83" cy="11.5" rx="1.83" ry="2.5"/> + <ellipse fill="#509EE3" cx="34.83" cy="11.5" rx="1.83" ry="2.5"/> + <path d="M19.416 16.561c.716 2.638 2.633 3.956 5.751 3.956 3.119 0 4.908-1.318 5.368-3.956" stroke="#509EE3" stroke-width="2"/> + <rect fill="#F9D45C" x="44" y="30" width="2" height="2" rx="1"/> + <rect fill="#A8C987" x="40" y="30" width="2" height="2" rx="1"/> + </g> +</svg> diff --git a/resources/frontend_client/app/locales/de.json b/resources/frontend_client/app/locales/de.json deleted file mode 100644 index b3a9532f6beda193d3e13b2a078bee15a47d3384..0000000000000000000000000000000000000000 --- a/resources/frontend_client/app/locales/de.json +++ /dev/null @@ -1,154 +0,0 @@ -{ - "charset": "utf-8", - "headers": { - "project-id-version": "metabase", - "report-msgid-bugs-to": "docs@metabase.com", - "pot-creation-date": "2017-08-21 21:54+0200", - "po-revision-date": "2017-08-21 21:17+0200", - "last-translator": "Tom Robinson <tom@metabase.com>", - "language-team": "German <translation-team-de@lists.sourceforge.net>", - "language": "de", - "mime-version": "1.0", - "content-type": "text/plain; charset=UTF-8", - "content-transfer-encoding": "8bit", - "#-#-#-#-# metabase-frontend.pot #-#-#-#-#": "", - "plural-forms": "nplurals=2; plural=(n != 1);", - "#-#-#-#-# metabase-backend.pot (metabase) #-#-#-#-#": "" - }, - "translations": { - "": { - "Hello World!": { - "msgid": "Hello World!", - "msgstr": [ - "Hallo Welt!" - ] - }, - "Metabase is up and running.": { - "msgid": "Metabase is up and running.", - "msgstr": [ - "Metabase ist auf und laufen" - ] - }, - "removed the filter {item.details.name}": { - "msgid": "removed the filter {item.details.name}", - "msgstr": [ - "entfernen sie den filter {item.details.name}" - ] - }, - "joined!": { - "msgid": "joined!", - "msgstr": [ - "beigetreten!" - ] - }, - "Setup Tip": { - "msgid": "Setup Tip", - "msgstr": [ - "Setup Tipp" - ] - }, - "View all": { - "msgid": "View all", - "msgstr": [ - "Alle Anzeigen" - ] - }, - "Recently Viewed": { - "msgid": "Recently Viewed", - "msgstr": [ - "Vor Kurzem Anzeige" - ] - }, - "You haven't looked at any dashboards or questions recently": { - "msgid": "You haven't looked at any dashboards or questions recently", - "msgstr": [ - "Sie haben nicht schaute zu jeder dashboards oder fragen kürzlich" - ] - }, - "Activity": { - "msgid": "Activity", - "msgstr": [ - "Aktivitäten" - ] - }, - "Hey there": { - "msgid": "Hey there", - "msgstr": [ - "Hallo" - ] - }, - "How's it going": { - "msgid": "How's it going", - "msgstr": [ - "Wie ist es gehen" - ] - }, - "Howdy": { - "msgid": "Howdy", - "msgstr": [ - "Hallo" - ] - }, - "Greetings": { - "msgid": "Greetings", - "msgstr": [ - "Grüße" - ] - }, - "Good to see you": { - "msgid": "Good to see you", - "msgstr": [ - "Schön, dich zu sehen" - ] - }, - "What do you want to know?": { - "msgid": "What do you want to know?", - "msgstr": [ - "Was möchten sie wissen?" - ] - }, - "What's on your mind?": { - "msgid": "What's on your mind?", - "msgstr": [ - "Was auf dem herzen?" - ] - }, - "What do you want to find out?": { - "msgid": "What do you want to find out?", - "msgstr": [ - "Was möchten sie finden ot?" - ] - }, - "Dashboards": { - "msgid": "Dashboards", - "msgstr": [ - "Ãœbersicht" - ] - }, - "Questions": { - "msgid": "Questions", - "msgstr": [ - "Fragen" - ] - }, - "Pulses": { - "msgid": "Pulses", - "msgstr": [ - "Impuse" - ] - }, - "Data Reference": { - "msgid": "Data Reference", - "msgstr": [ - "Daten Referenz" - ] - }, - "New Question": { - "msgid": "New Question", - "msgstr": [ - "Neue Frage" - ] - } - } - } -} \ No newline at end of file diff --git a/resources/sample-dataset.db.mv.db b/resources/sample-dataset.db.mv.db index 5ca82600bfcfb98e5958a360d2bdd9771c49009d..a6495ccd7abf2cc81d44f483cce0685018abf6b6 100644 Binary files a/resources/sample-dataset.db.mv.db and b/resources/sample-dataset.db.mv.db differ diff --git a/sample_dataset/metabase/sample_dataset/addresses.edn b/sample_dataset/metabase/sample_dataset/addresses.edn new file mode 100644 index 0000000000000000000000000000000000000000..e175fc72821ef1cfa972ae91fccb369a1c2f8351 --- /dev/null +++ b/sample_dataset/metabase/sample_dataset/addresses.edn @@ -0,0 +1,17500 @@ +({:lat 40.71314890000001, + :lon -98.5259864, + :house-number "9611-9809", + :street "West Rosedale Road", + :city "Wood River", + :state-abbrev "NE", + :zip "68883"} + {:lat 41.5813224, + :lon -92.6991321, + :house-number "101", + :street "4th Street", + :city "Searsboro", + :state-abbrev "IA", + :zip "50242"} + {:lat 46.11973039999999, + :lon -92.8416108, + :house-number "29494", + :street "Anderson Drive", + :city "Sandstone", + :state-abbrev "MN", + :zip "55072"} + {:lat 37.9202933, + :lon -104.9726909, + :house-number "2-7900", + :street "Cuerno Verde Road", + :city "Rye", + :state-abbrev "CO", + :zip "81069"} + {:lat 42.348954, + :lon -77.056681, + :house-number "761", + :street "Fish Hill Road", + :city "Beaver Dams", + :state-abbrev "NY", + :zip "14812"} + {:lat 30.1514772, + :lon -92.4861786, + :house-number "1243", + :street "West Whitney Street", + :city "Morse", + :state-abbrev "LA", + :zip "70559"} + {:lat 31.2341055, + :lon -88.5856948, + :house-number "630", + :street "Coaker Road", + :city "Leakesville", + :state-abbrev "MS", + :zip "39451"} + {:lat 37.43472089999999, + :lon -94.6426865, + :house-number "1167", + :street "East 570th Avenue", + :city "Pittsburg", + :state-abbrev "KS", + :zip "66762"} + {:lat 42.29790209999999, + :lon -95.4673587, + :house-number "5816-5894", + :street "280th Street", + :city "Ida Grove", + :state-abbrev "IA", + :zip "51445"} + {:lat 40.8006673, + :lon -83.2838391, + :house-number "13081-13217", + :street "Main Street", + :city "Upper Sandusky", + :state-abbrev "OH", + :zip "43351"} + {:lat 42.1394217, + :lon -93.982366, + :house-number "495", + :street "Juniper Road", + :city "Pilot Mound", + :state-abbrev "IA", + :zip "50223"} + {:lat 44.9564152, + :lon -97.2287266, + :house-number "16701-16743", + :street "449th Avenue", + :city "Florence", + :state-abbrev "SD", + :zip "57235"} + {:lat 33.08172, + :lon -116.661853, + :house-number "2993", + :street "Hoskings Ranch Road", + :city "Santa Ysabel", + :state-abbrev "CA", + :zip "92070"} + {:lat 39.6485802, + :lon -121.9343322, + :house-number "3964", + :street "Chico River Road", + :city "Chico", + :state-abbrev "CA", + :zip "95928"} + {:lat 35.1080351, + :lon -92.0101859, + :house-number "258", + :street "Opal Road", + :city "El Paso", + :state-abbrev "AR", + :zip "72045"} + {:lat 32.7042678, + :lon -87.54135959999999, + :house-number "3234", + :street "County Road 7", + :city "Greensboro", + :state-abbrev "AL", + :zip "36744"} + {:lat 42.5048529, + :lon -124.1878367, + :house-number "6111", + :street "Rogue Riv", + :city "Gold Beach", + :state-abbrev "OR", + :zip "97444"} + {:lat 45.554885, + :lon -122.5277436, + :house-number "13116", + :street "Northeast Sandy Boulevard", + :city "Portland", + :state-abbrev "OR", + :zip "97230"} + {:lat 42.3222669, + :lon -123.324017, + :house-number "1460", + :street "Grays Creek Road", + :city "Grants Pass", + :state-abbrev "OR", + :zip "97527"} + {:lat 41.466973, + :lon -95.1012128, + :house-number "52737", + :street "570th Street", + :city "Marne", + :state-abbrev "IA", + :zip "51552"} + {:lat 40.9764889, + :lon -98.13254409999999, + :house-number "2002-2078", + :street "North J Road", + :city "Phillips", + :state-abbrev "NE", + :zip "68865"} + {:lat 38.5063378, + :lon -122.1878842, + :house-number "1165", + :street "Rimrock Drive", + :city "Napa", + :state-abbrev "CA", + :zip "94558"} + {:lat 33.0854659, + :lon -81.67635899999999, + :house-number "2003", + :street "Brigham Landing Road", + :city "Girard", + :state-abbrev "GA", + :zip "30426"} + {:lat 47.1794946, + :lon -93.6321358, + :house-number "20930", + :street "Sugar Hills Road", + :city "Cohasset", + :state-abbrev "MN", + :zip "55721"} + {:lat 36.0479853, + :lon -94.20265719999999, + :house-number "1201-2099", + :street "Plumberosa Drive", + :city "Fayetteville", + :state-abbrev "AR", + :zip "72701"} + {:lat 41.4720902, + :lon -118.346273, + :house-number "19480", + :street "Happy Creek Road", + :city "Winnemucca", + :state-abbrev "NV", + :zip "89445"} + {:lat 41.1278979, + :lon -83.5359594, + :house-number "2398-2558", + :street "Township Highway 249", + :city "Arcadia", + :state-abbrev "OH", + :zip "44804"} + {:lat 34.4325826, + :lon -88.5702919, + :house-number "355", + :street "Road 2538", + :city "Baldwyn", + :state-abbrev "MS", + :zip "38824"} + {:lat 39.6955024, + :lon -82.5027112, + :house-number "5125", + :street "Duffy Road Southeast", + :city "Lancaster", + :state-abbrev "OH", + :zip "43130"} + {:lat 34.3125434, + :lon -91.20821, + :house-number "13-99", + :street "Deberry Levee Road", + :city "DeWitt", + :state-abbrev "AR", + :zip "72042"} + {:lat 43.80686660000001, + :lon -91.6359614, + :house-number "2303", + :street "Christianson Hill Road", + :city "Houston", + :state-abbrev "MN", + :zip "55943"} + {:lat 37.94777029999999, + :lon -104.8778192, + :house-number "6101", + :street "County Road CC67", + :city "Pueblo", + :state-abbrev "CO", + :zip "81004"} + {:lat 40.3372992, + :lon -106.7981696, + :house-number "33430", + :street "Danver Trail", + :city "Steamboat Springs", + :state-abbrev "CO", + :zip "80487"} + {:lat 38.656887, + :lon -77.975854, + :house-number "3349", + :street "Holly Springs Road", + :city "Amissville", + :state-abbrev "VA", + :zip "20106"} + {:lat 42.5616569, + :lon -88.677537, + :house-number "N1962", + :street "County Road K", + :city "Sharon", + :state-abbrev "WI", + :zip "53585"} + {:lat 38.6232452, + :lon -98.8225441, + :house-number "244-298", + :street "Northwest 180 Road", + :city "Hoisington", + :state-abbrev "KS", + :zip "67544"} + {:lat 32.3735889, + :lon -111.03742, + :house-number "3210-3298", + :street "West Overton Road", + :city "Tucson", + :state-abbrev "AZ", + :zip "85742"} + {:lat 38.4920821, + :lon -82.0002696, + :house-number "155", + :street "Cow Creek Road", + :city "Hurricane", + :state-abbrev "WV", + :zip "25526"} + {:lat 43.9393467, + :lon -96.1592993, + :house-number "618", + :street "150th Avenue", + :city "Edgerton", + :state-abbrev "MN", + :zip "56128"} + {:lat 29.99592, + :lon -97.5590129, + :house-number "171", + :street "Olive Oyle Lane", + :city "Dale", + :state-abbrev "TX", + :zip "78616"} + {:lat 41.539616, + :lon -93.874784, + :house-number "31619", + :street "Silverado Lane", + :city "Waukee", + :state-abbrev "IA", + :zip "50263"} + {:lat 44.10684560000001, + :lon -75.9958097, + :house-number "30379-30383", + :street "Herbrecht Road", + :city "Watertown", + :state-abbrev "NY", + :zip "13601"} + {:lat 42.43880739999999, + :lon -92.6713936, + :house-number "18001-18999", + :street "North Morrion", + :city "Dike", + :state-abbrev "IA", + :zip "50624"} + {:lat 43.0246186, + :lon -94.6294178, + :house-number "4251-4389", + :street "485th Avenue", + :city "Mallard", + :state-abbrev "IA", + :zip "50562"} + {:lat 41.3221172, + :lon -91.47486099999999, + :house-number "28001-28599", + :street "180th Street", + :city "Columbus Junction", + :state-abbrev "IA", + :zip "52738"} + {:lat 44.8540315, + :lon -84.87710150000001, + :house-number "15000-15312", + :street "Crooked Road Northeast", + :city "Kalkaska", + :state-abbrev "MI", + :zip "49646"} + {:lat 35.337122, + :lon -111.3635, + :house-number "111", + :street "Leupp Road", + :city "Flagstaff", + :state-abbrev "AZ", + :zip "86004"} + {:lat 39.9879628, + :lon -97.1458337, + :house-number "1260", + :street "29th Road", + :city "Morrowville", + :state-abbrev "KS", + :zip "66958"} + {:lat 37.1382857, + :lon -94.11219600000001, + :house-number "6043", + :street "County Road 30", + :city "Reeds", + :state-abbrev "MO", + :zip "64859"} + {:lat 48.2384345, + :lon -97.86937549999999, + :house-number "12751-12799", + :street "57th Street Northeast", + :city "Fordville", + :state-abbrev "ND", + :zip "58231"} + {:lat 33.493444, + :lon -89.8906564, + :house-number "21421", + :street "U.S. 82", + :city "Carrollton", + :state-abbrev "MS", + :zip "38917"} + {:lat 34.057083, + :lon -83.81534099999999, + :house-number "1110", + :street "Puckett Road", + :city "Auburn", + :state-abbrev "GA", + :zip "30011"} + {:lat 34.272145, + :lon -89.185783, + :house-number "1380", + :street "Juba Road", + :city "Pontotoc", + :state-abbrev "MS", + :zip "38863"} + {:lat 42.324103, + :lon -96.74542609999999, + :house-number "87245", + :street "590 Avenue", + :city "Waterbury", + :state-abbrev "NE", + :zip "68785"} + {:lat 32.1412409, + :lon -85.6383022, + :house-number "373", + :street "Lee Loop Road", + :city "Union Springs", + :state-abbrev "AL", + :zip "36089"} + {:lat 40.5974724, + :lon -102.5311932, + :house-number "22842", + :street "County Road 15", + :city "Haxtun", + :state-abbrev "CO", + :zip "80731"} + {:lat 46.578927, + :lon -116.537937, + :house-number "35378", + :street "South Road", + :city "Kendrick", + :state-abbrev "ID", + :zip "83537"} + {:lat 37.8205743, + :lon -87.2486685, + :house-number "174", + :street "Kentucky 1554", + :city "Owensboro", + :state-abbrev "KY", + :zip "42301"} + {:lat 37.4230302, + :lon -105.3720874, + :house-number "3405", + :street "Brittain Road", + :city "Fort Garland", + :state-abbrev "CO", + :zip "81133"} + {:lat 38.111944, + :lon -82.5399396, + :house-number "1826", + :street "Paddle Creek Road", + :city "Fort Gay", + :state-abbrev "WV", + :zip "25514"} + {:lat 35.69917, + :lon -81.017265, + :house-number "7100", + :street "Hudson Chapel Road", + :city "Catawba", + :state-abbrev "NC", + :zip "28609"} + {:lat 27.872096, + :lon -82.61317000000001, + :house-number "12055", + :street "Gandy Boulevard North", + :city "Saint Petersburg", + :state-abbrev "FL", + :zip "33702"} + {:lat 43.21093399999999, + :lon -95.24696499999999, + :house-number "1725", + :street "300th Street", + :city "Spencer", + :state-abbrev "IA", + :zip "51301"} + {:lat 42.723496, + :lon -75.856499, + :house-number "207", + :street "Grenadier Drive", + :city "DeRuyter", + :state-abbrev "NY", + :zip "13052"} + {:lat 44.7004251, + :lon -102.1931175, + :house-number "18630", + :street "Old Marcus Road", + :city "Howes", + :state-abbrev "SD", + :zip "57748"} + {:lat 43.763995, + :lon -75.38189799999999, + :house-number "6568", + :street "Pine Grove Road", + :city "Glenfield", + :state-abbrev "NY", + :zip "13343"} + {:lat 56.26086710000001, + :lon -158.7019256, + :house-number "19", + :street "Riverfront Drive C Street", + :city "Chignik Lake", + :state-abbrev "AK", + :zip "99548"} + {:lat 37.89017339999999, + :lon -87.1323259, + :house-number "449-499", + :street "County Road 400 West", + :city "Rockport", + :state-abbrev "IN", + :zip "47635"} + {:lat 42.3420983, + :lon -96.0377865, + :house-number "2508-2520", + :street "Hancock Avenue", + :city "Moville", + :state-abbrev "IA", + :zip "51039"} + {:lat 42.1362931, + :lon -99.8663091, + :house-number "84975", + :street "Nebraska 7", + :city "Ainsworth", + :state-abbrev "NE", + :zip "69210"} + {:lat 44.500381, + :lon -109.2863821, + :house-number "3801-4099", + :street "North Fork Highway", + :city "Cody", + :state-abbrev "WY", + :zip "82414"} + {:lat 30.604401, + :lon -85.02780299999999, + :house-number "28367", + :street "Florida 69", + :city "Grand Ridge", + :state-abbrev "FL", + :zip "32442"} + {:lat 42.5549457, + :lon -72.6583358, + :house-number "28-50", + :street "Hawks Road", + :city "Shelburne Falls", + :state-abbrev "MA", + :zip "01370"} + {:lat 39.6293759, + :lon -85.3744045, + :house-number "350", + :street "East North Street", + :city "Rushville", + :state-abbrev "IN", + :zip "46173"} + {:lat 43.1848428, + :lon -100.033216, + :house-number "30682", + :street "291st Street", + :city "Winner", + :state-abbrev "SD", + :zip "57580"} + {:lat 32.9558013, + :lon -89.0665798, + :house-number "600", + :street "Hight Moore Road", + :city "Noxapater", + :state-abbrev "MS", + :zip "39346"} + {:lat 37.1228826, + :lon -83.99708249999999, + :house-number "1436", + :street "Tom Cat Trail", + :city "London", + :state-abbrev "KY", + :zip "40741"} + {:lat 45.838178, + :lon -111.495663, + :house-number "4333", + :street "Madison Road", + :city "Three Forks", + :state-abbrev "MT", + :zip "59752"} + {:lat 28.412059, + :lon -81.43779599999999, + :house-number "4797", + :street "Central Florida Parkway", + :city "Orlando", + :state-abbrev "FL", + :zip "32821"} + {:lat 33.2513235, + :lon -95.88581839999999, + :house-number "101-1299", + :street "Mosley Street", + :city "Commerce", + :state-abbrev "TX", + :zip "75428"} + {:lat 48.14901099999999, + :lon -122.137382, + :house-number "6913", + :street "168th Street Northeast", + :city "Arlington", + :state-abbrev "WA", + :zip "98223"} + {:lat 36.2020785, + :lon -94.4309788, + :house-number "15079-15327", + :street "Readings Road", + :city "Siloam Springs", + :state-abbrev "AR", + :zip "72761"} + {:lat 48.6052737, + :lon -109.2199464, + :house-number "4", + :street "Hc 69", + :city "Chinook", + :state-abbrev "MT", + :zip "59523"} + {:lat 40.8042019, + :lon -86.01402499999999, + :house-number "2787", + :street "North 300 East", + :city "Peru", + :state-abbrev "IN", + :zip "46970"} + {:lat 42.6392077, + :lon -72.50279499999999, + :house-number "21-89", + :street "Lyons Hill Road", + :city "Gill", + :state-abbrev "MA", + :zip "01354"} + {:lat 40.29595, + :lon -122.440831, + :house-number "17300", + :street "View Drive", + :city "Cottonwood", + :state-abbrev "CA", + :zip "96022"} + {:lat 42.167971, + :lon -121.867448, + :house-number "9665", + :street "Pat Drive", + :city "Klamath Falls", + :state-abbrev "OR", + :zip "97601"} + {:lat 41.350309, + :lon -123.84852, + :house-number "2", + :street "Site 7", + :city "Hoopa", + :state-abbrev "CA", + :zip "95546"} + {:lat 43.5903545, + :lon -91.33149290000001, + :house-number "17779", + :street "Walnut Road", + :city "Brownsville", + :state-abbrev "MN", + :zip "55919"} + {:lat 34.505161, + :lon -93.006959, + :house-number "2190", + :street "East Grand Avenue", + :city "Hot Springs", + :state-abbrev "AR", + :zip "71901"} + {:lat 31.36935089999999, + :lon -103.5827313, + :house-number "292-386", + :street "County Road 206", + :city "Pecos", + :state-abbrev "TX", + :zip "79772"} + {:lat 43.0570164, + :lon -97.9539025, + :house-number "29883", + :street "412th Avenue", + :city "Avon", + :state-abbrev "SD", + :zip "57315"} + {:lat 61.37213, + :lon -150.098354, + :house-number "25890", + :street "Holstein Avenue", + :city "Wasilla", + :state-abbrev "AK", + :zip "99654"} + {:lat 44.7873093, + :lon -120.934635, + :house-number "11105", + :street "North Old Highway 97", + :city "Madras", + :state-abbrev "OR", + :zip "97741"} + {:lat 47.4154041, + :lon -117.7767551, + :house-number "25611", + :street "South Carman Road", + :city "Cheney", + :state-abbrev "WA", + :zip "99004"} + {:lat 33.2537345, + :lon -92.1484057, + :house-number "839", + :street "Bradley 60 Road", + :city "Hermitage", + :state-abbrev "AR", + :zip "71647"} + {:lat 36.64563400000001, + :lon -120.00331, + :house-number "11821", + :street "West Lincoln Avenue", + :city "Fresno", + :state-abbrev "CA", + :zip "93706"} + {:lat 38.8234, + :lon -82.6755, + :house-number "2329", + :street "Jackson Fork Road", + :city "South Webster", + :state-abbrev "OH", + :zip "45682"} + {:lat 46.331827, + :lon -116.2924427, + :house-number "2160", + :street "Settler Road", + :city "Craigmont", + :state-abbrev "ID", + :zip "83523"} + {:lat 43.7130017, + :lon -75.43909099999999, + :house-number "6088", + :street "Meiss Road", + :city "Glenfield", + :state-abbrev "NY", + :zip "13343"} + {:lat 38.0319159, + :lon -94.2752332, + :house-number "3118", + :street "South 1938 Road", + :city "Rich Hill", + :state-abbrev "MO", + :zip "64779"} + {:lat 46.6588404, + :lon -113.6540732, + :house-number "550", + :street "Rock Creek Road", + :city "Clinton", + :state-abbrev "MT", + :zip "59825"} + {:lat 47.6128237, + :lon -96.9681621, + :house-number "16780", + :street "14th Street Northeast", + :city "Buxton", + :state-abbrev "ND", + :zip "58218"} + {:lat 35.598895, + :lon -84.83532799999999, + :house-number "6183", + :street "Old Dixie Highway", + :city "Evensville", + :state-abbrev "TN", + :zip "37332"} + {:lat 39.6135899, + :lon -122.5711026, + :house-number "3135", + :street "Sanhedrin Road", + :city "Elk Creek", + :state-abbrev "CA", + :zip "95939"} + {:lat 39.8863642, + :lon -86.5583394, + :house-number "1701-1999", + :street "West County Road 850 North", + :city "Lizton", + :state-abbrev "IN", + :zip "46149"} + {:lat 42.7814427, + :lon -94.8164443, + :house-number "48502-48654", + :street "150th Avenue", + :city "Pocahontas", + :state-abbrev "IA", + :zip "50574"} + {:lat 33.509668, + :lon -96.032372, + :house-number "5706", + :street "Farm to Market 1743", + :city "Windom", + :state-abbrev "TX", + :zip "75492"} + {:lat 40.919299, + :lon -81.545908, + :house-number "271", + :street "West Comet Road", + :city "Clinton", + :state-abbrev "OH", + :zip "44216"} + {:lat 40.891452, + :lon -87.290014, + :house-number "5361", + :street "East 700 South", + :city "Brook", + :state-abbrev "IN", + :zip "47922"} + {:lat 30.0178145, + :lon -93.0878386, + :house-number "521-599", + :street "Louisiana 27", + :city "Bell City", + :state-abbrev "LA", + :zip "70630"} + {:lat 39.2802275, + :lon -122.1971163, + :house-number "370", + :street "North Street", + :city "Maxwell", + :state-abbrev "CA", + :zip "95955"} + {:lat 48.65539769999999, + :lon -104.9287866, + :house-number "1711", + :street "Welliver Road", + :city "Redstone", + :state-abbrev "MT", + :zip "59257"} + {:lat 40.922515, + :lon -74.654822, + :house-number "1 A", + :street "Point Pleasant Road", + :city "Hopatcong", + :state-abbrev "NJ", + :zip "07843"} + {:lat 29.18861, + :lon -95.4850699, + :house-number "28161", + :street "FM 521 Road", + :city "Angleton", + :state-abbrev "TX", + :zip "77515"} + {:lat 46.13674779999999, + :lon -106.4627731, + :house-number "2020-2026", + :street "Rosebud Creek Road", + :city "Forsyth", + :state-abbrev "MT", + :zip "59327"} + {:lat 41.6020003, + :lon -80.32627610000002, + :house-number "10237", + :street "Pennsylvania 285", + :city "Conneaut Lake", + :state-abbrev "PA", + :zip "16316"} + {:lat 31.3799326, + :lon -94.5245865, + :house-number "870", + :street "Marions Ferry Road", + :city "Huntington", + :state-abbrev "TX", + :zip "75949"} + {:lat 34.2667033, + :lon -83.7451529, + :house-number "2945-2965", + :street "Gillsville Highway", + :city "Gainesville", + :state-abbrev "GA", + :zip "30507"} + {:lat 42.593775, + :lon -72.021141, + :house-number "538", + :street "Clark Street", + :city "Gardner", + :state-abbrev "MA", + :zip "01440"} + {:lat 43.3301989, + :lon -97.8519453, + :house-number "41706", + :street "280th Street", + :city "Tripp", + :state-abbrev "SD", + :zip "57376"} + {:lat 34.75105, + :lon -119.427429, + :house-number "31541", + :street "California 33", + :city "Maricopa", + :state-abbrev "CA", + :zip "93252"} + {:lat 41.4658789, + :lon -74.9888927, + :house-number "583", + :street "Pennsylvania 590", + :city "Lackawaxen", + :state-abbrev "PA", + :zip "18435"} + {:lat 47.8845232, + :lon -97.6237067, + :house-number "1451-1499", + :street "37th Street Northeast", + :city "Larimore", + :state-abbrev "ND", + :zip "58251"} + {:lat 46.3580329, + :lon -87.829121, + :house-number "4801", + :street "Road Cce", + :city "Ishpeming", + :state-abbrev "MI", + :zip "49849"} + {:lat 31.921767, + :lon -98.324061, + :house-number "1188", + :street "Farm to Market Road 1702", + :city "Dublin", + :state-abbrev "TX", + :zip "76446"} + {:lat 41.1490825, + :lon -87.17967300000001, + :house-number "9698-9912", + :street "North 700 West", + :city "De Motte", + :state-abbrev "IN", + :zip "46310"} + {:lat 37.703053, + :lon -106.37448, + :house-number "19045", + :street "County Road 15", + :city "Del Norte", + :state-abbrev "CO", + :zip "81132"} + {:lat 37.2630179, + :lon -86.1622465, + :house-number "8235", + :street "Nolin Dam Road", + :city "Mammoth Cave", + :state-abbrev "KY", + :zip "42259"} + {:lat 43.153337, + :lon -105.019411, + :house-number "2919", + :street "Walker Creek Road", + :city "Douglas", + :state-abbrev "WY", + :zip "82633"} + {:lat 38.5538818, + :lon -78.85508659999999, + :house-number "5132", + :street "Wengers Mill Road", + :city "Linville", + :state-abbrev "VA", + :zip "22834"} + {:lat 39.561936, + :lon -80.161913, + :house-number "228", + :street "Abpp Drive", + :city "Grant Town", + :state-abbrev "WV", + :zip "26574"} + {:lat 47.6208379, + :lon -105.3887932, + :house-number "281-699", + :street "East Duck Creek Road", + :city "Circle", + :state-abbrev "MT", + :zip "59215"} + {:lat 47.3232465, + :lon -119.309409, + :house-number "8001-8783", + :street "Road 16 Northeast", + :city "Moses Lake", + :state-abbrev "WA", + :zip "98837"} + {:lat 41.414166, + :lon -81.9847444, + :house-number "5509-5501,5500-5508", + :street "McKinley Avenue", + :city "North Ridgeville", + :state-abbrev "OH", + :zip "44039"} + {:lat 30.9388436, + :lon -96.07014129999999, + :house-number "9967-10311", + :street "Jinkins Road", + :city "North Zulch", + :state-abbrev "TX", + :zip "77872"} + {:lat 33.4107005, + :lon -93.8872518, + :house-number "261", + :street "Pr 1138", + :city "Texarkana", + :state-abbrev "AR", + :zip "71854"} + {:lat 44.297511, + :lon -95.6824956, + :house-number "3001-3099", + :street "170th Street", + :city "Tracy", + :state-abbrev "MN", + :zip "56175"} + {:lat 29.11341699999999, + :lon -97.021, + :house-number "15024", + :street "Farm to Market 682", + :city "Yoakum", + :state-abbrev "TX", + :zip "77995"} + {:lat 30.3817696, + :lon -91.1954073, + :house-number "2933", + :street "Sarpy Avenue", + :city "Baton Rouge", + :state-abbrev "LA", + :zip "70820"} + {:lat 48.205763, + :lon -94.5749173, + :house-number "22087", + :street "Fishermans Haven Road Northeast", + :city "Waskish", + :state-abbrev "MN", + :zip "56685"} + {:lat 45.4779624, + :lon -92.5522438, + :house-number "1855", + :street "190th Street", + :city "Centuria", + :state-abbrev "WI", + :zip "54824"} + {:lat 42.3285929, + :lon -97.01779409999999, + :house-number "86229-86265", + :street "576th Avenue", + :city "Wayne", + :state-abbrev "NE", + :zip "68787"} + {:lat 38.827956, + :lon -83.75506399999999, + :house-number "6956", + :street "Mount Aire Road", + :city "Russellville", + :state-abbrev "OH", + :zip "45168"} + {:lat 44.728183, + :lon -83.4251333, + :house-number "2491", + :street "Miller Road", + :city "Lincoln", + :state-abbrev "MI", + :zip "48742"} + {:lat 42.7990559, + :lon -82.66714689999999, + :house-number "8840", + :street "Saint Clair Highway", + :city "Casco", + :state-abbrev "MI", + :zip "48064"} + {:lat 35.166327, + :lon -118.477387, + :house-number "19500", + :street "Dovetail Court", + :city "Tehachapi", + :state-abbrev "CA", + :zip "93561"} + {:lat 33.6872507, + :lon -90.2063275, + :house-number "268", + :street "County Road 454", + :city "Greenwood", + :state-abbrev "MS", + :zip "38930"} + {:lat 37.5746079, + :lon -78.47260849999999, + :house-number "1349", + :street "Prison Road", + :city "Dillwyn", + :state-abbrev "VA", + :zip "23936"} + {:lat 32.682146, + :lon -79.891785, + :house-number "1746", + :street "East Ashley Avenue", + :city "Folly Beach", + :state-abbrev "SC", + :zip "29439"} + {:lat 42.6071945, + :lon -105.5102213, + :house-number "820-872", + :street "Poison Lake Road", + :city "Douglas", + :state-abbrev "WY", + :zip "82633"} + {:lat 46.0369948, + :lon -96.75492559999999, + :house-number "17401-17499", + :street "95th Street Southeast", + :city "Fairmount", + :state-abbrev "ND", + :zip "58030"} + {:lat 44.9074009, + :lon -92.65948999999999, + :house-number "314", + :street "Wisconsin 35", + :city "River Falls", + :state-abbrev "WI", + :zip "54022"} + {:lat 40.5479816, + :lon -105.4740886, + :house-number "3331-3589", + :street "Ballard Road", + :city "Bellvue", + :state-abbrev "CO", + :zip "80512"} + {:lat 37.7530823, + :lon -87.9231926, + :house-number "5036", + :street "Airline Road", + :city "Uniontown", + :state-abbrev "KY", + :zip "42461"} + {:lat 39.7320556, + :lon -90.2451991, + :house-number "212", + :street "Park Street", + :city "Jacksonville", + :state-abbrev "IL", + :zip "62650"} + {:lat 35.9255799, + :lon -76.302166, + :house-number "1343", + :street "Davenport Road", + :city "Columbia", + :state-abbrev "NC", + :zip "27925"} + {:lat 36.8939065, + :lon -89.78379319999999, + :house-number "19736-19746", + :street "County Road 581", + :city "Essex", + :state-abbrev "MO", + :zip "63846"} + {:lat 41.8323487, + :lon -91.2776887, + :house-number "790", + :street "Echo Avenue", + :city "Mechanicsville", + :state-abbrev "IA", + :zip "52306"} + {:lat 40.916432, + :lon -86.097224, + :house-number "10360", + :street "North 100 West", + :city "Macy", + :state-abbrev "IN", + :zip "46951"} + {:lat 32.782583, + :lon -85.3869612, + :house-number "4119", + :street "Chambers County 173", + :city "La Fayette", + :state-abbrev "AL", + :zip "36862"} + {:lat 41.1256993, + :lon -82.8288503, + :house-number "6738-6772", + :street "Ohio 162", + :city "Attica", + :state-abbrev "OH", + :zip "44807"} + {:lat 41.31672040000001, + :lon -101.013686, + :house-number "26046", + :street "West North River Road", + :city "Hershey", + :state-abbrev "NE", + :zip "69143"} + {:lat 47.0765723, + :lon -93.5083726, + :house-number "13271", + :street "West Splithand Road", + :city "Grand Rapids", + :state-abbrev "MN", + :zip "55744"} + {:lat 31.2316415, + :lon -84.5972409, + :house-number "735", + :street "Kelley Road", + :city "Colquitt", + :state-abbrev "GA", + :zip "39837"} + {:lat 45.36304, + :lon -84.30846, + :house-number "9011", + :street "Kisser Road", + :city "Onaway", + :state-abbrev "MI", + :zip "49765"} + {:lat 35.822889, + :lon -81.574767, + :house-number "4126", + :street "Smokey Creek Road", + :city "Lenoir", + :state-abbrev "NC", + :zip "28645"} + {:lat 30.3280707, + :lon -96.7380668, + :house-number "9872", + :street "County Road 132", + :city "Somerville", + :state-abbrev "TX", + :zip "77879"} + {:lat 40.9746726, + :lon -72.1968189, + :house-number "154-156", + :street "Cedar Street", + :city "East Hampton", + :state-abbrev "NY", + :zip "11937"} + {:lat 45.82408, + :lon -104.30909, + :house-number "671", + :street "Prairie Dale Road", + :city "Ekalaka", + :state-abbrev "MT", + :zip "59324"} + {:lat 48.88848369999999, + :lon -96.6728171, + :house-number "3399", + :street "360th Avenue", + :city "Lancaster", + :state-abbrev "MN", + :zip "56735"} + {:lat 32.3125762, + :lon -102.3958822, + :house-number "1901-1999", + :street "Telephone Road", + :city "Andrews", + :state-abbrev "TX", + :zip "79714"} + {:lat 41.16777949999999, + :lon -82.43573289999999, + :house-number "1246", + :street "Jarvis Road", + :city "Wakeman", + :state-abbrev "OH", + :zip "44889"} + {:lat 34.8981079, + :lon -114.783128, + :house-number "131764-132898", + :street "Homer-Klinefelter Road", + :city "Needles", + :state-abbrev "CA", + :zip "92363"} + {:lat 41.977433, + :lon -94.06967730000001, + :house-number "496", + :street "260th Street", + :city "Ogden", + :state-abbrev "IA", + :zip "50212"} + {:lat 42.0529562, + :lon -85.51435, + :house-number "22711", + :street "Davis Drive", + :city "Three Rivers", + :state-abbrev "MI", + :zip "49093"} + {:lat 42.1728599, + :lon -89.7458849, + :house-number "17411", + :street "Shannon Route", + :city "Shannon", + :state-abbrev "IL", + :zip "61078"} + {:lat 42.012186, + :lon -79.046403, + :house-number "12759", + :street "Gurnsey Hollow Road", + :city "Frewsburg", + :state-abbrev "NY", + :zip "14738"} + {:lat 31.6307641, + :lon -91.5771343, + :house-number "253", + :street "Par Road 2-75", + :city "Ferriday", + :state-abbrev "LA", + :zip "71334"} + {:lat 37.17645479999999, + :lon -94.0074102, + :house-number "7059", + :street "Highway Bb", + :city "La Russell", + :state-abbrev "MO", + :zip "64848"} + {:lat 36.780462, + :lon -120.065853, + :house-number "15274", + :street "West Shields Avenue", + :city "Kerman", + :state-abbrev "CA", + :zip "93630"} + {:lat 35.34071, + :lon -86.855531, + :house-number "2051", + :street "Edmondson Road", + :city "Cornersville", + :state-abbrev "TN", + :zip "37047"} + {:lat 48.6032364, + :lon -116.9725721, + :house-number "22", + :street "Snick Road", + :city "Priest River", + :state-abbrev "ID", + :zip "83856"} + {:lat 41.5309186, + :lon -78.083709, + :house-number "1073", + :street "Bailey Run Road", + :city "Austin", + :state-abbrev "PA", + :zip "16720"} + {:lat 46.2863851, + :lon -122.2715574, + :house-number "21000", + :street "Spirit Lake Highway", + :city "Toutle", + :state-abbrev "WA", + :zip "98649"} + {:lat 32.376953, + :lon -81.519071, + :house-number "5460", + :street "Old River Road South", + :city "Brooklet", + :state-abbrev "GA", + :zip "30415"} + {:lat 35.5070479, + :lon -89.37359339999999, + :house-number "79", + :street "North Of Highway", + :city "Stanton", + :state-abbrev "TN", + :zip "38069"} + {:lat 46.6409403, + :lon -102.8973443, + :house-number "5331", + :street "116th Avenue Southwest", + :city "New England", + :state-abbrev "ND", + :zip "58647"} + {:lat 39.6049775, + :lon -91.3183488, + :house-number "15584", + :street "Old 79", + :city "New London", + :state-abbrev "MO", + :zip "63459"} + {:lat 41.8804234, + :lon -76.3494723, + :house-number "3044", + :street "Robinson Road", + :city "Ulster", + :state-abbrev "PA", + :zip "18850"} + {:lat 45.193929, + :lon -91.1803149, + :house-number "24722", + :street "240th Avenue", + :city "Cornell", + :state-abbrev "WI", + :zip "54732"} + {:lat 32.8449836, + :lon -81.0811368, + :house-number "365", + :street "Hickory Hill Road", + :city "Varnville", + :state-abbrev "SC", + :zip "29944"} + {:lat 33.1325727, + :lon -89.1536935, + :house-number "219", + :street "Perry Road", + :city "Louisville", + :state-abbrev "MS", + :zip "39339"} + {:lat 37.192428, + :lon -92.53576609999999, + :house-number "3914", + :street "Missouri 5", + :city "Mansfield", + :state-abbrev "MO", + :zip "65704"} + {:lat 37.9059184, + :lon -94.5065307, + :house-number "14b", + :street "County Road H", + :city "Richards", + :state-abbrev "MO", + :zip "64778"} + {:lat 31.3797001, + :lon -110.9571969, + :house-number "2582-2640", + :street "North Al Harrison Road", + :city "Nogales", + :state-abbrev "AZ", + :zip "85621"} + {:lat 42.160778, + :lon -121.885837, + :house-number "11206", + :street "Ruger Road", + :city "Klamath Falls", + :state-abbrev "OR", + :zip "97601"} + {:lat 42.7741203, + :lon -91.2382421, + :house-number "22331", + :street "312th Street", + :city "Garber", + :state-abbrev "IA", + :zip "52048"} + {:lat 40.39649, + :lon -82.979333, + :house-number "4633", + :street "Steamtown Road", + :city "Ashley", + :state-abbrev "OH", + :zip "43003"} + {:lat 44.4337856, + :lon -95.3307342, + :house-number "26000-26670", + :street "County Road 7", + :city "Wabasso", + :state-abbrev "MN", + :zip "56293"} + {:lat 59.75461900000001, + :lon -154.8722999, + :house-number "9998", + :street "Iliamna Village Road", + :city "Iliamna", + :state-abbrev "AK", + :zip "99606"} + {:lat 37.2799922, + :lon -89.8329473, + :house-number "136", + :street "State Highway RA", + :city "Whitewater", + :state-abbrev "MO", + :zip "63785"} + {:lat 30.0020916, + :lon -94.2665475, + :house-number "5831", + :street "Moonglow Drive", + :city "Beaumont", + :state-abbrev "TX", + :zip "77713"} + {:lat 30.2060304, + :lon -99.0853849, + :house-number "1631", + :street "Otto Staudt Road", + :city "Fredericksburg", + :state-abbrev "TX", + :zip "78624"} + {:lat 36.1941456, + :lon -87.98984159999999, + :house-number "561", + :street "Little Sulphur Creek Road", + :city "Big Sandy", + :state-abbrev "TN", + :zip "38221"} + {:lat 41.3768809, + :lon -102.1067028, + :house-number "5275", + :street "Road 203", + :city "Lewellen", + :state-abbrev "NE", + :zip "69147"} + {:lat 55.294047, + :lon -160.6788618, + :house-number "100", + :street "Main Street", + :city "Sand Point", + :state-abbrev "AK", + :zip "99661"} + {:lat 37.8951407, + :lon -89.06417429999999, + :house-number "4500-4758", + :street "East Euclid Street", + :city "Royalton", + :state-abbrev "IL", + :zip "62983"} + {:lat 41.686414, + :lon -88.634023, + :house-number "3832", + :street "West Sandwich Road", + :city "Sandwich", + :state-abbrev "IL", + :zip "60548"} + {:lat 29.2769731, + :lon -95.60418879999999, + :house-number "22828-23086", + :street "County Road 25", + :city "Damon", + :state-abbrev "TX", + :zip "77430"} + {:lat 44.7650241, + :lon -99.0478074, + :house-number "35700-35792", + :street "181st Street", + :city "Orient", + :state-abbrev "SD", + :zip "57467"} + {:lat 38.2021244, + :lon -105.6365082, + :house-number "761", + :street "County Road 191", + :city "Westcliffe", + :state-abbrev "CO", + :zip "81252"} + {:lat 34.7160477, + :lon -84.6346884, + :house-number "2641", + :street "Tails Creek Church Road", + :city "Ellijay", + :state-abbrev "GA", + :zip "30540"} + {:lat 43.6342315, + :lon -92.93457219999999, + :house-number "19443", + :street "560th Avenue", + :city "Austin", + :state-abbrev "MN", + :zip "55912"} + {:lat 33.0768658, + :lon -98.6260044, + :house-number "322", + :street "Medlan Chapel Road", + :city "Graham", + :state-abbrev "TX", + :zip "76450"} + {:lat 44.6974348, + :lon -109.6102857, + :house-number "561", + :street "Sunlight Road", + :city "Cody", + :state-abbrev "WY", + :zip "82414"} + {:lat 44.0728434, + :lon -95.7249308, + :house-number "1668-1698", + :street "156th Street", + :city "Slayton", + :state-abbrev "MN", + :zip "56172"} + {:lat 43.5026735, + :lon -84.5066867, + :house-number "3620", + :street "South Magrudder Road", + :city "St. Louis", + :state-abbrev "MI", + :zip "48880"} + {:lat 41.778891, + :lon -71.844071, + :house-number "191", + :street "Snake Meadow Road", + :city "Killingly", + :state-abbrev "CT", + :zip "06239"} + {:lat 45.42324980000001, + :lon -102.9816937, + :house-number "13627", + :street "Rabbit Creek Place", + :city "Reva", + :state-abbrev "SD", + :zip "57651"} + {:lat 41.6072879, + :lon -91.44762399999999, + :house-number "4960", + :street "U.S. 6", + :city "Iowa City", + :state-abbrev "IA", + :zip "52240"} + {:lat 43.8482247, + :lon -95.9080164, + :house-number "17229", + :street "1st Street", + :city "Chandler", + :state-abbrev "MN", + :zip "56122"} + {:lat 66.37241159999999, + :lon -150.4916213, + :house-number "15", + :street "North Slope Haul Road", + :city "Fairbanks", + :state-abbrev "AK", + :zip "99701"} + {:lat 40.47705, + :lon -82.26044399999999, + :house-number "17403", + :street "Kaylor Road", + :city "Danville", + :state-abbrev "OH", + :zip "43014"} + {:lat 43.339598, + :lon -82.67549059999999, + :house-number "3507-3745", + :street "Marlette Road", + :city "Croswell", + :state-abbrev "MI", + :zip "48422"} + {:lat 45.9052449, + :lon -96.0412765, + :house-number "23248", + :street "County Highway 8", + :city "Elbow Lake", + :state-abbrev "MN", + :zip "56531"} + {:lat 30.7336771, + :lon -94.9438504, + :house-number "100", + :street "Sunshine Lane", + :city "Livingston", + :state-abbrev "TX", + :zip "77351"} + {:lat 34.070783, + :lon -79.210173, + :house-number "17791", + :street "Pee Dee Road North", + :city "Galivants Ferry", + :state-abbrev "SC", + :zip "29544"} + {:lat 37.530722, + :lon -84.34361249999999, + :house-number "1", + :street "Old Garrard Road", + :city "Berea", + :state-abbrev "KY", + :zip "40403"} + {:lat 45.0499174, + :lon -91.9176634, + :house-number "N10285", + :street "490th Street", + :city "Wheeler", + :state-abbrev "WI", + :zip "54772"} + {:lat 32.5545752, + :lon -110.9335244, + :house-number "35498", + :street "Arizona 77", + :city "Tucson", + :state-abbrev "AZ", + :zip "85739"} + {:lat 29.231494, + :lon -98.58674599999999, + :house-number "19333", + :street "Texas 16", + :city "Von Ormy", + :state-abbrev "TX", + :zip "78073"} + {:lat 31.7874786, + :lon -88.7970596, + :house-number "38", + :street "Gene Roberts Drive", + :city "Shubuta", + :state-abbrev "MS", + :zip "39360"} + {:lat 62.1421096, + :lon -149.9113851, + :house-number "37555", + :street "South Kaliak", + :city "Talkeetna", + :state-abbrev "AK", + :zip "99676"} + {:lat 40.232191, + :lon -83.1745059, + :house-number "14850", + :street "Smart-Cole Road", + :city "Ostrander", + :state-abbrev "OH", + :zip "43061"} + {:lat 34.716032, + :lon -82.469116, + :house-number "805", + :street "River Road", + :city "Piedmont", + :state-abbrev "SC", + :zip "29673"} + {:lat 43.6307309, + :lon -70.8311294, + :house-number "111", + :street "Poverty Pond Road", + :city "Shapleigh", + :state-abbrev "ME", + :zip "04076"} + {:lat 41.7371709, + :lon -75.31117100000002, + :house-number "1711", + :street "Great Bend Turnpike", + :city "Pleasant Mount", + :state-abbrev "PA", + :zip "18453"} + {:lat 37.01058099999999, + :lon -79.571281, + :house-number "1861", + :street "Jasmine Road", + :city "Sandy Level", + :state-abbrev "VA", + :zip "24161"} + {:lat 39.6546536, + :lon -75.89517219999999, + :house-number "361", + :street "Leeds Road", + :city "Elkton", + :state-abbrev "MD", + :zip "21921"} + {:lat 42.0185478, + :lon -85.5427941, + :house-number "21250", + :street "Dentler Drive", + :city "Three Rivers", + :state-abbrev "MI", + :zip "49093"} + {:lat 44.0018723, + :lon -96.04839899999999, + :house-number "1045", + :street "10th Avenue", + :city "Woodstock", + :state-abbrev "MN", + :zip "56186"} + {:lat 38.390851, + :lon -90.4221929, + :house-number "2656", + :street "Fox Run", + :city "Imperial", + :state-abbrev "MO", + :zip "63052"} + {:lat 34.442297, + :lon -80.412144, + :house-number "3338", + :street "McCaskill Road", + :city "Bethune", + :state-abbrev "SC", + :zip "29009"} + {:lat 37.63756310000001, + :lon -86.3101248, + :house-number "3839", + :street "Mook-Centerview Road", + :city "Hudson", + :state-abbrev "KY", + :zip "40145"} + {:lat 44.8002186, + :lon -91.1806963, + :house-number "17210", + :street "Scenic Drive", + :city "Fall Creek", + :state-abbrev "WI", + :zip "54742"} + {:lat 39.6528941, + :lon -83.3396202, + :house-number "7470", + :street "Railroad Street Northeast", + :city "Mount Sterling", + :state-abbrev "OH", + :zip "43143"} + {:lat 38.36489090000001, + :lon -98.67333769999999, + :house-number "550B", + :street "U.S. 56", + :city "Great Bend", + :state-abbrev "KS", + :zip "67530"} + {:lat 45.75853559999999, + :lon -94.4943314, + :house-number "18633", + :street "440th Street", + :city "Holdingford", + :state-abbrev "MN", + :zip "56340"} + {:lat 32.173593, + :lon -98.95164749999999, + :house-number "402", + :street "County Road 292", + :city "Rising Star", + :state-abbrev "TX", + :zip "76471"} + {:lat 42.182373, + :lon -75.84169299999999, + :house-number "37", + :street "Country Knoll Drive", + :city "Binghamton", + :state-abbrev "NY", + :zip "13901"} + {:lat 34.4361918, + :lon -83.2612249, + :house-number "15285", + :street "Georgia 106", + :city "Carnesville", + :state-abbrev "GA", + :zip "30521"} + {:lat 43.8940625, + :lon -103.1231529, + :house-number "24217", + :street "Alihon Lane", + :city "Hermosa", + :state-abbrev "SD", + :zip "57744"} + {:lat 40.7652004, + :lon -86.8898238, + :house-number "488-998", + :street "West 100 North", + :city "Reynolds", + :state-abbrev "IN", + :zip "47980"} + {:lat 36.7088456, + :lon -79.67940689999999, + :house-number "463", + :street "Nowhere Road", + :city "Axton", + :state-abbrev "VA", + :zip "24054"} + {:lat 39.2996331, + :lon -89.8230667, + :house-number "20684", + :street "Claremont Road", + :city "Carlinville", + :state-abbrev "IL", + :zip "62626"} + {:lat 41.4699166, + :lon -96.3151407, + :house-number "6453", + :street "County Road 15", + :city "Arlington", + :state-abbrev "NE", + :zip "68002"} + {:lat 42.8194108, + :lon -74.4285355, + :house-number "1582", + :street "New York 162", + :city "Sprakers", + :state-abbrev "NY", + :zip "12166"} + {:lat 44.0269598, + :lon -104.7325072, + :house-number "818", + :street "State Highway 116 South", + :city "Upton", + :state-abbrev "WY", + :zip "82730"} + {:lat 33.4951893, + :lon -93.9307702, + :house-number "9655", + :street "Old Post Road", + :city "Texarkana", + :state-abbrev "AR", + :zip "71854"} + {:lat 43.3445718, + :lon -82.8238781, + :house-number "208", + :street "Morris Road", + :city "Sandusky", + :state-abbrev "MI", + :zip "48471"} + {:lat 40.0203256, + :lon -97.8166682, + :house-number "224", + :street "Road 4900", + :city "Hardy", + :state-abbrev "NE", + :zip "68943"} + {:lat 35.3401668, + :lon -85.1263524, + :house-number "202", + :street "McCallie Ferry Road", + :city "Soddy-Daisy", + :state-abbrev "TN", + :zip "37379"} + {:lat 47.4284009, + :lon -97.1742982, + :house-number "80", + :street "158th Avenue Northeast", + :city "Hillsboro", + :state-abbrev "ND", + :zip "58045"} + {:lat 42.38416, + :lon -123.078391, + :house-number "2585", + :street "Galls Creek Road", + :city "Gold Hill", + :state-abbrev "OR", + :zip "97525"} + {:lat 38.716067, + :lon -83.340987, + :house-number "345", + :street "Johnson Run Road", + :city "Blue Creek", + :state-abbrev "OH", + :zip "45616"} + {:lat 31.049082, + :lon -87.716759, + :house-number "56503", + :street "Paul Road", + :city "Perdido", + :state-abbrev "AL", + :zip "36562"} + {:lat 40.511278, + :lon -89.624106, + :house-number "15874", + :street "Red Shale Hill Road", + :city "Pekin", + :state-abbrev "IL", + :zip "61554"} + {:lat 34.5311922, + :lon -102.5352594, + :house-number "2901-2999", + :street "Texas 86", + :city "Friona", + :state-abbrev "TX", + :zip "79035"} + {:lat 34.0789972, + :lon -87.161383, + :house-number "85", + :street "June Lane", + :city "Arley", + :state-abbrev "AL", + :zip "35541"} + {:lat 37.345752, + :lon -112.624213, + :house-number "1100", + :street "Lydia's Canyon Road", + :city "Glendale", + :state-abbrev "UT", + :zip "84729"} + {:lat 38.1811093, + :lon -96.713141, + :house-number "758", + :street "H Road", + :city "Cedar Point", + :state-abbrev "KS", + :zip "66843"} + {:lat 39.8391125, + :lon -95.5968961, + :house-number "2278", + :street "Horned Owl Road", + :city "Hiawatha", + :state-abbrev "KS", + :zip "66434"} + {:lat 47.8330014, + :lon -100.3183434, + :house-number "1220-1298", + :street "29th Street Northeast", + :city "Anamoose", + :state-abbrev "ND", + :zip "58710"} + {:lat 44.93645, + :lon -88.1927369, + :house-number "8669", + :street "Valley Line Road", + :city "Oconto Falls", + :state-abbrev "WI", + :zip "54154"} + {:lat 47.853634, + :lon -121.500764, + :house-number "15819", + :street "Index-Galena Road", + :city "Sultan", + :state-abbrev "WA", + :zip "98294"} + {:lat 39.573651, + :lon -76.264066, + :house-number "320", + :street "Priestford Road", + :city "Churchville", + :state-abbrev "MD", + :zip "21028"} + {:lat 35.074197, + :lon -81.86616099999999, + :house-number "170", + :street "State Road S-42-1958", + :city "Chesnee", + :state-abbrev "SC", + :zip "29323"} + {:lat 42.3412681, + :lon -93.0848007, + :house-number "24590-24998", + :street "V Avenue", + :city "Eldora", + :state-abbrev "IA", + :zip "50627"} + {:lat 37.4311908, + :lon -103.4459611, + :house-number "32997", + :street "County Road 193.5", + :city "Kim", + :state-abbrev "CO", + :zip "81049"} + {:lat 35.689965, + :lon -83.922624, + :house-number "621", + :street "Butler Mill Road", + :city "Maryville", + :state-abbrev "TN", + :zip "37803"} + {:lat 41.7821158, + :lon -92.6880092, + :house-number "3551", + :street "50th Street", + :city "Grinnell", + :state-abbrev "IA", + :zip "50112"} + {:lat 43.4605346, + :lon -85.2398777, + :house-number "7800", + :street "Schmeid Road", + :city "Lakeview", + :state-abbrev "MI", + :zip "48850"} + {:lat 40.1309832, + :lon -110.6794079, + :house-number "36449", + :street "Strawberry River Road", + :city "Duchesne", + :state-abbrev "UT", + :zip "84021"} + {:lat 47.9993618, + :lon -102.9765214, + :house-number "11168", + :street "41st Street Northwest", + :city "Keene", + :state-abbrev "ND", + :zip "58847"} + {:lat 42.90755799999999, + :lon -89.06496299999999, + :house-number "676", + :street "Craig Road", + :city "Edgerton", + :state-abbrev "WI", + :zip "53534"} + {:lat 35.4347041, + :lon -102.2610425, + :house-number "101", + :street "U.S. 385", + :city "Boys Ranch", + :state-abbrev "TX", + :zip "79010"} + {:lat 37.036569, + :lon -119.428604, + :house-number "35213", + :street "Oak Springs Road", + :city "Tollhouse", + :state-abbrev "CA", + :zip "93667"} + {:lat 31.5683718, + :lon -98.7032416, + :house-number "321", + :street "County Road 549", + :city "Mullin", + :state-abbrev "TX", + :zip "76864"} + {:lat 40.2804507, + :lon -87.40699389999999, + :house-number "4500-4758", + :street "State Road 28", + :city "Williamsport", + :state-abbrev "IN", + :zip "47993"} + {:lat 35.926761, + :lon -82.64889210000001, + :house-number "741", + :street "Lewis Branch Road", + :city "Marshall", + :state-abbrev "NC", + :zip "28753"} + {:lat 61.52915400000001, + :lon -149.457747, + :house-number "4501", + :street "Well Site Road", + :city "Wasilla", + :state-abbrev "AK", + :zip "99654"} + {:lat 42.8486932, + :lon -92.8694371, + :house-number "13469-13599", + :street "Ivy Avenue", + :city "Greene", + :state-abbrev "IA", + :zip "50636"} + {:lat 43.8968648, + :lon -101.7609158, + :house-number "24206", + :street "Fairview Road", + :city "Philip", + :state-abbrev "SD", + :zip "57567"} + {:lat 35.525398, + :lon -119.463896, + :house-number "26323", + :street "Merced Avenue", + :city "Wasco", + :state-abbrev "CA", + :zip "93280"} + {:lat 43.8726352, + :lon -70.2875686, + :house-number "256", + :street "Yarmouth Road", + :city "Gray", + :state-abbrev "ME", + :zip "04039"} + {:lat 30.098465, + :lon -95.6346049, + :house-number "1720", + :street "Hicks Street", + :city "Tomball", + :state-abbrev "TX", + :zip "77375"} + {:lat 62.36383000000001, + :lon -150.3484531, + :house-number "5766", + :street "East Whispering Woods Avenue", + :city "Trapper Creek", + :state-abbrev "AK", + :zip "99688"} + {:lat 40.7840789, + :lon -79.7983422, + :house-number "101-199", + :street "Durango Lane", + :city "Cabot", + :state-abbrev "PA", + :zip "16023"} + {:lat 30.7506, + :lon -83.232, + :house-number "3680", + :street "Carroll Ulmer Road", + :city "Valdosta", + :state-abbrev "GA", + :zip "31601"} + {:lat 42.937593, + :lon -93.6410973, + :house-number "1747-1799", + :street "120th Street", + :city "Goodell", + :state-abbrev "IA", + :zip "50439"} + {:lat 43.1187216, + :lon -94.9136304, + :house-number "3600-3698", + :street "340th Avenue", + :city "Ruthven", + :state-abbrev "IA", + :zip "51358"} + {:lat 34.3883628, + :lon -89.4442464, + :house-number "369", + :street "Mississippi 30", + :city "Oxford", + :state-abbrev "MS", + :zip "38655"} + {:lat 48.8050359, + :lon -97.20627449999999, + :house-number "9601-9697", + :street "160th Avenue Northeast", + :city "Pembina", + :state-abbrev "ND", + :zip "58271"} + {:lat 41.3713447, + :lon -74.508512, + :house-number "180", + :street "County Road 22", + :city "Slate Hill", + :state-abbrev "NY", + :zip "10973"} + {:lat 32.3737973, + :lon -85.0684674, + :house-number "43", + :street "Downing Drive", + :city "Phenix City", + :state-abbrev "AL", + :zip "36869"} + {:lat 37.7498548, + :lon -78.25526839999999, + :house-number "179", + :street "Cloverdale Road", + :city "Bremo Bluff", + :state-abbrev "VA", + :zip "23022"} + {:lat 41.5099562, + :lon -92.4001047, + :house-number "1961", + :street "540th Avenue", + :city "Gibson", + :state-abbrev "IA", + :zip "50104"} + {:lat 44.3993806, + :lon -103.7008776, + :house-number "20716", + :street "Whitewood Creek Road", + :city "Sturgis", + :state-abbrev "SD", + :zip "57785"} + {:lat 36.4429932, + :lon -81.6218153, + :house-number "12996", + :street "Highway 88", + :city "Creston", + :state-abbrev "NC", + :zip "28615"} + {:lat 40.9406, + :lon -77.4245418, + :house-number "228", + :street "Back Road", + :city "Rebersburg", + :state-abbrev "PA", + :zip "16872"} + {:lat 33.7877413, + :lon -88.22801779999999, + :house-number "6350", + :street "County Lake Road", + :city "Sulligent", + :state-abbrev "AL", + :zip "35586"} + {:lat 55.294047, + :lon -160.6788618, + :house-number "100", + :street "Main Street", + :city "Sand Point", + :state-abbrev "AK", + :zip "99661"} + {:lat 43.4903212, + :lon -97.74377899999999, + :house-number "42264", + :street "269th Street", + :city "Alexandria", + :state-abbrev "SD", + :zip "57311"} + {:lat 38.731919, + :lon -75.501182, + :house-number "14329", + :street "Road 592", + :city "Bridgeville", + :state-abbrev "DE", + :zip "19933"} + {:lat 42.6917144, + :lon -100.7440356, + :house-number "5-31", + :street "290th Avenue", + :city "Valentine", + :state-abbrev "NE", + :zip "69201"} + {:lat 41.0493214, + :lon -81.5361166, + :house-number "101", + :street "West Emerling Avenue", + :city "Akron", + :state-abbrev "OH", + :zip "44301"} + {:lat 40.7486852, + :lon -106.6111981, + :house-number "4998", + :street "County Road 16", + :city "Walden", + :state-abbrev "CO", + :zip "80480"} + {:lat 38.65403999999999, + :lon -89.949741, + :house-number "520", + :street "Willow Bend Lane", + :city "O'Fallon", + :state-abbrev "IL", + :zip "62269"} + {:lat 48.169711, + :lon -96.74520530000001, + :house-number "32548", + :street "200th Street Northwest", + :city "Warren", + :state-abbrev "MN", + :zip "56762"} + {:lat 33.5299639, + :lon -104.8391361, + :house-number "292-434", + :street "Draper Road", + :city "Roswell", + :state-abbrev "NM", + :zip "88201"} + {:lat 40.2052092, + :lon -102.7168116, + :house-number "5001-5999", + :street "County Road 44", + :city "Yuma", + :state-abbrev "CO", + :zip "80759"} + {:lat 39.8454451, + :lon -75.2502988, + :house-number "825", + :street "Clonmell Road", + :city "Paulsboro", + :state-abbrev "NJ", + :zip "08066"} + {:lat 37.6251974, + :lon -103.3524315, + :house-number "43200", + :street "Colorado 109", + :city "Kim", + :state-abbrev "CO", + :zip "81049"} + {:lat 41.6501178, + :lon -90.1317763, + :house-number "4100-4116", + :street "Sand Road", + :city "Erie", + :state-abbrev "IL", + :zip "61250"} + {:lat 40.2049537, + :lon -80.39586109999999, + :house-number "1767", + :street "Brush Run Road", + :city "Avella", + :state-abbrev "PA", + :zip "15312"} + {:lat 40.390835, + :lon -87.9144398, + :house-number "36376", + :street "North 170 East Road", + :city "Rankin", + :state-abbrev "IL", + :zip "60960"} + {:lat 34.865119, + :lon -120.296285, + :house-number "4989", + :street "Foxen Canyon Road", + :city "Santa Maria", + :state-abbrev "CA", + :zip "93454"} + {:lat 36.1709076, + :lon -83.715249, + :house-number "285", + :street "Emory Road", + :city "Blaine", + :state-abbrev "TN", + :zip "37709"} + {:lat 32.5653807, + :lon -100.8001906, + :house-number "11498", + :street "County Road 4167", + :city "Hermleigh", + :state-abbrev "TX", + :zip "79526"} + {:lat 46.3313462, + :lon -90.4063331, + :house-number "9668-9674", + :street "Pleasant Lake Road", + :city "Upson", + :state-abbrev "WI", + :zip "54565"} + {:lat 64.846943, + :lon -148.2255821, + :house-number "5674", + :street "Old Ridge Trail", + :city "Fairbanks", + :state-abbrev "AK", + :zip "99709"} + {:lat 37.9969555, + :lon -122.9928238, + :house-number "27107-27539", + :street "Chimney Rock Road", + :city "Inverness", + :state-abbrev "CA", + :zip "94937"} + {:lat 40.2127077, + :lon -95.8461579, + :house-number "71669", + :street "639 Avenue", + :city "Humboldt", + :state-abbrev "NE", + :zip "68376"} + {:lat 46.4302493, + :lon -101.1323048, + :house-number "6746-6842", + :street "County Road 83", + :city "Flasher", + :state-abbrev "ND", + :zip "58535"} + {:lat 37.329562, + :lon -79.731115, + :house-number "1229", + :street "Shadow Mountain Lane", + :city "Blue Ridge", + :state-abbrev "VA", + :zip "24064"} + {:lat 33.0356797, + :lon -85.5411367, + :house-number "6203-6299", + :street "Chambers County 053", + :city "Wadley", + :state-abbrev "AL", + :zip "36276"} + {:lat 38.4266745, + :lon -121.652151, + :house-number "R3E", + :street "Camino Court", + :city "Dixon", + :state-abbrev "CA", + :zip "95620"} + {:lat 32.4891393, + :lon -82.79339829999999, + :house-number "730-800", + :street "Jw Warren Road", + :city "East Dublin", + :state-abbrev "GA", + :zip "31027"} + {:lat 41.3328225, + :lon -86.2217687, + :house-number "9701-10035", + :street "South Iris Road", + :city "Plymouth", + :state-abbrev "IN", + :zip "46563"} + {:lat 30.0216907, + :lon -90.186703, + :house-number "4501-4517", + :street "Alphonse Drive", + :city "Metairie", + :state-abbrev "LA", + :zip "70006"} + {:lat 33.737864, + :lon -95.5020739, + :house-number "3104", + :street "County Road 43270", + :city "Powderly", + :state-abbrev "TX", + :zip "75473"} + {:lat 33.474077, + :lon -83.04224800000001, + :house-number "4511", + :street "White Plains Road", + :city "White Plains", + :state-abbrev "GA", + :zip "30678"} + {:lat 42.9046499, + :lon -75.78098279999999, + :house-number "3701-3797", + :street "Erieville Road", + :city "Cazenovia", + :state-abbrev "NY", + :zip "13035"} + {:lat 42.0515048, + :lon -78.2052235, + :house-number "8299", + :street "Green Road", + :city "Bolivar", + :state-abbrev "NY", + :zip "14715"} + {:lat 36.4508851, + :lon -85.1597474, + :house-number "188", + :street "Victor Padgett", + :city "Monroe", + :state-abbrev "TN", + :zip "38573"} + {:lat 41.9092788, + :lon -89.05070889999999, + :house-number "1001", + :street "South Main Street", + :city "Rochelle", + :state-abbrev "IL", + :zip "61068"} + {:lat 34.89559560000001, + :lon -94.9734872, + :house-number "40523", + :street "Line Street", + :city "Le Flore", + :state-abbrev "OK", + :zip "74942"} + {:lat 47.366296, + :lon -108.518454, + :house-number "15182", + :street "Valentine Road", + :city "Roy", + :state-abbrev "MT", + :zip "59471"} + {:lat 43.0282796, + :lon -72.5981097, + :house-number "2-298", + :street "Windmill Hill Trail", + :city "Brookline", + :state-abbrev "VT", + :zip "05345"} + {:lat 42.7842145, + :lon -85.2515325, + :house-number "13981", + :street "Perry Road", + :city "Lake Odessa", + :state-abbrev "MI", + :zip "48849"} + {:lat 28.5522135, + :lon -81.35147409999999, + :house-number "2400-2418", + :street "East Colonial Drive", + :city "Orlando", + :state-abbrev "FL", + :zip "32803"} + {:lat 43.49627599999999, + :lon -84.054371, + :house-number "7130", + :street "Kochville Road", + :city "Freeland", + :state-abbrev "MI", + :zip "48623"} + {:lat 44.7162483, + :lon -108.1862576, + :house-number "2120", + :street "Ln 16 1/2", + :city "Lovell", + :state-abbrev "WY", + :zip "82431"} + {:lat 32.2029844, + :lon -89.7955089, + :house-number "177-183", + :street "Antioch-Shiloh Road", + :city "Pelahatchie", + :state-abbrev "MS", + :zip "39145"} + {:lat 43.7774483, + :lon -89.4570366, + :house-number "3942", + :street "8th Drive", + :city "Montello", + :state-abbrev "WI", + :zip "53949"} + {:lat 46.6066045, + :lon -109.5645057, + :house-number "1102-1162", + :street "Judith Gap Road", + :city "Judith Gap", + :state-abbrev "MT", + :zip "59453"} + {:lat 32.8635063, + :lon -84.8623672, + :house-number "629", + :street "Chipley Street", + :city "Pine Mountain", + :state-abbrev "GA", + :zip "31822"} + {:lat 30.1279774, + :lon -98.483712, + :house-number "2631", + :street "North US Highway 281", + :city "Blanco", + :state-abbrev "TX", + :zip "78606"} + {:lat 37.5581635, + :lon -89.535821, + :house-number "5485", + :street "County Road 535", + :city "Jackson", + :state-abbrev "MO", + :zip "63755"} + {:lat 35.9969869, + :lon -81.6819901, + :house-number "5895", + :street "North Carolina 90", + :city "Collettsville", + :state-abbrev "NC", + :zip "28611"} + {:lat 40.7380401, + :lon -83.28694360000001, + :house-number "8874-9380", + :street "Ohio 294", + :city "Harpster", + :state-abbrev "OH", + :zip "43323"} + {:lat 36.184963, + :lon -121.197168, + :house-number "43521", + :street "VÃa Canada", + :city "King City", + :state-abbrev "CA", + :zip "93930"} + {:lat 45.3941852, + :lon -90.93547889999999, + :house-number "2811", + :street "Martin Road", + :city "Sheldon", + :state-abbrev "WI", + :zip "54766"} + {:lat 41.0167821, + :lon -97.8283055, + :house-number "2406", + :street "East 23 Road", + :city "Hampton", + :state-abbrev "NE", + :zip "68843"} + {:lat 36.2943794, + :lon -84.09738039999999, + :house-number "301", + :street "Fox Lake Lane", + :city "LaFollette", + :state-abbrev "TN", + :zip "37766"} + {:lat 47.55104499999999, + :lon -106.080787, + :house-number "2913", + :street "Horse Creek Road", + :city "Circle", + :state-abbrev "MT", + :zip "59215"} + {:lat 39.268984, + :lon -77.19474199999999, + :house-number "9505", + :street "Meadow Ridge Lane", + :city "Laytonsville", + :state-abbrev "MD", + :zip "20882"} + {:lat 33.6957652, + :lon -93.00202499999999, + :house-number "274", + :street "Knight Road", + :city "Chidester", + :state-abbrev "AR", + :zip "71726"} + {:lat 32.8553266, + :lon -88.96096179999999, + :house-number "10041", + :street "Road 785", + :city "Philadelphia", + :state-abbrev "MS", + :zip "39350"} + {:lat 36.7202659, + :lon -78.5598595, + :house-number "971", + :street "Clay Road", + :city "Skipwith", + :state-abbrev "VA", + :zip "23968"} + {:lat 34.78028, + :lon -77.626577, + :house-number "112", + :street "Batchelor Road", + :city "Richlands", + :state-abbrev "NC", + :zip "28574"} + {:lat 36.3934825, + :lon -106.4900081, + :house-number "23196", + :street "U.S. 84", + :city "Abiquiu", + :state-abbrev "NM", + :zip "87510"} + {:lat 42.8717651, + :lon -76.63044459999999, + :house-number "5115", + :street "Ridge Road", + :city "Union Springs", + :state-abbrev "NY", + :zip "13160"} + {:lat 46.798863, + :lon -112.179823, + :house-number "8717", + :street "Chevallier Drive", + :city "Helena", + :state-abbrev "MT", + :zip "59602"} + {:lat 40.842401, + :lon -85.727317, + :house-number "810", + :street "Tipton Street", + :city "Lagro", + :state-abbrev "IN", + :zip "46941"} + {:lat 47.4690299, + :lon -111.448239, + :house-number "251", + :street "Polish Road", + :city "Great Falls", + :state-abbrev "MT", + :zip "59404"} + {:lat 30.326049, + :lon -92.203801, + :house-number "1899", + :street "Higginbotham Highway", + :city "Church Point", + :state-abbrev "LA", + :zip "70525"} + {:lat 40.18612299999999, + :lon -79.67120899999999, + :house-number "221", + :street "Waltz Mill Road", + :city "Ruffs Dale", + :state-abbrev "PA", + :zip "15679"} + {:lat 44.39979109999999, + :lon -68.8100769, + :house-number "38", + :street "Back Shore Road", + :city "Castine", + :state-abbrev "ME", + :zip "04421"} + {:lat 38.6913194, + :lon -86.4017087, + :house-number "27", + :street "Noe's Chicken House Road", + :city "Orleans", + :state-abbrev "IN", + :zip "47452"} + {:lat 29.43706, + :lon -82.813959, + :house-number "7231", + :street "Northwest 30th Street", + :city "Chiefland", + :state-abbrev "FL", + :zip "32626"} + {:lat 48.6167295, + :lon -99.1286372, + :house-number "7059-7099", + :street "83rd Street Northeast", + :city "Egeland", + :state-abbrev "ND", + :zip "58331"} + {:lat 34.1911408, + :lon -89.1202776, + :house-number "2425", + :street "Oak Forest Road", + :city "Pontotoc", + :state-abbrev "MS", + :zip "38863"} + {:lat 54.8488896, + :lon -163.4073178, + :house-number "180", + :street "Unimak Drive", + :city "False Pass", + :state-abbrev "AK", + :zip "99583"} + {:lat 61.2668195, + :lon -145.2833742, + :house-number "95", + :street "Alaska 4", + :city "Copper Center", + :state-abbrev "AK", + :zip "99573"} + {:lat 39.16605149999999, + :lon -123.3810453, + :house-number "11001", + :street "Low Gap Road", + :city "Ukiah", + :state-abbrev "CA", + :zip "95482"} + {:lat 42.2818569, + :lon -93.7430547, + :house-number "2100-2174", + :street "350th Street", + :city "Stanhope", + :state-abbrev "IA", + :zip "50246"} + {:lat 40.1801095, + :lon -98.23249589999999, + :house-number "2730", + :street "Road North", + :city "Lawrence", + :state-abbrev "NE", + :zip "68957"} + {:lat 44.104079, + :lon -111.4923297, + :house-number "3321", + :street "Pleasant Hill View", + :city "Ashton", + :state-abbrev "ID", + :zip "83420"} + {:lat 38.5209723, + :lon -106.6766573, + :house-number "2594-3538", + :street "County Road 76", + :city "Parlin", + :state-abbrev "CO", + :zip "81239"} + {:lat 34.25811789999999, + :lon -84.42085209999999, + :house-number "5683", + :street "Jay Green Road", + :city "Canton", + :state-abbrev "GA", + :zip "30114"} + {:lat 44.895405, + :lon -85.01131230000001, + :house-number "4571-4599", + :street "Musser Road", + :city "Mancelona", + :state-abbrev "MI", + :zip "49659"} + {:lat 34.8236058, + :lon -91.8405691, + :house-number "184", + :street "Lilly Lane", + :city "Lonoke", + :state-abbrev "AR", + :zip "72086"} + {:lat 28.6705901, + :lon -82.61898, + :house-number "9234-9256", + :street "Zebrafinch Avenue", + :city "Brooksville", + :state-abbrev "FL", + :zip "34614"} + {:lat 43.6585004, + :lon -98.13524149999999, + :house-number "40300-40398", + :street "257th Street", + :city "Mitchell", + :state-abbrev "SD", + :zip "57301"} + {:lat 41.711713, + :lon -79.9304678, + :house-number "31002", + :street "Johnson Road", + :city "Townville", + :state-abbrev "PA", + :zip "16360"} + {:lat 46.5652693, + :lon -96.1857107, + :house-number "14787", + :street "Minnesota 108", + :city "Pelican Rapids", + :state-abbrev "MN", + :zip "56572"} + {:lat 62.1402087, + :lon -149.9117934, + :house-number "37661", + :street "South Kaliak", + :city "Talkeetna", + :state-abbrev "AK", + :zip "99676"} + {:lat 41.4293833, + :lon -91.1865965, + :house-number "1901-1973", + :street "215th Street", + :city "Muscatine", + :state-abbrev "IA", + :zip "52761"} + {:lat 35.411868, + :lon -79.52678, + :house-number "1499", + :street "Plank Road", + :city "Robbins", + :state-abbrev "NC", + :zip "27325"} + {:lat 35.7831615, + :lon -91.6198103, + :house-number "225", + :street "Miller Creek Road", + :city "Batesville", + :state-abbrev "AR", + :zip "72501"} + {:lat 42.1059992, + :lon -71.71592009999999, + :house-number "171", + :street "Jones Road", + :city "Sutton", + :state-abbrev "MA", + :zip "01590"} + {:lat 38.643258, + :lon -76.558375, + :house-number "2540", + :street "Sharon Court", + :city "Sunderland", + :state-abbrev "MD", + :zip "20689"} + {:lat 42.607759, + :lon -76.354182, + :house-number "370", + :street "Clark Street Extended", + :city "Groton", + :state-abbrev "NY", + :zip "13073"} + {:lat 43.4861087, + :lon -96.9791156, + :house-number "46115", + :street "269th Street", + :city "Chancellor", + :state-abbrev "SD", + :zip "57015"} + {:lat 29.8558423, + :lon -94.097933, + :house-number "7665", + :street "Texas 73", + :city "Beaumont", + :state-abbrev "TX", + :zip "77705"} + {:lat 66.92456100000001, + :lon -151.505773, + :house-number "101", + :street "South Hickel Highway", + :city "Bettles", + :state-abbrev "AK", + :zip "99726"} + {:lat 29.9929309, + :lon -93.1543835, + :house-number "205", + :street "Hebert Trailer Park Road", + :city "Lake Charles", + :state-abbrev "LA", + :zip "70607"} + {:lat 33.601174, + :lon -81.15536999999999, + :house-number "6192", + :street "South Carolina 394", + :city "North", + :state-abbrev "SC", + :zip "29112"} + {:lat 34.960715, + :lon -78.781319, + :house-number "4850", + :street "Gainey Road", + :city "Fayetteville", + :state-abbrev "NC", + :zip "28306"} + {:lat 40.758517, + :lon -124.049391, + :house-number "2927", + :street "Freshwater Road", + :city "Eureka", + :state-abbrev "CA", + :zip "95503"} + {:lat 37.288416, + :lon -108.003251, + :house-number "493", + :street "Perins Peak Lane", + :city "Durango", + :state-abbrev "CO", + :zip "81301"} + {:lat 34.212497, + :lon -84.606105, + :house-number "576", + :street "Fincher Road", + :city "Canton", + :state-abbrev "GA", + :zip "30114"} + {:lat 43.4571255, + :lon -105.4554265, + :house-number "1245", + :street "Jenne Trail Road", + :city "Douglas", + :state-abbrev "WY", + :zip "82633"} + {:lat 46.1527482, + :lon -84.1175427, + :house-number "11443", + :street "East Gogomain Road", + :city "Goetzville", + :state-abbrev "MI", + :zip "49736"} + {:lat 38.2322784, + :lon -89.6670206, + :house-number "745-813", + :street "County Highway 12", + :city "Coulterville", + :state-abbrev "IL", + :zip "62237"} + {:lat 29.7620941, + :lon -98.5691174, + :house-number "31942-31950", + :street "Oak Ridge Parkway", + :city "Bulverde", + :state-abbrev "TX", + :zip "78163"} + {:lat 36.7177719, + :lon -86.44161539999999, + :house-number "759", + :street "Henry Clay Smith Road", + :city "Franklin", + :state-abbrev "KY", + :zip "42134"} + {:lat 47.7602102, + :lon -98.58079459999999, + :house-number "9343", + :street "24th Street Northeast", + :city "Tolna", + :state-abbrev "ND", + :zip "58380"} + {:lat 36.3361469, + :lon -97.63530899999999, + :house-number "16418", + :street "E0470 Road", + :city "Fairmont", + :state-abbrev "OK", + :zip "73736"} + {:lat 31.5882886, + :lon -83.4849861, + :house-number "196", + :street "Wyatt Way", + :city "Chula", + :state-abbrev "GA", + :zip "31733"} + {:lat 41.1434785, + :lon -95.15353309999999, + :house-number "1106", + :street "L Avenue", + :city "Elliott", + :state-abbrev "IA", + :zip "51532"} + {:lat 36.9492281, + :lon -90.1344712, + :house-number "7904", + :street "State Highway PP", + :city "Puxico", + :state-abbrev "MO", + :zip "63960"} + {:lat 33.999475, + :lon -90.88267689999999, + :house-number "7882", + :street "Mississippi 1", + :city "Duncan", + :state-abbrev "MS", + :zip "38740"} + {:lat 36.4568888, + :lon -114.8473813, + :house-number "17283", + :street "North Las Vegas Boulevard", + :city "Moapa", + :state-abbrev "NV", + :zip "89025"} + {:lat 27.331667, + :lon -82.08469099999999, + :house-number "11500", + :street "Curtis Road", + :city "Myakka City", + :state-abbrev "FL", + :zip "34251"} + {:lat 38.0299536, + :lon -100.3918896, + :house-number "26701-27699", + :street "East Oyler Road", + :city "Ingalls", + :state-abbrev "KS", + :zip "67853"} + {:lat 42.5574159, + :lon -89.5572457, + :house-number "3963", + :street "Carter Road", + :city "Juda", + :state-abbrev "WI", + :zip "53550"} + {:lat 30.669218, + :lon -89.077568, + :house-number "14262", + :street "Martha Redmond Road", + :city "Saucier", + :state-abbrev "MS", + :zip "39574"} + {:lat 37.8729318, + :lon -109.1608253, + :house-number "450", + :street "North Old Highway", + :city "Monticello", + :state-abbrev "UT", + :zip "84535"} + {:lat 39.929032, + :lon -109.381367, + :house-number "36168", + :street "South Archy Draw", + :city "Vernal", + :state-abbrev "UT", + :zip "84078"} + {:lat 37.5085713, + :lon -105.005597, + :house-number "200-298", + :street "Locust Street", + :city "La Veta", + :state-abbrev "CO", + :zip "81055"} + {:lat 34.5488649, + :lon -82.6195903, + :house-number "1101-1121", + :street "Harriett Circle", + :city "Anderson", + :state-abbrev "SC", + :zip "29621"} + {:lat 36.719095, + :lon -91.6816223, + :house-number "8880", + :street "County Road 9850", + :city "West Plains", + :state-abbrev "MO", + :zip "65775"} + {:lat 41.9937909, + :lon -94.49720429999999, + :house-number "701-799", + :street "250th Street", + :city "Scranton", + :state-abbrev "IA", + :zip "51462"} + {:lat 46.12154899999999, + :lon -111.597967, + :house-number "487", + :street "Lone Mountain Road", + :city "Toston", + :state-abbrev "MT", + :zip "59643"} + {:lat 37.720286, + :lon -121.794001, + :house-number "1133", + :street "Hartman Road", + :city "Livermore", + :state-abbrev "CA", + :zip "94551"} + {:lat 47.0110008, + :lon -100.3562825, + :house-number "26859-27499", + :street "188th Avenue Northeast", + :city "Wing", + :state-abbrev "ND", + :zip "58494"} + {:lat 45.6197676, + :lon -93.2683454, + :house-number "35996", + :street "University Avenue Northeast", + :city "Cambridge", + :state-abbrev "MN", + :zip "55008"} + {:lat 46.1212212, + :lon -93.5872875, + :house-number "4001-20827", + :street "85th Avenue", + :city "Onamia", + :state-abbrev "MN", + :zip "56359"} + {:lat 39.23785, + :lon -80.020365, + :house-number "651", + :street "Cole Road", + :city "Philippi", + :state-abbrev "WV", + :zip "26416"} + {:lat 46.5980332, + :lon -98.2902184, + :house-number "10168", + :street "56th Street Southeast", + :city "Marion", + :state-abbrev "ND", + :zip "58466"} + {:lat 37.0512191, + :lon -94.8314747, + :house-number "8", + :street "U.S. 69", + :city "Baxter Springs", + :state-abbrev "KS", + :zip "66713"} + {:lat 32.335616, + :lon -86.308317, + :house-number "3860", + :street "South Court Street", + :city "Montgomery", + :state-abbrev "AL", + :zip "36105"} + {:lat 39.733923, + :lon -84.050685, + :house-number "3119", + :street "Windmill Drive", + :city "Dayton", + :state-abbrev "OH", + :zip "45432"} + {:lat 30.9973976, + :lon -81.90707379999999, + :house-number "4328", + :street "3r Fish Camp Road", + :city "White Oak", + :state-abbrev "GA", + :zip "31568"} + {:lat 38.963328, + :lon -78.935407, + :house-number "9918", + :street "Howards Lick Road", + :city "Mathias", + :state-abbrev "WV", + :zip "26812"} + {:lat 26.1594226, + :lon -98.03753689999999, + :house-number "2607", + :street "Yanez Street", + :city "Donna", + :state-abbrev "TX", + :zip "78537"} + {:lat 33.9896453, + :lon -96.7604564, + :house-number "11410", + :street "Enos Road", + :city "Kingston", + :state-abbrev "OK", + :zip "73439"} + {:lat 44.1881709, + :lon -74.432176, + :house-number "6", + :street "Lake Simond Road", + :city "Tupper Lake", + :state-abbrev "NY", + :zip "12986"} + {:lat 40.8811136, + :lon -102.4120101, + :house-number "12000-12998", + :street "County Road 20", + :city "Ovid", + :state-abbrev "CO", + :zip "80744"} + {:lat 47.0568583, + :lon -109.1692646, + :house-number "95162", + :street "U.S. 87", + :city "Lewistown", + :state-abbrev "MT", + :zip "59457"} + {:lat 32.8088943, + :lon -85.4291454, + :house-number "7230-8282", + :street "Chambers County 173", + :city "La Fayette", + :state-abbrev "AL", + :zip "36862"} + {:lat 48.2667954, + :lon -112.1198458, + :house-number "6854", + :street "Messenger Road", + :city "Valier", + :state-abbrev "MT", + :zip "59486"} + {:lat 40.4162433, + :lon -95.01358599999999, + :house-number "21115", + :street "State Highway Ab", + :city "Burlington Junction", + :state-abbrev "MO", + :zip "64428"} + {:lat 44.12387349999999, + :lon -116.3023244, + :house-number "19200", + :street "Sweet Ola Highway", + :city "Ola", + :state-abbrev "ID", + :zip "83657"} + {:lat 33.664848, + :lon -87.000664, + :house-number "5150", + :street "Highway 78 East", + :city "Graysville", + :state-abbrev "AL", + :zip "35073"} + {:lat 41.898599, + :lon -79.740098, + :house-number "1922", + :street "South Main Street", + :city "Corry", + :state-abbrev "PA", + :zip "16407"} + {:lat 34.6712327, + :lon -83.49263409999999, + :house-number "1501", + :street "Frank Lovell Road", + :city "Clarkesville", + :state-abbrev "GA", + :zip "30523"} + {:lat 39.699357, + :lon -105.412938, + :house-number "263", + :street "Barrows Ranch Road", + :city "Evergreen", + :state-abbrev "CO", + :zip "80439"} + {:lat 36.4451252, + :lon -85.31148429999999, + :house-number "164", + :street "Frogtown Estate", + :city "Livingston", + :state-abbrev "TN", + :zip "38570"} + {:lat 42.692484, + :lon -82.78239789999999, + :house-number "32792-32798", + :street "Greenwood Drive", + :city "New Baltimore", + :state-abbrev "MI", + :zip "48047"} + {:lat 38.1628115, + :lon -99.1111131, + :house-number "1252", + :street "U.S. 56", + :city "Larned", + :state-abbrev "KS", + :zip "67550"} + {:lat 44.7408289, + :lon -121.1276098, + :house-number "7269", + :street "North Adams Drive", + :city "Madras", + :state-abbrev "OR", + :zip "97741"} + {:lat 27.013422, + :lon -80.423394, + :house-number "12002", + :street "Southwest Kanner Highway", + :city "Indiantown", + :state-abbrev "FL", + :zip "34956"} + {:lat 40.58038200000001, + :lon -95.8412306, + :house-number "6590", + :street "O Road", + :city "Nebraska City", + :state-abbrev "NE", + :zip "68410"} + {:lat 36.50025979999999, + :lon -90.32461889999999, + :house-number "520-550", + :street "Clay County Road 320", + :city "Qulin", + :state-abbrev "MO", + :zip "63961"} + {:lat 39.0543141, + :lon -88.27940439999999, + :house-number "4001-5147", + :street "East 1400th Avenue", + :city "Wheeler", + :state-abbrev "IL", + :zip "62479"} + {:lat 38.82139799999999, + :lon -75.54764899999999, + :house-number "11656", + :street "Utica Road", + :city "Greenwood", + :state-abbrev "DE", + :zip "19950"} + {:lat 38.814901, + :lon -121.135696, + :house-number "3874", + :street "El Monte Drive", + :city "Loomis", + :state-abbrev "CA", + :zip "95650"} + {:lat 35.838619, + :lon -77.163956, + :house-number "203", + :street "East Barnhill Street", + :city "Williamston", + :state-abbrev "NC", + :zip "27892"} + {:lat 31.3768988, + :lon -96.9819914, + :house-number "653", + :street "County Road 105", + :city "Riesel", + :state-abbrev "TX", + :zip "76682"} + {:lat 41.174038, + :lon -87.22173599999999, + :house-number "9190", + :street "West 1100 North", + :city "De Motte", + :state-abbrev "IN", + :zip "46310"} + {:lat 32.446336, + :lon -95.38271999999999, + :house-number "12197", + :street "Cross Fence Trail", + :city "Tyler", + :state-abbrev "TX", + :zip "75706"} + {:lat 34.0320311, + :lon -83.1427077, + :house-number "64", + :street "Goose Pond Road", + :city "Comer", + :state-abbrev "GA", + :zip "30629"} + {:lat 35.8515632, + :lon -78.702987, + :house-number "5004-5008", + :street "Picardy Place", + :city "Raleigh", + :state-abbrev "NC", + :zip "27612"} + {:lat 38.3571942, + :lon -78.4127535, + :house-number "1215", + :street "Kinderhook Road", + :city "Madison", + :state-abbrev "VA", + :zip "22727"} + {:lat 61.2668195, + :lon -145.2833742, + :house-number "95", + :street "Alaska 4", + :city "Copper Center", + :state-abbrev "AK", + :zip "99573"} + {:lat 40.2482635, + :lon -87.25751629999999, + :house-number "3190-3398", + :street "County Road 30 East", + :city "Attica", + :state-abbrev "IN", + :zip "47918"} + {:lat 36.31394, + :lon -92.37312899999999, + :house-number "308", + :street "Pebblecreek Drive", + :city "Mountain Home", + :state-abbrev "AR", + :zip "72653"} + {:lat 34.0787283, + :lon -92.6510928, + :house-number "4466", + :street "Arkansas 9", + :city "Carthage", + :state-abbrev "AR", + :zip "71725"} + {:lat 34.9490126, + :lon -83.80166799999999, + :house-number "497", + :street "Old Ferguson Town Road", + :city "Young Harris", + :state-abbrev "GA", + :zip "30582"} + {:lat 41.3283563, + :lon -84.8139396, + :house-number "7934", + :street "County Road 56", + :city "Saint Joe", + :state-abbrev "IN", + :zip "46785"} + {:lat 38.726831, + :lon -78.6577884, + :house-number "1026-1324", + :street "Industrial Park", + :city "Mount Jackson", + :state-abbrev "VA", + :zip "22842"} + {:lat 36.8177783, + :lon -93.8447328, + :house-number "7706", + :street "Farm Road 1120", + :city "Verona", + :state-abbrev "MO", + :zip "65769"} + {:lat 34.886794, + :lon -76.79080499999999, + :house-number "1155", + :street "Temples Point Road", + :city "Havelock", + :state-abbrev "NC", + :zip "28532"} + {:lat 40.6505745, + :lon -81.4352846, + :house-number "11439-11447", + :street "Glenpark Drive Northeast", + :city "Bolivar", + :state-abbrev "OH", + :zip "44612"} + {:lat 44.0510283, + :lon -86.0815351, + :house-number "7800-8094", + :street "Burley", + :city "Township of Branch", + :state-abbrev "MI", + :zip "49402"} + {:lat 41.717989, + :lon -72.30425199999999, + :house-number "41", + :street "Laurel Lane", + :city "Columbia", + :state-abbrev "CT", + :zip "06237"} + {:lat 48.4679556, + :lon -116.4196161, + :house-number "3312", + :street "National Forest Development Road 215", + :city "Sandpoint", + :state-abbrev "ID", + :zip "83864"} + {:lat 32.5672093, + :lon -95.7323965, + :house-number "1610", + :street "Vz County Road 1313", + :city "Canton", + :state-abbrev "TX", + :zip "75103"} + {:lat 43.223702, + :lon -77.527873, + :house-number "194", + :street "Colonial Drive", + :city "Webster", + :state-abbrev "NY", + :zip "14580"} + {:lat 32.8239852, + :lon -82.9221041, + :house-number "1206", + :street "Georgia 272", + :city "Tennille", + :state-abbrev "GA", + :zip "31089"} + {:lat 33.295844, + :lon -88.440877, + :house-number "14463", + :street "Old Macon Road", + :city "Columbus", + :state-abbrev "MS", + :zip "39701"} + {:lat 34.9712724, + :lon -78.9891936, + :house-number "3701", + :street "Applegate Road", + :city "Hope Mills", + :state-abbrev "NC", + :zip "28348"} + {:lat 35.463022, + :lon -77.113389, + :house-number "304", + :street "Warren Avenue", + :city "Chocowinity", + :state-abbrev "NC", + :zip "27817"} + {:lat 36.507964, + :lon -91.3047636, + :house-number "22828", + :street "Highway V", + :city "Myrtle", + :state-abbrev "MO", + :zip "65778"} + {:lat 45.9087868, + :lon -113.26501, + :house-number "2499", + :street "Fish Trap Road", + :city "Wise River", + :state-abbrev "MT", + :zip "59762"} + {:lat 37.82054300000001, + :lon -95.6682008, + :house-number "1821", + :street "70th Road", + :city "Yates Center", + :state-abbrev "KS", + :zip "66783"} + {:lat 38.7003089, + :lon -91.29538629999999, + :house-number "5290", + :street "Missouri 94", + :city "Marthasville", + :state-abbrev "MO", + :zip "63357"} + {:lat 41.1057731, + :lon -81.564117, + :house-number "1567-1573", + :street "West Exchange Street", + :city "Akron", + :state-abbrev "OH", + :zip "44313"} + {:lat 38.6242147, + :lon -95.4527918, + :house-number "3272-3348", + :street "Colorado Road", + :city "Pomona", + :state-abbrev "KS", + :zip "66076"} + {:lat 41.3111128, + :lon -94.4145582, + :house-number "2315-2347", + :street "Pinewood Avenue", + :city "Greenfield", + :state-abbrev "IA", + :zip "50849"} + {:lat 42.2345635, + :lon -76.83706459999999, + :house-number "4898", + :street "Clair Road", + :city "Millport", + :state-abbrev "NY", + :zip "14864"} + {:lat 45.7455717, + :lon -87.1809976, + :house-number "3701-3841", + :street "14.5 Road", + :city "Escanaba", + :state-abbrev "MI", + :zip "49829"} + {:lat 47.500227, + :lon -118.1411565, + :house-number "16423", + :street "Star Barn Road North", + :city "Davenport", + :state-abbrev "WA", + :zip "99122"} + {:lat 40.5177699, + :lon -90.4266696, + :house-number "21501", + :street "North Point Pleasant Road", + :city "Marietta", + :state-abbrev "IL", + :zip "61459"} + {:lat 36.034604, + :lon -86.844132, + :house-number "311", + :street "Deerwood Lane", + :city "Brentwood", + :state-abbrev "TN", + :zip "37027"} + {:lat 41.9659007, + :lon -92.8931427, + :house-number "509", + :street "Roberts Terrace", + :city "Marshalltown", + :state-abbrev "IA", + :zip "50158"} + {:lat 42.9233932, + :lon -95.4129181, + :house-number "6957-6999", + :street "Highway 10 Boulevard", + :city "Sutherland", + :state-abbrev "IA", + :zip "51058"} + {:lat 34.7953365, + :lon -92.7700229, + :house-number "24523-25321", + :street "Arkansas 9", + :city "Paron", + :state-abbrev "AR", + :zip "72122"} + {:lat 35.653733, + :lon -85.979012, + :house-number "11481", + :street "Shelbyville Road", + :city "Morrison", + :state-abbrev "TN", + :zip "37357"} + {:lat 45.92924499999999, + :lon -108.3549564, + :house-number "4851-5197", + :street "Yeoman Road", + :city "Shepherd", + :state-abbrev "MT", + :zip "59079"} + {:lat 36.1896105, + :lon -102.327912, + :house-number "13009", + :street "Rock Hill Road", + :city "Dalhart", + :state-abbrev "TX", + :zip "79022"} + {:lat 38.41670149999999, + :lon -122.753963, + :house-number "1426-1498", + :street "Corporate Center Parkway", + :city "Santa Rosa", + :state-abbrev "CA", + :zip "95407"} + {:lat 44.222597, + :lon -89.0943339, + :house-number "N6698", + :street "East Long Lake Road", + :city "Wild Rose", + :state-abbrev "WI", + :zip "54984"} + {:lat 42.0922467, + :lon -85.26004549999999, + :house-number "1821", + :street "V Drive South", + :city "Athens", + :state-abbrev "MI", + :zip "49011"} + {:lat 33.5235064, + :lon -94.7968594, + :house-number "765", + :street "County Road 4426", + :city "Avery", + :state-abbrev "TX", + :zip "75554"} + {:lat 42.026117, + :lon -74.804464, + :house-number "237", + :street "Mary Smith Hill Road", + :city "Livingston Manor", + :state-abbrev "NY", + :zip "12758"} + {:lat 47.7415303, + :lon -112.3168472, + :house-number "300-398", + :street "Pishkun Road", + :city "Choteau", + :state-abbrev "MT", + :zip "59422"} + {:lat 48.2485359, + :lon -104.6062252, + :house-number "5175", + :street "Road 1026", + :city "Froid", + :state-abbrev "MT", + :zip "59226"} + {:lat 45.4596787, + :lon -110.1984557, + :house-number "3319", + :street "Main Boulder Road", + :city "Mc Leod", + :state-abbrev "MT", + :zip "59052"} + {:lat 37.4279015, + :lon -77.75752279999999, + :house-number "18100", + :street "Duval Road", + :city "Moseley", + :state-abbrev "VA", + :zip "23120"} + {:lat 46.6669749, + :lon -92.0631631, + :house-number "98", + :street "Ostby Drive", + :city "Superior", + :state-abbrev "WI", + :zip "54880"} + {:lat 37.6065566, + :lon -98.73944519999999, + :house-number "20469-20731", + :street "South 1st Avenue", + :city "Pratt", + :state-abbrev "KS", + :zip "67124"} + {:lat 41.9584444, + :lon -95.5465569, + :house-number "2743", + :street "Dane Ridge Road", + :city "Dow City", + :state-abbrev "IA", + :zip "51528"} + {:lat 36.3495959, + :lon -92.29217919999999, + :house-number "4398", + :street "Buzzard Roost Road", + :city "Mountain Home", + :state-abbrev "AR", + :zip "72653"} + {:lat 40.8955618, + :lon -102.408923, + :house-number "12508-12998", + :street "County Road 22", + :city "Ovid", + :state-abbrev "CO", + :zip "80744"} + {:lat 43.9300967, + :lon -88.9492695, + :house-number "N8279", + :street "Wisconsin 49", + :city "Berlin", + :state-abbrev "WI", + :zip "54923"} + {:lat 32.4174517, + :lon -87.53871079999999, + :house-number "22605", + :street "County Road 53", + :city "Uniontown", + :state-abbrev "AL", + :zip "36786"} + {:lat 34.099726, + :lon -98.68657929999999, + :house-number "573", + :street "Williamson Road", + :city "Burkburnett", + :state-abbrev "TX", + :zip "76354"} + {:lat 46.4754497, + :lon -110.0784601, + :house-number "21-585", + :street "Haymaker Road", + :city "Harlowton", + :state-abbrev "MT", + :zip "59036"} + {:lat 39.038336, + :lon -104.174353, + :house-number "34255", + :street "Harrisville Road", + :city "Calhan", + :state-abbrev "CO", + :zip "80808"} + {:lat 37.861802, + :lon -120.625913, + :house-number "13149", + :street "Tulloch Dam Road", + :city "Jamestown", + :state-abbrev "CA", + :zip "95327"} + {:lat 44.5040542, + :lon -85.5457574, + :house-number "1400", + :street "East 2 1/2 Road", + :city "Kingsley", + :state-abbrev "MI", + :zip "49649"} + {:lat 35.7394238, + :lon -93.6382065, + :house-number "718-798", + :street "County Road 5141", + :city "Pettigrew", + :state-abbrev "AR", + :zip "72752"} + {:lat 45.5285437, + :lon -88.3064481, + :house-number "N15535", + :street "Parkway Road", + :city "Athelstane", + :state-abbrev "WI", + :zip "54104"} + {:lat 44.4656923, + :lon -106.6800018, + :house-number "47", + :street "Belus Road", + :city "Buffalo", + :state-abbrev "WY", + :zip "82834"} + {:lat 34.2037273, + :lon -116.5911904, + :house-number "50951", + :street "Burns Canyon Road", + :city "Pioneertown", + :state-abbrev "CA", + :zip "92268"} + {:lat 31.7196718, + :lon -84.1848424, + :house-number "259", + :street "Highway 32 East", + :city "Leesburg", + :state-abbrev "GA", + :zip "31763"} + {:lat 36.360493, + :lon -77.22337, + :house-number "439", + :street "Baughan Road", + :city "Woodland", + :state-abbrev "NC", + :zip "27897"} + {:lat 42.1887029, + :lon -74.13561899999999, + :house-number "11", + :street "Quarry Road", + :city "Tannersville", + :state-abbrev "NY", + :zip "12485"} + {:lat 30.008587, + :lon -98.826402, + :house-number "107", + :street "Old Comfort Road", + :city "Fredericksburg", + :state-abbrev "TX", + :zip "78624"} + {:lat 34.3910982, + :lon -85.3040286, + :house-number "537", + :street "Silver Leaf Drive", + :city "Summerville", + :state-abbrev "GA", + :zip "30747"} + {:lat 34.59938940000001, + :lon -82.15108649999999, + :house-number "11238", + :street "South Carolina 101", + :city "Gray Court", + :state-abbrev "SC", + :zip "29645"} + {:lat 33.5782505, + :lon -97.78660839999999, + :house-number "183", + :street "Hopson Road", + :city "Bowie", + :state-abbrev "TX", + :zip "76230"} + {:lat 41.285628, + :lon -85.998631, + :house-number "3734", + :street "North 800 West", + :city "Warsaw", + :state-abbrev "IN", + :zip "46582"} + {:lat 46.368916, + :lon -91.984899, + :house-number "6085", + :street "South County Road A", + :city "Solon Springs", + :state-abbrev "WI", + :zip "54873"} + {:lat 65.080709, + :lon -148.034074, + :house-number "4916", + :street "Rossburg Road", + :city "Fairbanks", + :state-abbrev "AK", + :zip "99712"} + {:lat 44.7638897, + :lon -99.2930452, + :house-number "18100-18114", + :street "345th Avenue", + :city "Orient", + :state-abbrev "SD", + :zip "57467"} + {:lat 36.9155806, + :lon -92.9576551, + :house-number "6642-7862", + :street "Chadwick Road", + :city "Chadwick", + :state-abbrev "MO", + :zip "65629"} + {:lat 40.3416011, + :lon -102.3142967, + :house-number "52944-53578", + :street "County Road CC", + :city "Wray", + :state-abbrev "CO", + :zip "80758"} + {:lat 43.0951171, + :lon -88.9768565, + :house-number "8970", + :street "Michel Lane", + :city "Waterloo", + :state-abbrev "WI", + :zip "53594"} + {:lat 46.2973537, + :lon -115.961682, + :house-number "906", + :street "Hidden Valley Lane", + :city "Weippe", + :state-abbrev "ID", + :zip "83553"} + {:lat 40.17479489999999, + :lon -87.6572382, + :house-number "1304", + :street "Park Haven Court", + :city "Danville", + :state-abbrev "IL", + :zip "61832"} + {:lat 41.2536332, + :lon -88.5247739, + :house-number "5377-5733", + :street "County Road 2000 South", + :city "Verona", + :state-abbrev "IL", + :zip "60479"} + {:lat 32.7908147, + :lon -85.1437507, + :house-number "45", + :street "Middle Street", + :city "Valley", + :state-abbrev "AL", + :zip "36854"} + {:lat 39.41639809999999, + :lon -87.3481896, + :house-number "4401", + :street "Riley Road", + :city "Terre Haute", + :state-abbrev "IN", + :zip "47802"} + {:lat 38.72755, + :lon -75.94157899999999, + :house-number "4346", + :street "Jones Lane", + :city "Preston", + :state-abbrev "MD", + :zip "21655"} + {:lat 42.0223194, + :lon -118.6227513, + :house-number "13546", + :street "Fields-Denio Road", + :city "Fields", + :state-abbrev "OR", + :zip "97710"} + {:lat 46.7163023, + :lon -94.564637, + :house-number "5305", + :street "24th Street Southwest", + :city "Pine River", + :state-abbrev "MN", + :zip "56474"} + {:lat 40.2912243, + :lon -98.57218610000001, + :house-number "919", + :street "Nebraska 4", + :city "Bladen", + :state-abbrev "NE", + :zip "68928"} + {:lat 45.0784019, + :lon -90.1627199, + :house-number "2138", + :street "County Road F", + :city "Athens", + :state-abbrev "WI", + :zip "54411"} + {:lat 36.015878, + :lon -89.431719, + :house-number "2142", + :street "Samaria Bend Road", + :city "Dyersburg", + :state-abbrev "TN", + :zip "38024"} + {:lat 47.3138924, + :lon -100.7064244, + :house-number "45737-46099", + :street "52nd Street Northeast", + :city "Wilton", + :state-abbrev "ND", + :zip "58579"} + {:lat 43.1130022, + :lon -90.60294999999999, + :house-number "16701-16899", + :street "County Road T", + :city "Boscobel", + :state-abbrev "WI", + :zip "53805"} + {:lat 47.79393899999999, + :lon -111.6032917, + :house-number "1260", + :street "18th Lane Northeast", + :city "Power", + :state-abbrev "MT", + :zip "59468"} + {:lat 35.5422894, + :lon -83.069052, + :house-number "2-158", + :street "Simpson Lane", + :city "Maggie Valley", + :state-abbrev "NC", + :zip "28751"} + {:lat 39.7718983, + :lon -75.4900014, + :house-number "1506-1508", + :street "Woodsdale Road", + :city "Wilmington", + :state-abbrev "DE", + :zip "19809"} + {:lat 47.150309, + :lon -100.2381976, + :house-number "31351", + :street "340th Street Northeast", + :city "Wing", + :state-abbrev "ND", + :zip "58494"} + {:lat 32.1289155, + :lon -81.93196429999999, + :house-number "122", + :street "Deer Run Circle", + :city "Claxton", + :state-abbrev "GA", + :zip "30417"} + {:lat 41.0531684, + :lon -86.6946993, + :house-number "4785", + :street "Indiana 14", + :city "Winamac", + :state-abbrev "IN", + :zip "46996"} + {:lat 47.10962199999999, + :lon -96.933262, + :house-number "16845", + :street "21st Street Southeast", + :city "Argusville", + :state-abbrev "ND", + :zip "58005"} + {:lat 43.056554, + :lon -77.611637, + :house-number "3224", + :street "East Henrietta Road", + :city "Henrietta", + :state-abbrev "NY", + :zip "14467"} + {:lat 46.560284, + :lon -104.093908, + :house-number "115", + :street "Tatley Road", + :city "Baker", + :state-abbrev "MT", + :zip "59313"} + {:lat 41.47658800000001, + :lon -85.377605, + :house-number "2565", + :street "East 850 North", + :city "Rome City", + :state-abbrev "IN", + :zip "46784"} + {:lat 31.1026404, + :lon -92.180238, + :house-number "1247", + :street "Highway 114", + :city "Hessmer", + :state-abbrev "LA", + :zip "71341"} + {:lat 43.2555386, + :lon -71.0671672, + :house-number "605", + :street "Berry River Road", + :city "Barrington", + :state-abbrev "NH", + :zip "03825"} + {:lat 29.79807, + :lon -99.192702, + :house-number "2247", + :street "Farm to Market 2828", + :city "Bandera", + :state-abbrev "TX", + :zip "78003"} + {:lat 43.7009933, + :lon -95.61413950000001, + :house-number "20161", + :street "Paul Avenue", + :city "Worthington", + :state-abbrev "MN", + :zip "56187"} + {:lat 35.3964691, + :lon -98.8329761, + :house-number "23317", + :street "East 1120 Road", + :city "Corn", + :state-abbrev "OK", + :zip "73024"} + {:lat 43.2321938, + :lon -96.1972263, + :house-number "2800-2898", + :street "Grant Avenue", + :city "Hull", + :state-abbrev "IA", + :zip "51239"} + {:lat 41.9509765, + :lon -90.4607997, + :house-number "1529-1603", + :street "330th Avenue", + :city "Charlotte", + :state-abbrev "IA", + :zip "52731"} + {:lat 43.1131538, + :lon -115.7902421, + :house-number "5444-6392", + :street "Airbase Road", + :city "Mountain Home", + :state-abbrev "ID", + :zip "83647"} + {:lat 43.6019566, + :lon -95.4120336, + :house-number "33530-33532", + :street "770th Street", + :city "Round Lake", + :state-abbrev "MN", + :zip "56167"} + {:lat 40.3189104, + :lon -84.36594269999999, + :house-number "3696", + :street "Basinburg Road", + :city "Fort Loramie", + :state-abbrev "OH", + :zip "45845"} + {:lat 33.2467157, + :lon -89.22798739999999, + :house-number "11022", + :street "Penderville Road", + :city "Weir", + :state-abbrev "MS", + :zip "39772"} + {:lat 43.745157, + :lon -70.460449, + :house-number "7", + :street "Homestead Lane", + :city "Gorham", + :state-abbrev "ME", + :zip "04038"} + {:lat 41.2099688, + :lon -90.9370234, + :house-number "901-999", + :street "Bluff Road", + :city "Joy", + :state-abbrev "IL", + :zip "61260"} + {:lat 46.31263879999999, + :lon -95.60988979999999, + :house-number "24100-24998", + :street "423rd Avenue", + :city "Battle Lake", + :state-abbrev "MN", + :zip "56515"} + {:lat 44.70675540000001, + :lon -95.03671349999999, + :house-number "77367", + :street "320th Street", + :city "Olivia", + :state-abbrev "MN", + :zip "56277"} + {:lat 42.5532759, + :lon -71.691813, + :house-number "711", + :street "Reservoir Road", + :city "Lunenburg", + :state-abbrev "MA", + :zip "01462"} + {:lat 32.532624, + :lon -81.277301, + :house-number "372", + :street "Morgan Cemetery Road", + :city "Clyo", + :state-abbrev "GA", + :zip "31303"} + {:lat 40.2045068, + :lon -104.9938637, + :house-number "3171-3339", + :street "Highway 66", + :city "Longmont", + :state-abbrev "CO", + :zip "80504"} + {:lat 42.7140096, + :lon -96.40934569999999, + :house-number "23232-23998", + :street "Fir Avenue", + :city "Merrill", + :state-abbrev "IA", + :zip "51038"} + {:lat 61.738969, + :lon -148.9115811, + :house-number "14154", + :street "Eska Mine Road", + :city "Sutton-Alpine", + :state-abbrev "AK", + :zip "99674"} + {:lat 39.90810070000001, + :lon -78.532979, + :house-number "526", + :street "Rose Lane", + :city "Bedford", + :state-abbrev "PA", + :zip "15522"} + {:lat 41.9988526, + :lon -85.3709988, + :house-number "30011", + :street "Covey Road", + :city "Leonidas", + :state-abbrev "MI", + :zip "49066"} + {:lat 40.575289, + :lon -90.7320816, + :house-number "20001-20347", + :street "County Road 900 East", + :city "Sciota", + :state-abbrev "IL", + :zip "61475"} + {:lat 38.2434457, + :lon -86.80134699999999, + :house-number "9914", + :street "South 475 East", + :city "Ferdinand", + :state-abbrev "IN", + :zip "47532"} + {:lat 61.99231690000001, + :lon -146.7686644, + :house-number "2446", + :street "Glenn Highway", + :city "Glennallen", + :state-abbrev "AK", + :zip "99588"} + {:lat 43.6943312, + :lon -107.8125286, + :house-number "2671", + :street "Lake Creek Road", + :city "Thermopolis", + :state-abbrev "WY", + :zip "82443"} + {:lat 46.3835299, + :lon -104.5119599, + :house-number "220", + :street "Plevna Road", + :city "Plevna", + :state-abbrev "MT", + :zip "59344"} + {:lat 35.0061837, + :lon -97.6163622, + :house-number "13132", + :street "205th Street", + :city "Dibble", + :state-abbrev "OK", + :zip "73031"} + {:lat 42.06306, + :lon -73.1616919, + :house-number "14-22", + :street "Norfolk Road", + :city "Sandisfield", + :state-abbrev "MA", + :zip "01255"} + {:lat 42.772582, + :lon -106.153102, + :house-number "6600", + :street "Hat 6 Road", + :city "Casper", + :state-abbrev "WY", + :zip "82609"} + {:lat 43.9507803, + :lon -72.1719845, + :house-number "1738-1884", + :street "Millpond Road", + :city "Fairlee", + :state-abbrev "VT", + :zip "05045"} + {:lat 45.069139, + :lon -89.90620899999999, + :house-number "10631", + :street "7th Lane", + :city "Athens", + :state-abbrev "WI", + :zip "54411"} + {:lat 44.3252818, + :lon -97.7446155, + :house-number "42300-42398", + :street "211th Street", + :city "Iroquois", + :state-abbrev "SD", + :zip "57353"} + {:lat 42.01385, + :lon -95.7325582, + :house-number "23694", + :street "Sumac Avenue", + :city "Ute", + :state-abbrev "IA", + :zip "51060"} + {:lat 34.9340828, + :lon -88.2797599, + :house-number "28-54", + :street "County Road 324", + :city "Iuka", + :state-abbrev "MS", + :zip "38852"} + {:lat 45.1066001, + :lon -96.5884731, + :house-number "48000-48098", + :street "157th Street", + :city "Revillo", + :state-abbrev "SD", + :zip "57259"} + {:lat 31.052132, + :lon -88.74860989999999, + :house-number "3978", + :street "Merritt Road", + :city "Leakesville", + :state-abbrev "MS", + :zip "39451"} + {:lat 34.3851754, + :lon -88.901736, + :house-number "1034", + :street "Corolla Lane", + :city "Blue Springs", + :state-abbrev "MS", + :zip "38828"} + {:lat 33.92264, + :lon -88.444554, + :house-number "50016", + :street "Moss Road", + :city "Amory", + :state-abbrev "MS", + :zip "38821"} + {:lat 39.5817883, + :lon -102.1110891, + :house-number "36808", + :street "County Road 1", + :city "Idalia", + :state-abbrev "CO", + :zip "80735"} + {:lat 29.881082, + :lon -98.18124999999999, + :house-number "1550", + :street "Casa Sierra", + :city "Canyon Lake", + :state-abbrev "TX", + :zip "78133"} + {:lat 42.683499, + :lon -82.744973, + :house-number "51789", + :street "Base Street", + :city "New Baltimore", + :state-abbrev "MI", + :zip "48047"} + {:lat 46.7782043, + :lon -89.0932449, + :house-number "625", + :street "Depot Street", + :city "Greenland", + :state-abbrev "MI", + :zip "49929"} + {:lat 47.44263600000001, + :lon -99.017718, + :house-number "7151-7175", + :street "2nd Street Northeast", + :city "Carrington", + :state-abbrev "ND", + :zip "58421"} + {:lat 48.0305978, + :lon -110.5805282, + :house-number "2500", + :street "Day Road", + :city "Loma", + :state-abbrev "MT", + :zip "59460"} + {:lat 46.840345, + :lon -67.96735199999999, + :house-number "213", + :street "Kelley Road", + :city "Caribou", + :state-abbrev "ME", + :zip "04736"} + {:lat 38.830162, + :lon -121.025937, + :house-number "4730", + :street "Pilot Creek Lane", + :city "Pilot Hill", + :state-abbrev "CA", + :zip "95664"} + {:lat 43.4787462, + :lon -93.8897504, + :house-number "49347", + :street "40th Avenue", + :city "Buffalo Center", + :state-abbrev "IA", + :zip "50424"} + {:lat 34.3977584, + :lon -85.3889731, + :house-number "1252", + :street "Lyerly Dam Road", + :city "Lyerly", + :state-abbrev "GA", + :zip "30730"} + {:lat 39.093997, + :lon -119.151808, + :house-number "155", + :street "Penrose Lane", + :city "Yerington", + :state-abbrev "NV", + :zip "89447"} + {:lat 36.8575068, + :lon -84.75244339999999, + :house-number "695", + :street "Raleigh Creek Road", + :city "Monticello", + :state-abbrev "KY", + :zip "42633"} + {:lat 37.90008600000001, + :lon -82.99735439999999, + :house-number "7150", + :street "Lower Sand Lick Road", + :city "West Liberty", + :state-abbrev "KY", + :zip "41472"} + {:lat 37.4791753, + :lon -104.557762, + :house-number "30268-30270", + :street "County Road 61", + :city "Aguilar", + :state-abbrev "CO", + :zip "81020"} + {:lat 34.6932203, + :lon -80.3077761, + :house-number "449", + :street "Black Creek Church Road", + :city "Mount Croghan", + :state-abbrev "SC", + :zip "29727"} + {:lat 36.465115, + :lon -94.763875, + :house-number "13527", + :street "East 380 Road", + :city "Jay", + :state-abbrev "OK", + :zip "74346"} + {:lat 31.1153828, + :lon -97.41697579999999, + :house-number "173-285", + :street "Old Waco Road", + :city "Temple", + :state-abbrev "TX", + :zip "76502"} + {:lat 39.3489089, + :lon -105.1765411, + :house-number "413-993", + :street "North Platte River Road", + :city "Sedalia", + :state-abbrev "CO", + :zip "80135"} + {:lat 30.9789516, + :lon -89.0645222, + :house-number "825", + :street "New York Road", + :city "Brooklyn", + :state-abbrev "MS", + :zip "39425"} + {:lat 39.95067299999999, + :lon -79.395112, + :house-number "615", + :street "Clay Run Road", + :city "Mill Run", + :state-abbrev "PA", + :zip "15464"} + {:lat 41.4758628, + :lon -86.2338681, + :house-number "15-381", + :street "Juniper Road", + :city "Bremen", + :state-abbrev "IN", + :zip "46506"} + {:lat 39.1523735, + :lon -87.7098115, + :house-number "12766", + :street "East 2000th Avenue", + :city "West York", + :state-abbrev "IL", + :zip "62478"} + {:lat 38.12026040000001, + :lon -95.76673579999999, + :house-number "653-681", + :street "Kafir Lane", + :city "Burlington", + :state-abbrev "KS", + :zip "66839"} + {:lat 62.10305649999999, + :lon -145.9668141, + :house-number "173", + :street "Glenn Highway", + :city "Glennallen", + :state-abbrev "AK", + :zip "99588"} + {:lat 46.900972, + :lon -122.865224, + :house-number "2648", + :street "Angus Road Southeast", + :city "Tenino", + :state-abbrev "WA", + :zip "98589"} + {:lat 41.3225163, + :lon -81.3750723, + :house-number "850", + :street "South Sussex Court", + :city "Aurora", + :state-abbrev "OH", + :zip "44202"} + {:lat 45.9751596, + :lon -120.399776, + :house-number "78", + :street "Jensen Quarry Road", + :city "Roosevelt", + :state-abbrev "WA", + :zip "99356"} + {:lat 42.193509, + :lon -88.710493, + :house-number "2785", + :street "Garden Prairie Road", + :city "Garden Prairie", + :state-abbrev "IL", + :zip "61038"} + {:lat 44.40173679999999, + :lon -70.9609732, + :house-number "2021", + :street "North Road", + :city "Gilead", + :state-abbrev "ME", + :zip "04217"} + {:lat 39.185932, + :lon -85.1369519, + :house-number "7505", + :street "North Spades Road", + :city "Sunman", + :state-abbrev "IN", + :zip "47041"} + {:lat 60.80175790000001, + :lon -148.9586428, + :house-number "1975", + :street "Wyatt's Windy Road", + :city "Anchorage", + :state-abbrev "AK", + :zip "99587"} + {:lat 57.69668100000001, + :lon -152.547253, + :house-number "12715", + :street "Chiniak Highway", + :city "Kodiak", + :state-abbrev "AK", + :zip "99615"} + {:lat 27.859979, + :lon -81.935053, + :house-number "3001", + :street "Bonnie Mine Road", + :city "Bartow", + :state-abbrev "FL", + :zip "33830"} + {:lat 43.962291, + :lon -101.3931105, + :house-number "23700", + :street "Indian Creek Road", + :city "Kadoka", + :state-abbrev "SD", + :zip "57543"} + {:lat 44.30904899999999, + :lon -120.7914501, + :house-number "4893", + :street "North Ochoco Highway", + :city "Prineville", + :state-abbrev "OR", + :zip "97754"} + {:lat 43.7022358, + :lon -106.4562194, + :house-number "823", + :street "Sussex Road", + :city "Kaycee", + :state-abbrev "WY", + :zip "82639"} + {:lat 30.00989879999999, + :lon -102.6003381, + :house-number "2005", + :street "Longbranch", + :city "Alpine", + :state-abbrev "TX", + :zip "79830"} + {:lat 39.093084, + :lon -97.2875339, + :house-number "537", + :street "3400 Avenue", + :city "Abilene", + :state-abbrev "KS", + :zip "67410"} + {:lat 42.3859311, + :lon -75.8204083, + :house-number "201", + :street "McBerney Road", + :city "Greene", + :state-abbrev "NY", + :zip "13778"} + {:lat 33.5250515, + :lon -81.23591669999999, + :house-number "71", + :street "Off Highway", + :city "Springfield", + :state-abbrev "SC", + :zip "29146"} + {:lat 34.5177893, + :lon -120.426859, + :house-number "6001", + :street "Jalama Road", + :city "Lompoc", + :state-abbrev "CA", + :zip "93436"} + {:lat 40.8553864, + :lon -85.3552304, + :house-number "1608-1798", + :street "North 500 East", + :city "Markle", + :state-abbrev "IN", + :zip "46770"} + {:lat 42.5548728, + :lon -100.7206827, + :house-number "21-30", + :street "290th Avenue", + :city "Valentine", + :state-abbrev "NE", + :zip "69201"} + {:lat 37.8871013, + :lon -111.3800388, + :house-number "1075", + :street "South Draw Lane", + :city "Boulder", + :state-abbrev "UT", + :zip "84716"} + {:lat 45.8947616, + :lon -101.8710636, + :house-number "10371", + :street "212th Avenue", + :city "Keldron", + :state-abbrev "SD", + :zip "57634"} + {:lat 35.0672596, + :lon -87.2733781, + :house-number "349-359", + :street "Richardson Road", + :city "Leoma", + :state-abbrev "TN", + :zip "38468"} + {:lat 62.8626841, + :lon -149.8708804, + :house-number "29161", + :street "North Parks Highway", + :city "Trapper Creek", + :state-abbrev "AK", + :zip "99683"} + {:lat 41.065276, + :lon -94.9103061, + :house-number "1659", + :street "Aspen Avenue", + :city "Villisca", + :state-abbrev "IA", + :zip "50864"} + {:lat 36.228495, + :lon -80.5179206, + :house-number "1018-1024", + :street "Harley Drive", + :city "East Bend", + :state-abbrev "NC", + :zip "27018"} + {:lat 40.4416933, + :lon -107.5048772, + :house-number "6275", + :street "County Road 33", + :city "Craig", + :state-abbrev "CO", + :zip "81625"} + {:lat 46.1798489, + :lon -94.7900583, + :house-number "27676-27698", + :street "Oak Ridge Road", + :city "Browerville", + :state-abbrev "MN", + :zip "56438"} + {:lat 41.23278759999999, + :lon -98.6898791, + :house-number "1378-1390", + :street "Valley Road", + :city "Farwell", + :state-abbrev "NE", + :zip "68838"} + {:lat 40.1116969, + :lon -75.70111469999999, + :house-number "33", + :street "Saint Andrews Lane", + :city "Glenmoore", + :state-abbrev "PA", + :zip "19343"} + {:lat 33.9628991, + :lon -94.4645876, + :house-number "195", + :street "Kornegay Road", + :city "De Queen", + :state-abbrev "AR", + :zip "71832"} + {:lat 47.252835, + :lon -111.2510361, + :house-number "791", + :street "East Eden Road", + :city "Great Falls", + :state-abbrev "MT", + :zip "59405"} + {:lat 34.254886, + :lon -88.9181922, + :house-number "238", + :street "Prewitt Road Extension", + :city "Pontotoc", + :state-abbrev "MS", + :zip "38863"} + {:lat 28.0166763, + :lon -82.3265856, + :house-number "10601", + :street "Bartolotti Loop", + :city "Seffner", + :state-abbrev "FL", + :zip "33584"} + {:lat 42.9727314, + :lon -93.6407687, + :house-number "1435", + :street "Rake Avenue", + :city "Goodell", + :state-abbrev "IA", + :zip "50439"} + {:lat 40.8830528, + :lon -97.711072, + :house-number "1313", + :street "Road G", + :city "York", + :state-abbrev "NE", + :zip "68467"} + {:lat 33.9453372, + :lon -102.8765466, + :house-number "2511-2585", + :street "County Road 97", + :city "Muleshoe", + :state-abbrev "TX", + :zip "79347"} + {:lat 33.028728, + :lon -85.82033799999999, + :house-number "3151", + :street "Lashley Road", + :city "Alexander City", + :state-abbrev "AL", + :zip "35010"} + {:lat 57.2031043, + :lon -153.3069441, + :house-number "3", + :street "3 Saints Avenue", + :city "Old Harbor", + :state-abbrev "AK", + :zip "99643"} + {:lat 43.2271856, + :lon -93.7495422, + :house-number "1201-1245", + :street "320th Street", + :city "Britt", + :state-abbrev "IA", + :zip "50423"} + {:lat 44.234764, + :lon -83.5723488, + :house-number "1655", + :street "Oates Road", + :city "Tawas City", + :state-abbrev "MI", + :zip "48763"} + {:lat 43.05741829999999, + :lon -71.9011538, + :house-number "2368", + :street "2nd New Hampshire Turnpike North", + :city "Deering", + :state-abbrev "NH", + :zip "03244"} + {:lat 39.8053478, + :lon -89.38065110000001, + :house-number "14400", + :street "Bullard Road", + :city "Buffalo", + :state-abbrev "IL", + :zip "62515"} + {:lat 36.464025, + :lon -88.82106499999999, + :house-number "9832", + :street "Reams Road", + :city "South Fulton", + :state-abbrev "TN", + :zip "38257"} + {:lat 46.81518, + :lon -94.628722, + :house-number "298", + :street "County 41 Northwest", + :city "Backus", + :state-abbrev "MN", + :zip "56435"} + {:lat 33.0933287, + :lon -96.80106560000002, + :house-number "8565", + :street "Gratitude Trail", + :city "Plano", + :state-abbrev "TX", + :zip "75024"} + {:lat 47.7854307, + :lon -111.07975, + :house-number "5463", + :street "Carter Road", + :city "Floweree", + :state-abbrev "MT", + :zip "59440"} + {:lat 68.1407585, + :lon -151.7337144, + :house-number "1104", + :street "Summer Street", + :city "Anaktuvuk Pass", + :state-abbrev "AK", + :zip "99721"} + {:lat 36.6480556, + :lon -89.20687989999999, + :house-number "140-148", + :street "County Road 507", + :city "East Prairie", + :state-abbrev "MO", + :zip "63845"} + {:lat 37.466346, + :lon -78.787437, + :house-number "4467", + :street "Wildway Road", + :city "Appomattox", + :state-abbrev "VA", + :zip "24522"} + {:lat 42.1816833, + :lon -72.2562806, + :house-number "1-799", + :street "Smith Road", + :city "Brimfield", + :state-abbrev "MA", + :zip "01010"} + {:lat 38.23524500000001, + :lon -89.9299319, + :house-number "4327", + :street "North Road", + :city "Red Bud", + :state-abbrev "IL", + :zip "62278"} + {:lat 48.0768505, + :lon -103.9195978, + :house-number "14951", + :street "44th Lane Northwest", + :city "Williston", + :state-abbrev "ND", + :zip "58801"} + {:lat 39.5134118, + :lon -83.5542948, + :house-number "298-1500", + :street "Miami Trace Road Northwest", + :city "Washington Court House", + :state-abbrev "OH", + :zip "43160"} + {:lat 38.3767066, + :lon -83.86546, + :house-number "500", + :street "Harvey Point Lane", + :city "Ewing", + :state-abbrev "KY", + :zip "41039"} + {:lat 35.0987129, + :lon -78.44406599999999, + :house-number "1580", + :street "Honeycutt Road", + :city "Clinton", + :state-abbrev "NC", + :zip "28328"} + {:lat 40.2518581, + :lon -111.6709749, + :house-number "701", + :street "Columbia Lane", + :city "Provo", + :state-abbrev "UT", + :zip "84604"} + {:lat 38.2357129, + :lon -84.3932788, + :house-number "421", + :street "Russell Cave Road", + :city "Paris", + :state-abbrev "KY", + :zip "40361"} + {:lat 41.46980569999999, + :lon -75.56494649999999, + :house-number "601-999", + :street "Center Street", + :city "Jessup", + :state-abbrev "PA", + :zip "18434"} + {:lat 40.007307, + :lon -91.379312, + :house-number "6309", + :street "North 24th Street", + :city "Quincy", + :state-abbrev "IL", + :zip "62305"} + {:lat 48.2478274, + :lon -97.4702992, + :house-number "5764-5768", + :street "Carpenter Avenue West", + :city "Forest River", + :state-abbrev "ND", + :zip "58233"} + {:lat 31.8534425, + :lon -95.2631505, + :house-number "4450", + :street "County Road 1707", + :city "Jacksonville", + :state-abbrev "TX", + :zip "75766"} + {:lat 45.53608759999999, + :lon -122.6070854, + :house-number "1823", + :street "Northeast 55th Avenue", + :city "Portland", + :state-abbrev "OR", + :zip "97213"} + {:lat 41.8130925, + :lon -78.1391026, + :house-number "187", + :street "Atkins Road", + :city "Roulette", + :state-abbrev "PA", + :zip "16746"} + {:lat 28.7773258, + :lon -97.38853019999999, + :house-number "7705-8103", + :street "U.S. 77 Alternate", + :city "Goliad", + :state-abbrev "TX", + :zip "77963"} + {:lat 36.798081, + :lon -88.5468652, + :house-number "714", + :street "Heath Lane", + :city "Mayfield", + :state-abbrev "KY", + :zip "42066"} + {:lat 67.08449519999999, + :lon -157.8628762, + :house-number "9998", + :street "Ambler Avenue", + :city "Ambler", + :state-abbrev "AK", + :zip "99786"} + {:lat 38.8718307, + :lon -122.7625342, + :house-number "7947-7949", + :street "Harrington Flat Road", + :city "Kelseyville", + :state-abbrev "CA", + :zip "95451"} + {:lat 35.6170622, + :lon -112.2642372, + :house-number "2779", + :street "Sunaire Avenue", + :city "Williams", + :state-abbrev "AZ", + :zip "86046"} + {:lat 44.972463, + :lon -116.8597028, + :house-number "44600", + :street "Brownlee-Oxbow Highway", + :city "Oxbow", + :state-abbrev "OR", + :zip "97840"} + {:lat 36.1922535, + :lon -82.6589703, + :house-number "2662", + :street "Sand Bar Road", + :city "Chuckey", + :state-abbrev "TN", + :zip "37641"} + {:lat 39.5195547, + :lon -87.1212157, + :house-number "515-521", + :street "South Lambert Street", + :city "Brazil", + :state-abbrev "IN", + :zip "47834"} + {:lat 41.44949889999999, + :lon -81.5390384, + :house-number "19916", + :street "Harvard Avenue", + :city "Warrensville Heights", + :state-abbrev "OH", + :zip "44122"} + {:lat 47.789402, + :lon -117.27539, + :house-number "9613", + :street "East Mount Spokane Park Drive", + :city "Mead", + :state-abbrev "WA", + :zip "99021"} + {:lat 43.7183199, + :lon -111.7442182, + :house-number "7864-7870", + :street "South 1600 East", + :city "Rexburg", + :state-abbrev "ID", + :zip "83440"} + {:lat 40.1236675, + :lon -104.5281548, + :house-number "8505", + :street "County Road 57", + :city "Keenesburg", + :state-abbrev "CO", + :zip "80643"} + {:lat 42.8495931, + :lon -100.9546814, + :house-number "37592", + :street "South Kilgore Road", + :city "Kilgore", + :state-abbrev "NE", + :zip "69216"} + {:lat 39.690456, + :lon -86.58319499999999, + :house-number "3438", + :street "West County Road 500 South", + :city "Clayton", + :state-abbrev "IN", + :zip "46118"} + {:lat 48.107966, + :lon -98.453614, + :house-number "10001-10199", + :street "48th Street Northeast", + :city "Lakota", + :state-abbrev "ND", + :zip "58344"} + {:lat 47.098346, + :lon -91.817061, + :house-number "871", + :street "Stanley Road", + :city "Two Harbors", + :state-abbrev "MN", + :zip "55616"} + {:lat 40.910567, + :lon -88.8735779, + :house-number "19000-19998", + :street "North 300 East Road", + :city "Flanagan", + :state-abbrev "IL", + :zip "61740"} + {:lat 43.4110729, + :lon -99.56864089999999, + :house-number "33049", + :street "275th Street", + :city "Dallas", + :state-abbrev "SD", + :zip "57529"} + {:lat 42.63015499999999, + :lon -103.736465, + :house-number "562", + :street "Andrews Road", + :city "Harrison", + :state-abbrev "NE", + :zip "69346"} + {:lat 40.5366017, + :lon -118.0511495, + :house-number "12490", + :street "Nevada 400", + :city "Imlay", + :state-abbrev "NV", + :zip "89418"} + {:lat 33.4474665, + :lon -79.637743, + :house-number "1468-1814", + :street "County Road S-45-122", + :city "Andrews", + :state-abbrev "SC", + :zip "29510"} + {:lat 46.074001, + :lon -110.0419413, + :house-number "617", + :street "Wheeler Creek Road", + :city "Big Timber", + :state-abbrev "MT", + :zip "59011"} + {:lat 44.6353768, + :lon -123.1255399, + :house-number "31401-31799", + :street "Bryant Way Southwest", + :city "Albany", + :state-abbrev "OR", + :zip "97321"} + {:lat 38.334902, + :lon -96.54696059999999, + :house-number "1871", + :street "Buck Creek Road", + :city "Cottonwood Falls", + :state-abbrev "KS", + :zip "66845"} + {:lat 35.2986389, + :lon -79.54832239999999, + :house-number "4957-4963", + :street "Dowd Road", + :city "West End", + :state-abbrev "NC", + :zip "27376"} + {:lat 44.63497, + :lon -94.87430189999999, + :house-number "72000-72999", + :street "400th Street", + :city "Bird Island", + :state-abbrev "MN", + :zip "55310"} + {:lat 45.0184419, + :lon -89.45760779999999, + :house-number "7494", + :street "Sunrise Road", + :city "Wausau", + :state-abbrev "WI", + :zip "54403"} + {:lat 38.0828436, + :lon -104.138255, + :house-number "59001-62733", + :street "Huerfano Meter Station Road", + :city "Fowler", + :state-abbrev "CO", + :zip "81039"} + {:lat 44.7461575, + :lon -97.22435259999999, + :house-number "44915-44965", + :street "182nd Street", + :city "Hayti", + :state-abbrev "SD", + :zip "57241"} + {:lat 38.84375530000001, + :lon -92.2123464, + :house-number "9301-9351", + :street "South Rangeline Road", + :city "Columbia", + :state-abbrev "MO", + :zip "65201"} + {:lat 43.878749, + :lon -123.004424, + :house-number "81785", + :street "Sears Road", + :city "Creswell", + :state-abbrev "OR", + :zip "97426"} + {:lat 36.067496, + :lon -118.962811, + :house-number "10", + :street "Olive Drive", + :city "Porterville", + :state-abbrev "CA", + :zip "93257"} + {:lat 37.353904, + :lon -80.63106499999999, + :house-number "540", + :street "Big Branch Hollow Road", + :city "Pembroke", + :state-abbrev "VA", + :zip "24136"} + {:lat 44.5013568, + :lon -70.190512, + :house-number "212", + :street "Hyde Road", + :city "Jay", + :state-abbrev "ME", + :zip "04239"} + {:lat 41.7447019, + :lon -95.230696, + :house-number "1648", + :street "Redwood Road", + :city "Kirkman", + :state-abbrev "IA", + :zip "51447"} + {:lat 35.4066559, + :lon -79.91025669999999, + :house-number "R", + :street "Love Joy Road", + :city "Troy", + :state-abbrev "NC", + :zip "27371"} + {:lat 40.24334390000001, + :lon -92.29689409999999, + :house-number "49055", + :street "Aberdeen Avenue", + :city "Baring", + :state-abbrev "MO", + :zip "63531"} + {:lat 68.1407585, + :lon -151.7337144, + :house-number "1104", + :street "Summer Street", + :city "Anaktuvuk Pass", + :state-abbrev "AK", + :zip "99721"} + {:lat 44.75825709999999, + :lon -83.34710319999999, + :house-number "4337", + :street "Sucker Creek Road", + :city "Black River", + :state-abbrev "MI", + :zip "48721"} + {:lat 62.21401100000001, + :lon -149.953529, + :house-number "34200", + :street "South Answer Creek Road", + :city "Talkeetna", + :state-abbrev "AK", + :zip "99676"} + {:lat 43.8345894, + :lon -74.5497638, + :house-number "104", + :street "Carry Lane", + :city "Indian Lake", + :state-abbrev "NY", + :zip "12812"} + {:lat 42.9445834, + :lon -116.06033, + :house-number "37714", + :street "Owyhee Highway", + :city "Grand View", + :state-abbrev "ID", + :zip "83624"} + {:lat 45.270075, + :lon -91.58520299999999, + :house-number "444", + :street "28th Street", + :city "New Auburn", + :state-abbrev "WI", + :zip "54757"} + {:lat 32.139047, + :lon -95.380782, + :house-number "23600", + :street "County Road 181", + :city "Bullard", + :state-abbrev "TX", + :zip "75757"} + {:lat 37.2135641, + :lon -80.5379223, + :house-number "4422-4657", + :street "Mount Zion Road", + :city "Blacksburg", + :state-abbrev "VA", + :zip "24060"} + {:lat 36.6163612, + :lon -94.5197949, + :house-number "656", + :street "Coyote Lane", + :city "Anderson", + :state-abbrev "MO", + :zip "64831"} + {:lat 30.9334562, + :lon -83.8215372, + :house-number "1434", + :street "Patten Coolidge Road", + :city "Thomasville", + :state-abbrev "GA", + :zip "31757"} + {:lat 45.4285494, + :lon -106.0600979, + :house-number "56", + :street "10 Mile Road", + :city "Ashland", + :state-abbrev "MT", + :zip "59003"} + {:lat 32.0184677, + :lon -83.23612779999999, + :house-number "371", + :street "Ball-Adams Road", + :city "Rhine", + :state-abbrev "GA", + :zip "31077"} + {:lat 35.22150310000001, + :lon -83.87637219999999, + :house-number "870", + :street "Morris Creek Road", + :city "Andrews", + :state-abbrev "NC", + :zip "28901"} + {:lat 38.7088689, + :lon -106.2974023, + :house-number "21636-23044", + :street "Chalk Creek Drive", + :city "Nathrop", + :state-abbrev "CO", + :zip "81236"} + {:lat 48.2479813, + :lon -112.7933473, + :house-number "2870", + :street "Heart Butte Road", + :city "Heart Butte", + :state-abbrev "MT", + :zip "59448"} + {:lat 46.2673028, + :lon -94.83111799999999, + :house-number "25651", + :street "440th Street", + :city "Staples", + :state-abbrev "MN", + :zip "56479"} + {:lat 39.8195973, + :lon -106.6488937, + :house-number "10856-10872", + :street "Colorado 131", + :city "Bond", + :state-abbrev "CO", + :zip "80423"} + {:lat 34.6729655, + :lon -90.9358108, + :house-number "1-1245", + :street "Lee Road 146", + :city "Marianna", + :state-abbrev "AR", + :zip "72360"} + {:lat 43.73931109999999, + :lon -73.2099542, + :house-number "1430", + :street "Camp Road", + :city "Hubbardton", + :state-abbrev "VT", + :zip "05733"} + {:lat 44.4667235, + :lon -109.646441, + :house-number "2045-2099", + :street "North Fork Highway", + :city "Cody", + :state-abbrev "WY", + :zip "82414"} + {:lat 31.85133149999999, + :lon -89.56420399999999, + :house-number "8945", + :street "Mississippi 35", + :city "Mize", + :state-abbrev "MS", + :zip "39116"} + {:lat 64.11335319999999, + :lon -145.7153202, + :house-number "4-6", + :street "Berm Road", + :city "Delta Junction", + :state-abbrev "AK", + :zip "99737"} + {:lat 44.08427500000001, + :lon -69.424667, + :house-number "993", + :street "Bremen Road", + :city "Waldoboro", + :state-abbrev "ME", + :zip "04572"} + {:lat 40.8418528, + :lon -95.0637706, + :house-number "2684", + :street "140 Street", + :city "Clarinda", + :state-abbrev "IA", + :zip "51632"} + {:lat 42.5752479, + :lon -83.4225818, + :house-number "4144-4174", + :street "Cedar Avenue", + :city "West Bloomfield Township", + :state-abbrev "MI", + :zip "48323"} + {:lat 37.9000567, + :lon -95.4400805, + :house-number "1100-1140", + :street "1100th Street", + :city "Iola", + :state-abbrev "KS", + :zip "66749"} + {:lat 44.211073, + :lon -116.980414, + :house-number "5370", + :street "Oregon 201", + :city "Ontario", + :state-abbrev "OR", + :zip "97914"} + {:lat 41.69714279999999, + :lon -76.2646924, + :house-number "1439", + :street "Old Stage Coach Road", + :city "Wyalusing", + :state-abbrev "PA", + :zip "18853"} + {:lat 41.517833, + :lon -109.7861365, + :house-number "169-303", + :street "Tenneco Road", + :city "Little America", + :state-abbrev "WY", + :zip "82929"} + {:lat 44.767292, + :lon -117.2465541, + :house-number "41643-41645", + :street "Stanciu Road", + :city "Richland", + :state-abbrev "OR", + :zip "97870"} + {:lat 35.9428891, + :lon -96.5972493, + :house-number "11574", + :street "South Highway 16", + :city "Drumright", + :state-abbrev "OK", + :zip "74030"} + {:lat 47.830185, + :lon -116.902588, + :house-number "17465", + :street "North Reservoir Road", + :city "Rathdrum", + :state-abbrev "ID", + :zip "83858"} + {:lat 37.0261329, + :lon -120.7084354, + :house-number "17767-18027", + :street "Britto Road", + :city "Dos Palos", + :state-abbrev "CA", + :zip "93620"} + {:lat 46.7897253, + :lon -102.3069165, + :house-number "8797-8799", + :street "43rd Street Southwest", + :city "Richardton", + :state-abbrev "ND", + :zip "58652"} + {:lat 39.4218773, + :lon -87.8772173, + :house-number "18895", + :street "North Bluegrass Road", + :city "Martinsville", + :state-abbrev "IL", + :zip "62442"} + {:lat 37.7548839, + :lon -82.11584700000002, + :house-number "1561", + :street "Holly Ridge", + :city "Delbarton", + :state-abbrev "WV", + :zip "25670"} + {:lat 43.6115287, + :lon -75.10066379999999, + :house-number "133", + :street "New York 28", + :city "Forestport", + :state-abbrev "NY", + :zip "13338"} + {:lat 39.5495735, + :lon -74.9842683, + :house-number "977", + :street "Harding Highway", + :city "Newfield", + :state-abbrev "NJ", + :zip "08344"} + {:lat 40.4986933, + :lon -101.4616215, + :house-number "73606", + :street "342 Avenue", + :city "Wauneta", + :state-abbrev "NE", + :zip "69045"} + {:lat 39.88439229999999, + :lon -93.44229159999999, + :house-number "18236", + :street "Liv 216", + :city "Chula", + :state-abbrev "MO", + :zip "64635"} + {:lat 31.6453016, + :lon -98.7931651, + :house-number "6275", + :street "County Road 261", + :city "Zephyr", + :state-abbrev "TX", + :zip "76890"} + {:lat 37.8124198, + :lon -91.809668, + :house-number "13210", + :street "County Road 7480", + :city "Rolla", + :state-abbrev "MO", + :zip "65401"} + {:lat 46.6246575, + :lon -109.9853737, + :house-number "1701", + :street "Lode Road", + :city "Judith Gap", + :state-abbrev "MT", + :zip "59453"} + {:lat 31.7721425, + :lon -88.30609899999999, + :house-number "4243", + :street "County Road 6", + :city "Silas", + :state-abbrev "AL", + :zip "36919"} + {:lat 45.5132811, + :lon -90.7530905, + :house-number "W1521", + :street "U.S. 8", + :city "Hawkins", + :state-abbrev "WI", + :zip "54530"} + {:lat 38.53201540000001, + :lon -77.9146172, + :house-number "18315", + :street "Brenridge Drive", + :city "Brandy Station", + :state-abbrev "VA", + :zip "22714"} + {:lat 31.727264, + :lon -82.86896999999999, + :house-number "922", + :street "Rock Creek Road", + :city "Broxton", + :state-abbrev "GA", + :zip "31519"} + {:lat 37.3029865, + :lon -104.8068505, + :house-number "18926-19804", + :street "County Road 42", + :city "Aguilar", + :state-abbrev "CO", + :zip "81020"} + {:lat 44.7078688, + :lon -101.1166327, + :house-number "24915-24999", + :street "Fosters Bay Road", + :city "Hayes", + :state-abbrev "SD", + :zip "57537"} + {:lat 33.1992752, + :lon -84.7877791, + :house-number "800", + :street "Gold Mine Road", + :city "Grantville", + :state-abbrev "GA", + :zip "30220"} + {:lat 35.532079, + :lon -78.99712, + :house-number "151", + :street "Attie Lee Lane", + :city "Sanford", + :state-abbrev "NC", + :zip "27330"} + {:lat 39.3320421, + :lon -79.8101235, + :house-number "359-433", + :street "Kanetown Road", + :city "Tunnelton", + :state-abbrev "WV", + :zip "26444"} + {:lat 35.5339405, + :lon -103.0605646, + :house-number "8762-8798", + :street "Quay Road C", + :city "Nara Visa", + :state-abbrev "NM", + :zip "88430"} + {:lat 38.87266899999999, + :lon -81.692555, + :house-number "440", + :street "Creed Road", + :city "Sandyville", + :state-abbrev "WV", + :zip "25275"} + {:lat 40.7866224, + :lon -99.41291869999999, + :house-number "2860-3498", + :street "115th Road", + :city "Elm Creek", + :state-abbrev "NE", + :zip "68836"} + {:lat 43.72863599999999, + :lon -89.5407456, + :house-number "N2496", + :street "County Road O", + :city "Endeavor", + :state-abbrev "WI", + :zip "53930"} + {:lat 41.61609139999999, + :lon -80.167881, + :house-number "10862", + :street "Mercer Pike", + :city "Meadville", + :state-abbrev "PA", + :zip "16335"} + {:lat 41.0486853, + :lon -83.2739697, + :house-number "90", + :street "West Street", + :city "New Riegel", + :state-abbrev "OH", + :zip "44853"} + {:lat 44.3047656, + :lon -91.4396951, + :house-number "24455", + :street "Korpal Valley Road", + :city "Arcadia", + :state-abbrev "WI", + :zip "54612"} + {:lat 32.3749628, + :lon -81.91605910000001, + :house-number "1121", + :street "Georgia 46", + :city "Register", + :state-abbrev "GA", + :zip "30452"} + {:lat 43.190175, + :lon -84.290216, + :house-number "19158", + :street "West Brady Road", + :city "Oakley", + :state-abbrev "MI", + :zip "48649"} + {:lat 41.2768919, + :lon -73.3320092, + :house-number "40", + :street "Country Club Lane", + :city "Easton", + :state-abbrev "CT", + :zip "06612"} + {:lat 33.611816, + :lon -101.574374, + :house-number "1212", + :street "County Road 3900", + :city "Lorenzo", + :state-abbrev "TX", + :zip "79343"} + {:lat 30.40228699999999, + :lon -92.4700189, + :house-number "2821", + :street "Grand Coulee Road", + :city "Iota", + :state-abbrev "LA", + :zip "70543"} + {:lat 41.5195341, + :lon -86.2735781, + :house-number "67000", + :street "Block Linden Road", + :city "Lakeville", + :state-abbrev "IN", + :zip "46536"} + {:lat 31.933357, + :lon -98.647925, + :house-number "9151", + :street "Texas 36", + :city "Comanche", + :state-abbrev "TX", + :zip "76442"} + {:lat 45.6637316, + :lon -109.1018165, + :house-number "568", + :street "Pine Crest Road", + :city "Columbus", + :state-abbrev "MT", + :zip "59019"} + {:lat 32.2872424, + :lon -83.76449579999999, + :house-number "1888", + :street "Busby Road", + :city "Unadilla", + :state-abbrev "GA", + :zip "31091"} + {:lat 45.6368764, + :lon -84.80468809999999, + :house-number "5697-5965", + :street "East Levering Road", + :city "Levering", + :state-abbrev "MI", + :zip "49755"} + {:lat 40.3157351, + :lon -103.7817528, + :house-number "21492", + :street "County Road 19.5", + :city "Fort Morgan", + :state-abbrev "CO", + :zip "80701"} + {:lat 30.3464967, + :lon -100.5766679, + :house-number "4783", + :street "Scr 406", + :city "Sonora", + :state-abbrev "TX", + :zip "76950"} + {:lat 34.2315341, + :lon -119.0478149, + :house-number "1731-1753", + :street "Edgemont Drive", + :city "Camarillo", + :state-abbrev "CA", + :zip "93010"} + {:lat 35.320345, + :lon -90.586955, + :house-number "291", + :street "County Road 428", + :city "Parkin", + :state-abbrev "AR", + :zip "72373"} + {:lat 42.2477235, + :lon -71.67822749999999, + :house-number "201", + :street "Westboro Road", + :city "Grafton", + :state-abbrev "MA", + :zip "01536"} + {:lat 68.1407585, + :lon -151.7337144, + :house-number "1104", + :street "Summer Street", + :city "Anaktuvuk Pass", + :state-abbrev "AK", + :zip "99721"} + {:lat 41.8741662, + :lon -76.21114469999999, + :house-number "1047", + :street "Williams Road", + :city "Le Raysville", + :state-abbrev "PA", + :zip "18829"} + {:lat 41.977901, + :lon -77.8008182, + :house-number "340", + :street "Grover Hollow Road", + :city "Genesee", + :state-abbrev "PA", + :zip "16923"} + {:lat 32.5075849, + :lon -83.6623834, + :house-number "1938", + :street "South Houston Lake Road", + :city "Kathleen", + :state-abbrev "GA", + :zip "31047"} + {:lat 36.3514451, + :lon -91.216505, + :house-number "510", + :street "Sassafras Trail", + :city "Ravenden Springs", + :state-abbrev "AR", + :zip "72460"} + {:lat 36.7167521, + :lon -79.360768, + :house-number "282-298", + :street "Kendall Road", + :city "Blairs", + :state-abbrev "VA", + :zip "24527"} + {:lat 39.424406, + :lon -83.364902, + :house-number "8712", + :street "Ohio 753", + :city "Greenfield", + :state-abbrev "OH", + :zip "45123"} + {:lat 38.594246, + :lon -104.2138278, + :house-number "32801-33307", + :street "Myers Road", + :city "Yoder", + :state-abbrev "CO", + :zip "80864"} + {:lat 40.53627960000001, + :lon -96.0678761, + :house-number "1916", + :street "South 42nd Road", + :city "Talmage", + :state-abbrev "NE", + :zip "68448"} + {:lat 40.5008015, + :lon -78.2598843, + :house-number "1150", + :street "Fieldstone Lane", + :city "Hollidaysburg", + :state-abbrev "PA", + :zip "16648"} + {:lat 45.35636909999999, + :lon -98.0547479, + :house-number "40822-40854", + :street "140th Street", + :city "Groton", + :state-abbrev "SD", + :zip "57445"} + {:lat 31.9913681, + :lon -101.9196867, + :house-number "8526-9194", + :street "East County Road 120", + :city "Midland", + :state-abbrev "TX", + :zip "79706"} + {:lat 33.6400715, + :lon -97.244783, + :house-number "6060", + :street "U.S. 82", + :city "Gainesville", + :state-abbrev "TX", + :zip "76240"} + {:lat 37.3464922, + :lon -85.59606199999999, + :house-number "1625", + :street "Hudgins Highway", + :city "Summersville", + :state-abbrev "KY", + :zip "42782"} + {:lat 44.8919432, + :lon -94.599375, + :house-number "58232-58838", + :street "County Saint Aid Highway 12", + :city "Cosmos", + :state-abbrev "MN", + :zip "56228"} + {:lat 48.8773831, + :lon -99.122272, + :house-number "7220-7256", + :street "101st Street Northeast", + :city "Sarles", + :state-abbrev "ND", + :zip "58372"} + {:lat 34.20518, + :lon -91.688007, + :house-number "7620", + :street "Swan Lake Road", + :city "Altheimer", + :state-abbrev "AR", + :zip "72004"} + {:lat 36.862908, + :lon -93.01798409999999, + :house-number "13125", + :street "Missouri 125", + :city "Garrison", + :state-abbrev "MO", + :zip "65657"} + {:lat 46.3151618, + :lon -93.86941999999999, + :house-number "25000-25420", + :street "Emstad Road", + :city "Brainerd", + :state-abbrev "MN", + :zip "56401"} + {:lat 46.9479699, + :lon -105.66358, + :house-number "582", + :street "Bowgun Road", + :city "Terry", + :state-abbrev "MT", + :zip "59349"} + {:lat 45.196964, + :lon -91.28798, + :house-number "24378", + :street "County Highway East", + :city "Cornell", + :state-abbrev "WI", + :zip "54732"} + {:lat 62.457533, + :lon -151.0304531, + :house-number "10086", + :street "Cache Creek Trail", + :city "Petersville", + :state-abbrev "AK", + :zip "99688"} + {:lat 38.7511194, + :lon -105.5306814, + :house-number "845", + :street "County Road 102", + :city "Guffey", + :state-abbrev "CO", + :zip "80820"} + {:lat 40.54277860000001, + :lon -87.213391, + :house-number "4498", + :street "South 600 East", + :city "Oxford", + :state-abbrev "IN", + :zip "47971"} + {:lat 38.369965, + :lon -108.918405, + :house-number "8255", + :street "V Road", + :city "Bedrock", + :state-abbrev "CO", + :zip "81411"} + {:lat 48.032514, + :lon -121.953431, + :house-number "4005", + :street "203rd Avenue Northeast", + :city "Snohomish", + :state-abbrev "WA", + :zip "98290"} + {:lat 45.488054, + :lon -123.0048721, + :house-number "31675", + :street "Southwest Tongue Lane", + :city "Cornelius", + :state-abbrev "OR", + :zip "97113"} + {:lat 60.736956, + :lon -151.204963, + :house-number "52191", + :street "Lucille Drive", + :city "Kenai", + :state-abbrev "AK", + :zip "99611"} + {:lat 47.8002663, + :lon -110.9993894, + :house-number "2111", + :street "Davis School Road", + :city "Carter", + :state-abbrev "MT", + :zip "59420"} + {:lat 34.64605, + :lon -85.357874, + :house-number "333", + :street "Dixon Springs Road", + :city "LaFayette", + :state-abbrev "GA", + :zip "30728"} + {:lat 33.5398494, + :lon -90.5841232, + :house-number "1", + :street "Fox Lane", + :city "Sunflower", + :state-abbrev "MS", + :zip "38778"} + {:lat 37.978329, + :lon -79.320073, + :house-number "165", + :street "High Rock Road", + :city "Raphine", + :state-abbrev "VA", + :zip "24472"} + {:lat 38.0566677, + :lon -95.1343707, + :house-number "11350", + :street "Southeast Trego Road", + :city "Kincaid", + :state-abbrev "KS", + :zip "66039"} + {:lat 41.5546254, + :lon -87.18648069999999, + :house-number "3370", + :street "Reserve Drive", + :city "Portage", + :state-abbrev "IN", + :zip "46368"} + {:lat 30.9257828, + :lon -84.5847308, + :house-number "905", + :street "Moore Street", + :city "Bainbridge", + :state-abbrev "GA", + :zip "39817"} + {:lat 34.919351, + :lon -86.41667699999999, + :house-number "267", + :street "Old Mountain Fork Road", + :city "New Market", + :state-abbrev "AL", + :zip "35761"} + {:lat 35.8908397, + :lon -100.3738468, + :house-number "2550", + :street "South Locust Street", + :city "Canadian", + :state-abbrev "TX", + :zip "79014"} + {:lat 47.553749, + :lon -110.6752015, + :house-number "2430", + :street "Schipf Lane", + :city "Highwood", + :state-abbrev "MT", + :zip "59450"} + {:lat 43.7129598, + :lon -82.8248114, + :house-number "4849", + :street "East Atwater Road", + :city "Minden City", + :state-abbrev "MI", + :zip "48456"} + {:lat 31.9415854, + :lon -85.179998, + :house-number "554-620", + :street "Alabama 6", + :city "Eufaula", + :state-abbrev "AL", + :zip "36027"} + {:lat 41.1433826, + :lon -77.0596565, + :house-number "12090-12170", + :street "Pennsylvania 44", + :city "Allenwood", + :state-abbrev "PA", + :zip "17810"} + {:lat 42.4568889, + :lon -78.98246499999999, + :house-number "11586", + :street "New York 39", + :city "Perrysburg", + :state-abbrev "NY", + :zip "14129"} + {:lat 44.7556077, + :lon -88.8731391, + :house-number "12469", + :street "Grant Road", + :city "Caroline", + :state-abbrev "WI", + :zip "54928"} + {:lat 45.7572842, + :lon -110.2586546, + :house-number "1006-1152", + :street "Convict Grade Road", + :city "Livingston", + :state-abbrev "MT", + :zip "59047"} + {:lat 40.643762, + :lon -74.34552699999999, + :house-number "630", + :street "Westfield Avenue", + :city "Westfield", + :state-abbrev "NJ", + :zip "07090"} + {:lat 35.620295, + :lon -119.173283, + :house-number "14609", + :street "Wallace Road", + :city "McFarland", + :state-abbrev "CA", + :zip "93250"} + {:lat 34.767517, + :lon -88.15534679999999, + :house-number "230", + :street "County Road 995", + :city "Iuka", + :state-abbrev "MS", + :zip "38852"} + {:lat 47.3610646, + :lon -114.7882854, + :house-number "8431", + :street "Montana 200", + :city "Plains", + :state-abbrev "MT", + :zip "59859"} + {:lat 31.6978281, + :lon -93.7426612, + :house-number "12051", + :street "Louisiana 191", + :city "Noble", + :state-abbrev "LA", + :zip "71462"} + {:lat 28.2836389, + :lon -81.3443001, + :house-number "2429", + :street "Academy Circle East", + :city "Kissimmee", + :state-abbrev "FL", + :zip "34744"} + {:lat 40.5483657, + :lon -100.8981094, + :house-number "37054", + :street "Road 740a", + :city "Hayes Center", + :state-abbrev "NE", + :zip "69032"} + {:lat 37.6560882, + :lon -114.4971551, + :house-number "9393", + :street "U.S. 93", + :city "Caliente", + :state-abbrev "NV", + :zip "89008"} + {:lat 69.74188029999999, + :lon -163.0054384, + :house-number "218", + :street "Qigalik Avenue", + :city "Point Lay", + :state-abbrev "AK", + :zip "99759"} + {:lat 37.87920620000001, + :lon -84.8322154, + :house-number "818", + :street "McAfee Lane", + :city "Salvisa", + :state-abbrev "KY", + :zip "40372"} + {:lat 32.2026464, + :lon -96.9182896, + :house-number "321", + :street "Carolyn Lane", + :city "Italy", + :state-abbrev "TX", + :zip "76651"} + {:lat 31.302434, + :lon -103.8943771, + :house-number "662-748", + :street "County Road 229", + :city "Toyah", + :state-abbrev "TX", + :zip "79785"} + {:lat 45.8477683, + :lon -109.9181288, + :house-number "103", + :street "Thompson Lane", + :city "Big Timber", + :state-abbrev "MT", + :zip "59011"} + {:lat 34.7146621, + :lon -83.2255016, + :house-number "251", + :street "State Road S-37-90", + :city "Westminster", + :state-abbrev "SC", + :zip "29693"} + {:lat 42.5941011, + :lon -100.1437794, + :house-number "41616", + :street "U.S. 20", + :city "Johnstown", + :state-abbrev "NE", + :zip "69214"} + {:lat 30.5429963, + :lon -95.2862281, + :house-number "4702", + :street "Texas 150", + :city "New Waverly", + :state-abbrev "TX", + :zip "77358"} + {:lat 27.371848, + :lon -80.3909539, + :house-number "5238", + :street "Northwest Conley Drive", + :city "Port St. Lucie", + :state-abbrev "FL", + :zip "34986"} + {:lat 31.7543016, + :lon -86.8140238, + :house-number "5555", + :street "West Pettibone Road", + :city "Georgiana", + :state-abbrev "AL", + :zip "36033"} + {:lat 44.8285857, + :lon -116.4514573, + :house-number "2679", + :street "Fruitvale Glendale Road", + :city "Fruitvale", + :state-abbrev "ID", + :zip "83612"} + {:lat 36.6824561, + :lon -88.8682925, + :house-number "2072", + :street "Burkett Road", + :city "Clinton", + :state-abbrev "KY", + :zip "42031"} + {:lat 40.1728449, + :lon -99.4659295, + :house-number "71375", + :street "I Road", + :city "Orleans", + :state-abbrev "NE", + :zip "68966"} + {:lat 35.3121184, + :lon -79.5780175, + :house-number "192", + :street "Palomino Road", + :city "Carthage", + :state-abbrev "NC", + :zip "28327"} + {:lat 30.269541, + :lon -93.648978, + :house-number "812", + :street "Louisiana 109", + :city "Starks", + :state-abbrev "LA", + :zip "70661"} + {:lat 38.8532138, + :lon -83.67866110000001, + :house-number "500", + :street "Bob Moore Road", + :city "Winchester", + :state-abbrev "OH", + :zip "45697"} + {:lat 44.9181746, + :lon -84.512378, + :house-number "7912", + :street "Old State Road", + :city "Johannesburg", + :state-abbrev "MI", + :zip "49751"} + {:lat 41.5769338, + :lon -95.73876659999999, + :house-number "2946", + :street "296th Street", + :city "Logan", + :state-abbrev "IA", + :zip "51546"} + {:lat 37.640246, + :lon -113.358102, + :house-number "95", + :street "South Main Street", + :city "Cedar City", + :state-abbrev "UT", + :zip "84720"} + {:lat 39.136784, + :lon -123.340683, + :house-number "10355", + :street "Gap Road", + :city "Ukiah", + :state-abbrev "CA", + :zip "95482"} + {:lat 61.8699257, + :lon -158.1134929, + :house-number "500", + :street "Airport Road", + :city "Crooked Creek", + :state-abbrev "AK", + :zip "99575"} + {:lat 40.2588011, + :lon -83.18504399999999, + :house-number "4105-4155", + :street "Newhouse Road", + :city "Ostrander", + :state-abbrev "OH", + :zip "43061"} + {:lat 42.2350476, + :lon -95.1320111, + :house-number "3835", + :street "Lee Avenue", + :city "Wall Lake", + :state-abbrev "IA", + :zip "51466"} + {:lat 45.2974883, + :lon -98.66082109999999, + :house-number "14400-14498", + :street "377th Avenue", + :city "Mansfield", + :state-abbrev "SD", + :zip "57460"} + {:lat 44.1826097, + :lon -96.9977377, + :house-number "46060", + :street "221st Street", + :city "Nunda", + :state-abbrev "SD", + :zip "57050"} + {:lat 33.9292032, + :lon -86.4874645, + :house-number "345-457", + :street "Industrial Park Road", + :city "Oneonta", + :state-abbrev "AL", + :zip "35121"} + {:lat 42.2030725, + :lon -90.238552, + :house-number "11401-11799", + :street "Illinois 84", + :city "Savanna", + :state-abbrev "IL", + :zip "61074"} + {:lat 38.747285, + :lon -122.436815, + :house-number "25470", + :street "Guenoc Valley Road", + :city "Middletown", + :state-abbrev "CA", + :zip "95461"} + {:lat 45.9996513, + :lon -116.4095479, + :house-number "382", + :street "Moughmer Point Road", + :city "Cottonwood", + :state-abbrev "ID", + :zip "83522"} + {:lat 35.6316119, + :lon -79.74275, + :house-number "3422", + :street "Fairview Farm Road", + :city "Asheboro", + :state-abbrev "NC", + :zip "27205"} + {:lat 37.8924646, + :lon -78.9085721, + :house-number "213", + :street "Wade's Lane", + :city "Nellysford", + :state-abbrev "VA", + :zip "22958"} + {:lat 44.7915107, + :lon -74.3894583, + :house-number "344", + :street "County Highway 13", + :city "North Bangor", + :state-abbrev "NY", + :zip "12966"} + {:lat 41.3518155, + :lon -102.8610079, + :house-number "12207-12499", + :street "Road 50", + :city "Gurley", + :state-abbrev "NE", + :zip "69141"} + {:lat 44.2673703, + :lon -102.897699, + :house-number "15898", + :street "Elk Creek Road", + :city "New Underwood", + :state-abbrev "SD", + :zip "57761"} + {:lat 43.9761662, + :lon -72.0256862, + :house-number "103", + :street "Moses Hill Road", + :city "Piermont", + :state-abbrev "NH", + :zip "03779"} + {:lat 33.911886, + :lon -117.402565, + :house-number "2250", + :street "Saint Lawrence Street", + :city "Riverside", + :state-abbrev "CA", + :zip "92504"} + {:lat 31.3295569, + :lon -96.6387872, + :house-number "9009", + :street "Farm to Market Road 339", + :city "Kosse", + :state-abbrev "TX", + :zip "76653"} + {:lat 43.6163727, + :lon -96.00734779999999, + :house-number "12288-12364", + :street "260th Street", + :city "Adrian", + :state-abbrev "MN", + :zip "56110"} + {:lat 36.3584405, + :lon -82.5067803, + :house-number "456", + :street "Dean Archer Road", + :city "Jonesborough", + :state-abbrev "TN", + :zip "37659"} + {:lat 47.306509, + :lon -114.3258778, + :house-number "8", + :street "Bison Hill Lane", + :city "Dixon", + :state-abbrev "MT", + :zip "59831"} + {:lat 37.883686, + :lon -92.7506329, + :house-number "37128", + :street "Norfolk Drive", + :city "Eldridge", + :state-abbrev "MO", + :zip "65463"} + {:lat 42.955365, + :lon -108.876239, + :house-number "12719", + :street "Us Highway 287", + :city "Lander", + :state-abbrev "WY", + :zip "82520"} + {:lat 37.684682, + :lon -98.3192297, + :house-number "2715-2999", + :street "Northwest 110 Avenue", + :city "Cunningham", + :state-abbrev "KS", + :zip "67035"} + {:lat 37.119296, + :lon -83.3545248, + :house-number "590", + :street "Camp Creek Road", + :city "Wendover", + :state-abbrev "KY", + :zip "41775"} + {:lat 36.0206405, + :lon -91.7991847, + :house-number "535", + :street "Arkansas 58", + :city "Melbourne", + :state-abbrev "AR", + :zip "72556"} + {:lat 30.468483, + :lon -97.17470200000001, + :house-number "112", + :street "Farm to Market Road 112", + :city "Lexington", + :state-abbrev "TX", + :zip "78947"} + {:lat 43.619591, + :lon -116.780469, + :house-number "19426", + :street "Homedale Road", + :city "Caldwell", + :state-abbrev "ID", + :zip "83607"} + {:lat 42.00185099999999, + :lon -88.83209800000002, + :house-number "25591", + :street "Clare Road", + :city "Clare", + :state-abbrev "IL", + :zip "60111"} + {:lat 32.9684008, + :lon -89.72839739999999, + :house-number "3519", + :street "Attala Road 4163", + :city "Sallis", + :state-abbrev "MS", + :zip "39160"} + {:lat 47.5166676, + :lon -101.3110546, + :house-number "3350-3398", + :street "7th Street Northwest", + :city "Coleharbor", + :state-abbrev "ND", + :zip "58531"} + {:lat 38.3699447, + :lon -105.7543789, + :house-number "15587", + :street "U.S. 50", + :city "Coaldale", + :state-abbrev "CO", + :zip "81222"} + {:lat 44.295008, + :lon -99.20564069999999, + :house-number "21301-21397", + :street "349th Avenue", + :city "Ree Heights", + :state-abbrev "SD", + :zip "57371"} + {:lat 46.7837226, + :lon -96.7149852, + :house-number "7001-7499", + :street "40th Street South", + :city "Moorhead", + :state-abbrev "MN", + :zip "56560"} + {:lat 35.2324893, + :lon -97.12338249999999, + :house-number "22979", + :street "Fishmarket Road", + :city "Tecumseh", + :state-abbrev "OK", + :zip "74873"} + {:lat 38.71972, + :lon -104.0339759, + :house-number "14388", + :street "County Road 2", + :city "Rush", + :state-abbrev "CO", + :zip "80833"} + {:lat 45.9914359, + :lon -91.87771029999999, + :house-number "N10010", + :street "Mack Lake Road", + :city "Trego", + :state-abbrev "WI", + :zip "54888"} + {:lat 32.3649355, + :lon -108.6417541, + :house-number "11", + :street "Easy Street", + :city "Lordsburg", + :state-abbrev "NM", + :zip "88045"} + {:lat 30.9223605, + :lon -88.7025561, + :house-number "150", + :street "Erkhart Lane", + :city "Lucedale", + :state-abbrev "MS", + :zip "39452"} + {:lat 34.1874207, + :lon -118.3460122, + :house-number "3300", + :street "West Pacific Avenue", + :city "Burbank", + :state-abbrev "CA", + :zip "91505"} + {:lat 37.0346632, + :lon -84.97187, + :house-number "702", + :street "Parks Ridge Road", + :city "Russell Springs", + :state-abbrev "KY", + :zip "42642"} + {:lat 41.56147989999999, + :lon -101.5703272, + :house-number "605", + :street "Enfield Road", + :city "Arthur", + :state-abbrev "NE", + :zip "69121"} + {:lat 31.7017361, + :lon -92.19979389999999, + :house-number "440", + :street "McClendon Drive", + :city "Trout", + :state-abbrev "LA", + :zip "71371"} + {:lat 48.9376694, + :lon -102.5313156, + :house-number "8301-8355", + :street "105th Street Northwest", + :city "Portal", + :state-abbrev "ND", + :zip "58772"} + {:lat 40.3198608, + :lon -88.603081, + :house-number "2808", + :street "County Road 3425 East", + :city "Farmer City", + :state-abbrev "IL", + :zip "61842"} + {:lat 42.977263, + :lon -73.2044854, + :house-number "162", + :street "Lawrence Road", + :city "Shaftsbury", + :state-abbrev "VT", + :zip "05262"} + {:lat 46.0139827, + :lon -111.3499344, + :house-number "1083-1099", + :street "Broken Creek Road", + :city "Three Forks", + :state-abbrev "MT", + :zip "59752"} + {:lat 30.355476, + :lon -97.077581, + :house-number "5631", + :street "FM 1624 Road", + :city "Lexington", + :state-abbrev "TX", + :zip "78947"} + {:lat 36.6408739, + :lon -121.652028, + :house-number "80", + :street "Hunter Lane", + :city "Salinas", + :state-abbrev "CA", + :zip "93908"} + {:lat 42.691568, + :lon -85.76601199999999, + :house-number "3730", + :street "22nd Street", + :city "Dorr", + :state-abbrev "MI", + :zip "49323"} + {:lat 45.5956919, + :lon -91.7962239, + :house-number "1701-1791", + :street "27th Avenue", + :city "Rice Lake", + :state-abbrev "WI", + :zip "54868"} + {:lat 40.0893774, + :lon -80.94992909999999, + :house-number "69369", + :street "Lee Road", + :city "Saint Clairsville", + :state-abbrev "OH", + :zip "43950"} + {:lat 29.6556693, + :lon -91.3973822, + :house-number "505", + :street "Joey Street", + :city "Patterson", + :state-abbrev "LA", + :zip "70392"} + {:lat 48.33209799999999, + :lon -118.1551135, + :house-number "3126-3312", + :street "Washington 25", + :city "Gifford", + :state-abbrev "WA", + :zip "99131"} + {:lat 39.1427815, + :lon -93.9051427, + :house-number "10844", + :street "County Farm Road", + :city "Lexington", + :state-abbrev "MO", + :zip "64067"} + {:lat 29.3660443, + :lon -96.00103399999999, + :house-number "7654", + :street "County Road 121", + :city "Wharton", + :state-abbrev "TX", + :zip "77488"} + {:lat 33.7677247, + :lon -102.5037282, + :house-number "3600-3680", + :street "Lincoln", + :city "Levelland", + :state-abbrev "TX", + :zip "79336"} + {:lat 35.08888, + :lon -76.727883, + :house-number "373", + :street "Hidden Lane", + :city "Oriental", + :state-abbrev "NC", + :zip "28571"} + {:lat 46.4195137, + :lon -104.9640139, + :house-number "10241", + :street "U.S. 12", + :city "Ismay", + :state-abbrev "MT", + :zip "59336"} + {:lat 34.6336409, + :lon -86.0458366, + :house-number "2912", + :street "South Broad Street", + :city "Scottsboro", + :state-abbrev "AL", + :zip "35769"} + {:lat 41.0274016, + :lon -84.45712999999999, + :house-number "3000-3914", + :street "Road 151", + :city "Grover Hill", + :state-abbrev "OH", + :zip "45849"} + {:lat 41.309987, + :lon -93.62380399999999, + :house-number "9591", + :street "Nevada Street", + :city "Indianola", + :state-abbrev "IA", + :zip "50125"} + {:lat 42.6289348, + :lon -105.6969712, + :house-number "1981-2039", + :street "Spring Canyon Road", + :city "Douglas", + :state-abbrev "WY", + :zip "82633"} + {:lat 34.971496, + :lon -81.42881500000001, + :house-number "1362", + :street "Smith Woods Lane", + :city "Hickory Grove", + :state-abbrev "SC", + :zip "29717"} + {:lat 33.9081019, + :lon -100.9112754, + :house-number "272", + :street "Farm to Market 684", + :city "Roaring Springs", + :state-abbrev "TX", + :zip "79256"} + {:lat 35.36040130000001, + :lon -99.5277927, + :house-number "19471", + :street "East 1130 Road", + :city "Elk City", + :state-abbrev "OK", + :zip "73644"} + {:lat 41.039817, + :lon -93.72575499999999, + :house-number "2525", + :street "Kansas Street", + :city "Osceola", + :state-abbrev "IA", + :zip "50213"} + {:lat 31.2833428, + :lon -97.9102321, + :house-number "7957-7959", + :street "County Road 142", + :city "Gatesville", + :state-abbrev "TX", + :zip "76528"} + {:lat 33.9034747, + :lon -85.63498729999999, + :house-number "137-173", + :street "Pleasant Acres Trail", + :city "Piedmont", + :state-abbrev "AL", + :zip "36272"} + {:lat 35.733558, + :lon -117.9168199, + :house-number "3550", + :street "Grapevine Canyon Road", + :city "Inyokern", + :state-abbrev "CA", + :zip "93527"} + {:lat 44.095176, + :lon -88.237015, + :house-number "N5705", + :street "Vans Road", + :city "Hilbert", + :state-abbrev "WI", + :zip "54129"} + {:lat 39.7562657, + :lon -91.8451163, + :house-number "5201-5599", + :street "County Road 203", + :city "Hunnewell", + :state-abbrev "MO", + :zip "63443"} + {:lat 45.3314532, + :lon -117.96356, + :house-number "62000", + :street "Peach Road", + :city "La Grande", + :state-abbrev "OR", + :zip "97850"} + {:lat 42.4185731, + :lon -114.5851842, + :house-number "2331-2399", + :street "East 2900 North Road", + :city "Twin Falls", + :state-abbrev "ID", + :zip "83301"} + {:lat 43.1830975, + :lon -90.59240489999999, + :house-number "3444", + :street "Wisconsin 133", + :city "Blue River", + :state-abbrev "WI", + :zip "53518"} + {:lat 44.70918169999999, + :lon -95.3287785, + :house-number "77597", + :street "180th Street", + :city "Sacred Heart", + :state-abbrev "MN", + :zip "56285"} + {:lat 42.032549, + :lon -93.557581, + :house-number "4098", + :street "East 13th Street", + :city "Ames", + :state-abbrev "IA", + :zip "50010"} + {:lat 36.805957, + :lon -81.83017, + :house-number "30528", + :street "Old Saltworks Road", + :city "Meadowview", + :state-abbrev "VA", + :zip "24361"} + {:lat 34.7528691, + :lon -104.0238617, + :house-number "3401-3499", + :street "Q R Bh", + :city "McAlister", + :state-abbrev "NM", + :zip "88427"} + {:lat 42.3705256, + :lon -89.59859569999999, + :house-number "715", + :street "East Cedarville Road", + :city "Freeport", + :state-abbrev "IL", + :zip "61032"} + {:lat 44.3222966, + :lon -73.87596239999999, + :house-number "86", + :street "Nye Way", + :city "Wilmington", + :state-abbrev "NY", + :zip "12997"} + {:lat 56.29361249999999, + :lon -158.4068215, + :house-number "100", + :street "Old Cemetery Road", + :city "Chignik", + :state-abbrev "AK", + :zip "99564"} + {:lat 30.423704, + :lon -84.620513, + :house-number "2755", + :street "Cooks Landing Road", + :city "Quincy", + :state-abbrev "FL", + :zip "32351"} + {:lat 37.7368487, + :lon -83.0194695, + :house-number "663", + :street "Rockhouse Fork Road", + :city "Salyersville", + :state-abbrev "KY", + :zip "41465"} + {:lat 37.3628592, + :lon -82.338476, + :house-number "853", + :street "Abner Fork Road", + :city "Belcher", + :state-abbrev "KY", + :zip "41513"} + {:lat 44.8127198, + :lon -71.7810014, + :house-number "2154", + :street "McConnell Pond Road", + :city "Brighton", + :state-abbrev "VT", + :zip "05846"} + {:lat 42.7464915, + :lon -111.3920411, + :house-number "2101-2105", + :street "Slug Creek Road", + :city "Soda Springs", + :state-abbrev "ID", + :zip "83276"} + {:lat 45.5646244, + :lon -103.3030519, + :house-number "15002", + :street "Sd Highway 20", + :city "Reva", + :state-abbrev "SD", + :zip "57651"} + {:lat 38.3596886, + :lon -89.16878779999999, + :house-number "28000-28998", + :street "Hawaii Road", + :city "Ashley", + :state-abbrev "IL", + :zip "62808"} + {:lat 48.310733, + :lon -98.1306968, + :house-number "11501-11599", + :street "62nd Street Northeast", + :city "Adams", + :state-abbrev "ND", + :zip "58210"} + {:lat 34.8671568, + :lon -91.2059261, + :house-number "2002", + :street "U.S. 70", + :city "Brinkley", + :state-abbrev "AR", + :zip "72021"} + {:lat 39.9103911, + :lon -88.6523891, + :house-number "877", + :street "North 400 East Road", + :city "Milmine", + :state-abbrev "IL", + :zip "61855"} + {:lat 37.4425531, + :lon -94.65083899999999, + :house-number "575", + :street "South 250th Street", + :city "Pittsburg", + :state-abbrev "KS", + :zip "66762"} + {:lat 29.6211343, + :lon -82.90534199999999, + :house-number "5600-5698", + :street "Southwest 80th Street", + :city "Trenton", + :state-abbrev "FL", + :zip "32693"} + {:lat 46.6652679, + :lon -95.74012429999999, + :house-number "36087", + :street "South Rose Lake Road", + :city "Frazee", + :state-abbrev "MN", + :zip "56544"} + {:lat 43.3991513, + :lon -123.4633928, + :house-number "664", + :street "Hidden Meadows Lane", + :city "Oakland", + :state-abbrev "OR", + :zip "97462"} + {:lat 41.1820329, + :lon -87.72453, + :house-number "4036", + :street "North 8000 Road East", + :city "Bourbonnais", + :state-abbrev "IL", + :zip "60914"} + {:lat 39.355042, + :lon -91.09216579999999, + :house-number "15674", + :street "Highway Nn", + :city "Bowling Green", + :state-abbrev "MO", + :zip "63334"} + {:lat 36.9549673, + :lon -94.382311, + :house-number "7963", + :street "Lime Kiln Drive", + :city "Neosho", + :state-abbrev "MO", + :zip "64850"} + {:lat 36.5818107, + :lon -97.6177824, + :house-number "17502", + :street "East Blaine Road", + :city "Hunter", + :state-abbrev "OK", + :zip "74640"} + {:lat 32.4850005, + :lon -84.9905705, + :house-number "2205", + :street "2nd Avenue", + :city "Columbus", + :state-abbrev "GA", + :zip "31901"} + {:lat 33.4358134, + :lon -111.5525506, + :house-number "2200-2398", + :street "North Monterey Drive", + :city "Apache Junction", + :state-abbrev "AZ", + :zip "85120"} + {:lat 31.7535979, + :lon -109.431262, + :house-number "4002-4442", + :street "North Rucker Canyon Road", + :city "Elfrida", + :state-abbrev "AZ", + :zip "85610"} + {:lat 37.38131, + :lon -81.027579, + :house-number "114", + :street "Heart Lane", + :city "Princeton", + :state-abbrev "WV", + :zip "24739"} + {:lat 35.913898, + :lon -96.24968299999999, + :house-number "22978", + :street "E0760 Road", + :city "Kellyville", + :state-abbrev "OK", + :zip "74039"} + {:lat 40.033215, + :lon -104.4943459, + :house-number "2017", + :street "County Road 61", + :city "Keenesburg", + :state-abbrev "CO", + :zip "80643"} + {:lat 46.5015639, + :lon -112.1556568, + :house-number "120", + :street "North Fork Travis Creek Road", + :city "Clancy", + :state-abbrev "MT", + :zip "59634"} + {:lat 41.9220929, + :lon -104.0422838, + :house-number "20101-20161", + :street "Lagoon Road", + :city "Lyman", + :state-abbrev "NE", + :zip "69352"} + {:lat 37.0953898, + :lon -94.5471912, + :house-number "335", + :street "North Oak Avenue", + :city "Joplin", + :state-abbrev "MO", + :zip "64801"} + {:lat 33.8428536, + :lon -83.5384707, + :house-number "1241", + :street "Deer Trail", + :city "Bishop", + :state-abbrev "GA", + :zip "30621"} + {:lat 41.28943, + :lon -95.9103964, + :house-number "1414", + :street "Holiday Drive", + :city "Carter Lake", + :state-abbrev "IA", + :zip "51510"} + {:lat 34.1736976, + :lon -102.0168707, + :house-number "400-498", + :street "County Road 100", + :city "Plainview", + :state-abbrev "TX", + :zip "79072"} + {:lat 34.0200917, + :lon -95.04516249999999, + :house-number "1062", + :street "Kamrin Lane", + :city "Valliant", + :state-abbrev "OK", + :zip "74764"} + {:lat 42.92598, + :lon -72.679993, + :house-number "51", + :street "Williams Road", + :city "Newfane", + :state-abbrev "VT", + :zip "05345"} + {:lat 42.47665809999999, + :lon -71.2881029, + :house-number "162", + :street "Hartwell Road", + :city "Bedford", + :state-abbrev "MA", + :zip "01730"} + {:lat 42.4035075, + :lon -95.322609, + :house-number "2600-2698", + :street "Buchanan Avenue", + :city "Arthur", + :state-abbrev "IA", + :zip "51431"} + {:lat 28.1689285, + :lon -97.3016821, + :house-number "291", + :street "Boenig Road", + :city "Woodsboro", + :state-abbrev "TX", + :zip "78393"} + {:lat 42.825532, + :lon -72.11195359999999, + :house-number "69", + :street "Bixler Way", + :city "Jaffrey", + :state-abbrev "NH", + :zip "03452"} + {:lat 40.8992791, + :lon -90.3845278, + :house-number "1200-1250", + :street "Knox Road 300 East", + :city "Galesburg", + :state-abbrev "IL", + :zip "61401"} + {:lat 39.5794897, + :lon -84.79183789999999, + :house-number "9699", + :street "Kingrey Road", + :city "College Corner", + :state-abbrev "OH", + :zip "45003"} + {:lat 42.2946106, + :lon -99.07741209999999, + :house-number "46630", + :street "Benton Road", + :city "Atkinson", + :state-abbrev "NE", + :zip "68713"} + {:lat 41.008449, + :lon -81.4391969, + :house-number "2261", + :street "Wilson Drive", + :city "Akron", + :state-abbrev "OH", + :zip "44312"} + {:lat 33.7699665, + :lon -102.0486106, + :house-number "10302", + :street "County Road 430", + :city "Shallowater", + :state-abbrev "TX", + :zip "79363"} + {:lat 35.4492892, + :lon -99.4039615, + :house-number "10819", + :street "North 2010 Road", + :city "Elk City", + :state-abbrev "OK", + :zip "73644"} + {:lat 34.7891876, + :lon -80.901853, + :house-number "253-265", + :street "Landsford Road", + :city "Catawba", + :state-abbrev "SC", + :zip "29704"} + {:lat 45.21800390000001, + :lon -109.636886, + :house-number "1336", + :street "East Rosebud Road", + :city "Roscoe", + :state-abbrev "MT", + :zip "59071"} + {:lat 47.2702694, + :lon -100.6429055, + :house-number "42000", + :street "93rd Street Northeast", + :city "Regan", + :state-abbrev "ND", + :zip "58477"} + {:lat 46.7179037, + :lon -98.71281859999999, + :house-number "4801", + :street "83rd Avenue Southeast", + :city "Jamestown", + :state-abbrev "ND", + :zip "58401"} + {:lat 44.7383867, + :lon -94.2279976, + :house-number "11843", + :street "U.S. 212", + :city "Glencoe", + :state-abbrev "MN", + :zip "55336"} + {:lat 32.381986, + :lon -80.818321, + :house-number "121", + :street "Okatie Highway", + :city "Okatie", + :state-abbrev "SC", + :zip "29909"} + {:lat 43.771336, + :lon -72.670929, + :house-number "990", + :street "Mount Hunger Road", + :city "Bethel", + :state-abbrev "VT", + :zip "05032"} + {:lat 36.7586199, + :lon -85.1600334, + :house-number "276", + :street "State Highway 639", + :city "Albany", + :state-abbrev "KY", + :zip "42602"} + {:lat 35.4245949, + :lon -114.066813, + :house-number "823", + :street "West Travertine Way", + :city "Kingman", + :state-abbrev "AZ", + :zip "86409"} + {:lat 47.7894416, + :lon -99.995941, + :house-number "2700-2756", + :street "26th Street Northeast", + :city "Harvey", + :state-abbrev "ND", + :zip "58341"} + {:lat 39.4792955, + :lon -84.604654, + :house-number "1130-1390", + :street "West Taylor School Road", + :city "Hamilton", + :state-abbrev "OH", + :zip "45013"} + {:lat 32.6084958, + :lon -88.8978935, + :house-number "1096", + :street "Zion-Hampton Road", + :city "Collinsville", + :state-abbrev "MS", + :zip "39325"} + {:lat 42.38492400000001, + :lon -78.623586, + :house-number "5668", + :street "Town Line Road", + :city "West Valley", + :state-abbrev "NY", + :zip "14171"} + {:lat 44.0669826, + :lon -84.9218827, + :house-number "3560", + :street "Hamilton Road", + :city "Harrison", + :state-abbrev "MI", + :zip "48625"} + {:lat 38.9333055, + :lon -122.3335392, + :house-number "1460", + :street "California 16", + :city "Rumsey", + :state-abbrev "CA", + :zip "95679"} + {:lat 33.1813114, + :lon -94.57768589999999, + :house-number "724", + :street "County Road 2729", + :city "Marietta", + :state-abbrev "TX", + :zip "75566"} + {:lat 33.1996278, + :lon -80.44645919999999, + :house-number "207", + :street "South Railroad Avenue", + :city "Harleyville", + :state-abbrev "SC", + :zip "29448"} + {:lat 35.2066964, + :lon -101.744087, + :house-number "80", + :street "North Lakeside Drive", + :city "Amarillo", + :state-abbrev "TX", + :zip "79118"} + {:lat 44.1963215, + :lon -91.0606095, + :house-number "N3466", + :street "County Road H", + :city "Melrose", + :state-abbrev "WI", + :zip "54642"} + {:lat 41.5529653, + :lon -96.790846, + :house-number "687", + :street "County Road North", + :city "North Bend", + :state-abbrev "NE", + :zip "68649"} + {:lat 40.257934, + :lon -86.02649799999999, + :house-number "685", + :street "West 300 South", + :city "Tipton", + :state-abbrev "IN", + :zip "46072"} + {:lat 30.2058241, + :lon -97.8450228, + :house-number "8102-8104", + :street "Cattle Drive", + :city "Austin", + :state-abbrev "TX", + :zip "78749"} + {:lat 46.1292154, + :lon -95.7333798, + :house-number "35579", + :street "Rabbit Trail", + :city "Ashby", + :state-abbrev "MN", + :zip "56309"} + {:lat 43.319641, + :lon -74.006387, + :house-number "136", + :street "Hadley Hill Road", + :city "Hadley", + :state-abbrev "NY", + :zip "12835"} + {:lat 39.1319872, + :lon -104.4751631, + :house-number "10245", + :street "County Road 74-82", + :city "Peyton", + :state-abbrev "CO", + :zip "80831"} + {:lat 33.5267934, + :lon -86.72717759999999, + :house-number "1522", + :street "Cooper Hill Road", + :city "Birmingham", + :state-abbrev "AL", + :zip "35213"} + {:lat 31.915528, + :lon -97.7665016, + :house-number "2202", + :street "County Road 2130", + :city "Meridian", + :state-abbrev "TX", + :zip "76665"} + {:lat 34.3029723, + :lon -83.40866749999999, + :house-number "373-605", + :street "Georgia 63", + :city "Commerce", + :state-abbrev "GA", + :zip "30529"} + {:lat 37.0660571, + :lon -85.4978884, + :house-number "3542", + :street "Weed-Keltner Road", + :city "Columbia", + :state-abbrev "KY", + :zip "42728"} + {:lat 32.7374535, + :lon -87.1227475, + :house-number "85", + :street "Melton Lane", + :city "Marion", + :state-abbrev "AL", + :zip "36756"} + {:lat 38.3851647, + :lon -78.66171489999999, + :house-number "3675", + :street "Captain Yancey Road", + :city "Elkton", + :state-abbrev "VA", + :zip "22827"} + {:lat 33.1817593, + :lon -102.3888367, + :house-number "1200-1298", + :street "U.S. 380", + :city "Brownfield", + :state-abbrev "TX", + :zip "79316"} + {:lat 42.397973, + :lon -76.48916299999999, + :house-number "155", + :street "Compton Road", + :city "Ithaca", + :state-abbrev "NY", + :zip "14850"} + {:lat 47.74579550000001, + :lon -99.7179276, + :house-number "4001-4045", + :street "23rd Street Northeast", + :city "Harvey", + :state-abbrev "ND", + :zip "58341"} + {:lat 44.5367713, + :lon -88.08122159999999, + :house-number "1973-2050", + :street "Badgerland Drive", + :city "Howard", + :state-abbrev "WI", + :zip "54303"} + {:lat 43.3550494, + :lon -96.1572429, + :house-number "2043", + :street "Harrison Avenue", + :city "Rock Rapids", + :state-abbrev "IA", + :zip "51246"} + {:lat 31.595307, + :lon -97.432699, + :house-number "431", + :street "Mitchell Road", + :city "Valley Mills", + :state-abbrev "TX", + :zip "76689"} + {:lat 37.93648779999999, + :lon -77.8726105, + :house-number "8306", + :street "Jefferson Highway", + :city "Mineral", + :state-abbrev "VA", + :zip "23117"} + {:lat 37.3307973, + :lon -92.9157834, + :house-number "725", + :street "Woodlawn Street", + :city "Marshfield", + :state-abbrev "MO", + :zip "65706"} + {:lat 34.940116, + :lon -86.3316839, + :house-number "1800-1866", + :street "Upper Hurricane Creek Road", + :city "New Market", + :state-abbrev "AL", + :zip "35761"} + {:lat 44.1185313, + :lon -104.1529969, + :house-number "204", + :street "Pzinski Road", + :city "Newcastle", + :state-abbrev "WY", + :zip "82701"} + {:lat 35.898018, + :lon -88.3840419, + :house-number "510", + :street "Pate Road", + :city "Huntingdon", + :state-abbrev "TN", + :zip "38344"} + {:lat 47.599644, + :lon -120.151576, + :house-number "325", + :street "Stephanie Place", + :city "East Wenatchee", + :state-abbrev "WA", + :zip "98802"} + {:lat 45.06441909999999, + :lon -102.7382086, + :house-number "16154", + :street "Cedar Canyon Road", + :city "Faith", + :state-abbrev "SD", + :zip "57626"} + {:lat 31.546573, + :lon -99.009337, + :house-number "13500", + :street "County Road 226", + :city "Brownwood", + :state-abbrev "TX", + :zip "76801"} + {:lat 45.1370999, + :lon -112.956676, + :house-number "7100", + :street "Cold Spring Creek", + :city "Dillon", + :state-abbrev "MT", + :zip "59725"} + {:lat 31.8204453, + :lon -88.8549158, + :house-number "321-403", + :street "Sugar Hill Road", + :city "Shubuta", + :state-abbrev "MS", + :zip "39360"} + {:lat 44.4201989, + :lon -75.811244, + :house-number "1", + :street "Rabbit Island", + :city "Hammond", + :state-abbrev "NY", + :zip "13646"} + {:lat 47.6184896, + :lon -99.2657671, + :house-number "1400-1455", + :street "60th Avenue Northeast", + :city "Cathay", + :state-abbrev "ND", + :zip "58422"} + {:lat 37.2894401, + :lon -84.6981654, + :house-number "4840", + :street "Highway 328", + :city "Eubank", + :state-abbrev "KY", + :zip "42567"} + {:lat 46.771161, + :lon -94.447676, + :house-number "988", + :street "32nd Avenue Southwest", + :city "Backus", + :state-abbrev "MN", + :zip "56435"} + {:lat 43.7059915, + :lon -85.0254995, + :house-number "4614", + :street "North Rolland Road", + :city "Lake", + :state-abbrev "MI", + :zip "48632"} + {:lat 42.4106353, + :lon -102.860152, + :house-number "341", + :street "County Road 59", + :city "Alliance", + :state-abbrev "NE", + :zip "69301"} + {:lat 41.911865, + :lon -72.57235899999999, + :house-number "6", + :street "Mahoney Road", + :city "East Windsor", + :state-abbrev "CT", + :zip "06088"} + {:lat 30.8575926, + :lon -82.0110196, + :house-number "6017", + :street "U.S. 23", + :city "Folkston", + :state-abbrev "GA", + :zip "31537"} + {:lat 48.0111459, + :lon -111.0643719, + :house-number "2129-2679", + :street "Beaverslide Road", + :city "Carter", + :state-abbrev "MT", + :zip "59420"} + {:lat 32.374085, + :lon -97.916224, + :house-number "1121", + :street "Tolar Cemetery Road", + :city "Tolar", + :state-abbrev "TX", + :zip "76476"} + {:lat 41.45168719999999, + :lon -74.938189, + :house-number "200", + :street "German Hill Road", + :city "Shohola", + :state-abbrev "PA", + :zip "18458"} + {:lat 37.933424, + :lon -77.692847, + :house-number "17211", + :street "Tyler Station Road", + :city "Beaverdam", + :state-abbrev "VA", + :zip "23015"} + {:lat 39.010799, + :lon -86.80838299999999, + :house-number "822", + :street "South Coalmine Road", + :city "Bloomfield", + :state-abbrev "IN", + :zip "47424"} + {:lat 39.1563599, + :lon -121.1958697, + :house-number "14272-14502", + :street "Oak Meadow Road", + :city "Penn Valley", + :state-abbrev "CA", + :zip "95946"} + {:lat 32.668459, + :lon -85.596316, + :house-number "10551", + :street "County Road 188", + :city "Waverly", + :state-abbrev "AL", + :zip "36879"} + {:lat 44.986337, + :lon -92.239795, + :house-number "898", + :street "280th Street", + :city "Woodville", + :state-abbrev "WI", + :zip "54028"} + {:lat 43.4996263, + :lon -93.4964936, + :house-number "101-127", + :street "510th Street", + :city "Emmons", + :state-abbrev "MN", + :zip "56029"} + {:lat 35.0676406, + :lon -89.23785559999999, + :house-number "13245", + :street "Lagrange Road", + :city "Grand Junction", + :state-abbrev "TN", + :zip "38039"} + {:lat 33.27755, + :lon -85.9522191, + :house-number "47450", + :street "Alabama 77", + :city "Ashland", + :state-abbrev "AL", + :zip "36251"} + {:lat 39.5205902, + :lon -97.4111349, + :house-number "2750", + :street "Oat Road", + :city "Clyde", + :state-abbrev "KS", + :zip "66938"} + {:lat 30.3817592, + :lon -81.7560161, + :house-number "6854", + :street "Barney Road", + :city "Jacksonville", + :state-abbrev "FL", + :zip "32219"} + {:lat 31.2096126, + :lon -83.60047139999999, + :house-number "800-1110", + :street "Cool Springs Ellenton Road", + :city "Norman Park", + :state-abbrev "GA", + :zip "31771"} + {:lat 37.3628348, + :lon -89.7859974, + :house-number "569-799", + :street "Flint Lane", + :city "Burfordville", + :state-abbrev "MO", + :zip "63739"} + {:lat 44.2172864, + :lon -97.67311819999999, + :house-number "21801-21853", + :street "427th Avenue", + :city "Carthage", + :state-abbrev "SD", + :zip "57323"} + {:lat 33.723889, + :lon -84.0870659, + :house-number "7376", + :street "Union Grove Road", + :city "Lithonia", + :state-abbrev "GA", + :zip "30058"} + {:lat 32.779774, + :lon -92.9706936, + :house-number "1076", + :street "Featherston Road", + :city "Homer", + :state-abbrev "LA", + :zip "71040"} + {:lat 42.494867, + :lon -94.29595499999999, + :house-number "2047", + :street "Hayes Avenue", + :city "Fort Dodge", + :state-abbrev "IA", + :zip "50501"} + {:lat 43.1190903, + :lon -104.0590245, + :house-number "1832", + :street "Boner Road", + :city "Lusk", + :state-abbrev "WY", + :zip "82225"} + {:lat 39.072423, + :lon -94.83926, + :house-number "831", + :street "Lake Forest Drive", + :city "Bonner Springs", + :state-abbrev "KS", + :zip "66012"} + {:lat 36.597885, + :lon -78.63736670000002, + :house-number "6693", + :street "Virginia 49", + :city "Buffalo Junction", + :state-abbrev "VA", + :zip "24529"} + {:lat 46.4311829, + :lon -96.3830335, + :house-number "104", + :street "3rd Street Southwest", + :city "Rothsay", + :state-abbrev "MN", + :zip "56579"} + {:lat 40.7806966, + :lon -98.859955, + :house-number "10850", + :street "Gibbon Road", + :city "Gibbon", + :state-abbrev "NE", + :zip "68840"} + {:lat 36.8577857, + :lon -91.7594467, + :house-number "5322", + :street "County Road 2910", + :city "West Plains", + :state-abbrev "MO", + :zip "65775"} + {:lat 61.3830338, + :lon -145.2370339, + :house-number "56", + :street "Richardson Highway", + :city "Valdez", + :state-abbrev "AK", + :zip "99686"} + {:lat 45.4817989, + :lon -92.3051541, + :house-number "1846-1898", + :street "70th Street", + :city "Balsam Lake", + :state-abbrev "WI", + :zip "54810"} + {:lat 43.5812792, + :lon -72.2928555, + :house-number "597", + :street "Willow Brook Road", + :city "Plainfield", + :state-abbrev "NH", + :zip "03781"} + {:lat 30.369916, + :lon -98.942877, + :house-number "8216", + :street "U.S. 87", + :city "Fredericksburg", + :state-abbrev "TX", + :zip "78624"} + {:lat 43.7176676, + :lon -106.5450203, + :house-number "452", + :street "Sussex Road", + :city "Kaycee", + :state-abbrev "WY", + :zip "82639"} + {:lat 32.584375, + :lon -80.832729, + :house-number "9", + :street "Pocotaligo Place", + :city "Sheldon", + :state-abbrev "SC", + :zip "29941"} + {:lat 34.6600094, + :lon -79.52102119999999, + :house-number "701-713", + :street "State Road S-35-574", + :city "McColl", + :state-abbrev "SC", + :zip "29570"} + {:lat 40.2823179, + :lon -85.30899099999999, + :house-number "6704", + :street "425 East", + :city "Albany", + :state-abbrev "IN", + :zip "47320"} + {:lat 35.8201988, + :lon -79.0349435, + :house-number "3037", + :street "Jack Bennett Road", + :city "Chapel Hill", + :state-abbrev "NC", + :zip "27517"} + {:lat 32.0763808, + :lon -94.52793040000002, + :house-number "6132", + :street "Texas 315", + :city "Carthage", + :state-abbrev "TX", + :zip "75633"} + {:lat 35.393351, + :lon -78.637328, + :house-number "530", + :street "Oak Valley Farm Road", + :city "Coats", + :state-abbrev "NC", + :zip "27521"} + {:lat 46.4859426, + :lon -97.42375179999999, + :house-number "14300-14396", + :street "64th Street Southeast", + :city "Lisbon", + :state-abbrev "ND", + :zip "58054"} + {:lat 34.970647, + :lon -97.40248799999999, + :house-number "25316", + :street "180th Street", + :city "Purcell", + :state-abbrev "OK", + :zip "73080"} + {:lat 37.8900465, + :lon -120.3031323, + :house-number "26255", + :street "Richards Ranch Road", + :city "Sonora", + :state-abbrev "CA", + :zip "95370"} + {:lat 35.7432525, + :lon -86.59663859999999, + :house-number "805-1005", + :street "North Lane", + :city "Eagleville", + :state-abbrev "TN", + :zip "37060"} + {:lat 45.6515423, + :lon -123.0576768, + :house-number "18717", + :street "Northwest Dairy Creek Road", + :city "North Plains", + :state-abbrev "OR", + :zip "97133"} + {:lat 45.2094461, + :lon -94.81758029999999, + :house-number "16511", + :street "Sperry Lake Road", + :city "Atwater", + :state-abbrev "MN", + :zip "56209"} + {:lat 42.9955966, + :lon -92.81076689999999, + :house-number "2461-2499", + :street "Lancer Avenue", + :city "Marble Rock", + :state-abbrev "IA", + :zip "50653"} + {:lat 35.689693, + :lon -101.8942648, + :house-number "69", + :street "Ranch Road 1913", + :city "Channing", + :state-abbrev "TX", + :zip "79018"} + {:lat 30.060675, + :lon -81.66745999999999, + :house-number "2630", + :street "State Road 13", + :city "Fruit Cove", + :state-abbrev "FL", + :zip "32259"} + {:lat 37.91226, + :lon -90.41244999999999, + :house-number "2166", + :street "Fairview Church Road", + :city "Bonne Terre", + :state-abbrev "MO", + :zip "63628"} + {:lat 33.7797056, + :lon -80.85222259999999, + :house-number "358", + :street "Gully Horn Spring Trail", + :city "Saint Matthews", + :state-abbrev "SC", + :zip "29135"} + {:lat 38.5011246, + :lon -91.2857202, + :house-number "8439", + :street "Cedar Fork Road", + :city "New Haven", + :state-abbrev "MO", + :zip "63068"} + {:lat 34.086536, + :lon -85.95671700000001, + :house-number "4931", + :street "Lay Springs Road", + :city "Gadsden", + :state-abbrev "AL", + :zip "35904"} + {:lat 43.5288653, + :lon -92.935665, + :house-number "55501-55999", + :street "120 Street", + :city "Lyle", + :state-abbrev "MN", + :zip "55953"} + {:lat 39.1469534, + :lon -79.6904362, + :house-number "3635", + :street "Cheat Valley Highway", + :city "Parsons", + :state-abbrev "WV", + :zip "26287"} + {:lat 32.1063539, + :lon -92.2867517, + :house-number "787-891", + :street "Busby Road", + :city "Grayson", + :state-abbrev "LA", + :zip "71435"} + {:lat 39.232502, + :lon -121.6801351, + :house-number "3501-3629", + :street "Clark Road", + :city "Live Oak", + :state-abbrev "CA", + :zip "95953"} + {:lat 42.79668, + :lon -83.64693799999999, + :house-number "16073", + :street "Fish Lake Road", + :city "Holly", + :state-abbrev "MI", + :zip "48442"} + {:lat 46.93550270000001, + :lon -99.8257677, + :house-number "3300-3348", + :street "30th Avenue Southeast", + :city "Steele", + :state-abbrev "ND", + :zip "58482"} + {:lat 36.442696, + :lon -119.48305, + :house-number "35909", + :street "California 99", + :city "Kingsburg", + :state-abbrev "CA", + :zip "93631"} + {:lat 47.2240303, + :lon -100.3681224, + :house-number "37000-38298", + :street "262nd Street Northeast", + :city "Wing", + :state-abbrev "ND", + :zip "58494"} + {:lat 29.788228, + :lon -82.588786, + :house-number "14517", + :street "Northwest 232 Street", + :city "High Springs", + :state-abbrev "FL", + :zip "32643"} + {:lat 39.0523873, + :lon -123.4662723, + :house-number "18885", + :street "Philo Greenwood Road", + :city "Philo", + :state-abbrev "CA", + :zip "95466"} + {:lat 46.2231529, + :lon -106.0468284, + :house-number "1305", + :street "Moon Creek Road", + :city "Miles City", + :state-abbrev "MT", + :zip "59301"} + {:lat 33.3027656, + :lon -98.2232921, + :house-number "2472", + :street "Farm to Market Road 2190", + :city "Jacksboro", + :state-abbrev "TX", + :zip "76458"} + {:lat 42.0080615, + :lon -94.21360109999999, + :house-number "2200-2298", + :street "240th Street", + :city "Rippey", + :state-abbrev "IA", + :zip "50235"} + {:lat 30.1970222, + :lon -96.5706678, + :house-number "1265", + :street "Fm 1948 Road North", + :city "Burton", + :state-abbrev "TX", + :zip "77835"} + {:lat 34.6143871, + :lon -98.4092043, + :house-number "501-547", + :street "Northwest 14th Street", + :city "Lawton", + :state-abbrev "OK", + :zip "73507"} + {:lat 48.465387, + :lon -121.565696, + :house-number "54250", + :street "Rockport Cascade Road", + :city "Rockport", + :state-abbrev "WA", + :zip "98283"} + {:lat 41.676573, + :lon -73.828575, + :house-number "805", + :street "Freedom Plains Road", + :city "Poughkeepsie", + :state-abbrev "NY", + :zip "12603"} + {:lat 44.4809454, + :lon -93.6564215, + :house-number "32000-32486", + :street "195th Avenue", + :city "New Prague", + :state-abbrev "MN", + :zip "56071"} + {:lat 43.5590131, + :lon -116.7139159, + :house-number "16168-16246", + :street "Lake Shore Drive", + :city "Caldwell", + :state-abbrev "ID", + :zip "83607"} + {:lat 41.79876890000001, + :lon -81.0127329, + :house-number "7601-7699", + :street "North Ridge Road", + :city "Madison", + :state-abbrev "OH", + :zip "44057"} + {:lat 35.849231, + :lon -78.420177, + :house-number "120", + :street "Winchester Drive", + :city "Wendell", + :state-abbrev "NC", + :zip "27591"} + {:lat 45.9615434, + :lon -95.1162006, + :house-number "22762-23198", + :street "County Road 85", + :city "Osakis", + :state-abbrev "MN", + :zip "56360"} + {:lat 38.7638433, + :lon -122.832752, + :house-number "8770", + :street "Geysers Road", + :city "Geyserville", + :state-abbrev "CA", + :zip "95441"} + {:lat 48.4035234, + :lon -102.9192083, + :house-number "10389", + :street "68th Street Northwest", + :city "Tioga", + :state-abbrev "ND", + :zip "58852"} + {:lat 47.9751825, + :lon -114.1757164, + :house-number "740", + :street "Lutheran Camp Road", + :city "Lakeside", + :state-abbrev "MT", + :zip "59922"} + {:lat 40.9201276, + :lon -92.4560922, + :house-number "15164-15254", + :street "25th Street", + :city "Bloomfield", + :state-abbrev "IA", + :zip "52537"} + {:lat 41.5910944, + :lon -92.3653186, + :house-number "4852-4888", + :street "215th Street", + :city "Deep River", + :state-abbrev "IA", + :zip "52222"} + {:lat 30.404479, + :lon -84.992762, + :house-number "9893", + :street "Northwest 1st Street", + :city "Bristol", + :state-abbrev "FL", + :zip "32321"} + {:lat 40.54611269999999, + :lon -78.578299, + :house-number "232-486", + :street "Bottom Road", + :city "Ashville", + :state-abbrev "PA", + :zip "16613"} + {:lat 41.6681975, + :lon -85.9240149, + :house-number "56901-56999", + :street "Wynridge Circle", + :city "Elkhart", + :state-abbrev "IN", + :zip "46516"} + {:lat 29.88653279999999, + :lon -98.2046063, + :house-number "15288-15770", + :street "Farm to Market Road 306", + :city "Canyon Lake", + :state-abbrev "TX", + :zip "78133"} + {:lat 33.051018, + :lon -80.42415299999999, + :house-number "381", + :street "Cardinal Lane", + :city "Cottageville", + :state-abbrev "SC", + :zip "29435"} + {:lat 35.91618400000001, + :lon -94.035088, + :house-number "12678", + :street "South Whitehouse Road", + :city "Fayetteville", + :state-abbrev "AR", + :zip "72701"} + {:lat 37.494411, + :lon -76.932346, + :house-number "13901", + :street "Mountain Laurel Grove", + :city "Lanexa", + :state-abbrev "VA", + :zip "23089"} + {:lat 37.1418162, + :lon -120.4488007, + :house-number "2331", + :street "East Roosevelt Road", + :city "Merced", + :state-abbrev "CA", + :zip "95341"} + {:lat 44.6405197, + :lon -100.3246272, + :house-number "18959", + :street "291st Avenue", + :city "Pierre", + :state-abbrev "SD", + :zip "57501"} + {:lat 47.6043511, + :lon -110.8961427, + :house-number "2086-2186", + :street "Shepherd Crossing Road", + :city "Highwood", + :state-abbrev "MT", + :zip "59450"} + {:lat 48.8363749, + :lon -119.5416849, + :house-number "42", + :street "Offwhite Rock Road", + :city "Tonasket", + :state-abbrev "WA", + :zip "98855"} + {:lat 37.8920912, + :lon -83.46088739999999, + :house-number "210", + :street "Harrold Rose Road", + :city "Ezel", + :state-abbrev "KY", + :zip "41425"} + {:lat 46.1943079, + :lon -94.9694342, + :house-number "18280-18998", + :street "County Road 22", + :city "Clarissa", + :state-abbrev "MN", + :zip "56440"} + {:lat 33.1185029, + :lon -94.954814, + :house-number "1B", + :street "Southeast", + :city "Mount Pleasant", + :state-abbrev "TX", + :zip "75455"} + {:lat 35.54464189999999, + :lon -92.3177146, + :house-number "1352", + :street "Clella Circle", + :city "Bee Branch", + :state-abbrev "AR", + :zip "72013"} + {:lat 30.5741978, + :lon -90.2788624, + :house-number "27004", + :street "Teeney Weeney Lane", + :city "Folsom", + :state-abbrev "LA", + :zip "70437"} + {:lat 43.4391224, + :lon -118.5573503, + :house-number "65053", + :street "Crane Buchanan Road", + :city "Burns", + :state-abbrev "OR", + :zip "97720"} + {:lat 45.9334388, + :lon -95.5630416, + :house-number "12035-12483", + :street "39th Avenue Northwest", + :city "Garfield", + :state-abbrev "MN", + :zip "56332"} + {:lat 35.5732073, + :lon -79.3498272, + :house-number "1000-1398", + :street "Roberts Chapel Road", + :city "Goldston", + :state-abbrev "NC", + :zip "27252"} + {:lat 45.2415995, + :lon -97.8162801, + :house-number "14786", + :street "420th Avenue", + :city "Bristol", + :state-abbrev "SD", + :zip "57219"} + {:lat 30.91589999999999, + :lon -83.3531, + :house-number "4443", + :street "Mathis Mill Road", + :city "Valdosta", + :state-abbrev "GA", + :zip "31602"} + {:lat 37.4452308, + :lon -97.5756004, + :house-number "627", + :street "West 130th Avenue North", + :city "Conway Springs", + :state-abbrev "KS", + :zip "67031"} + {:lat 43.9979139, + :lon -91.20442670000001, + :house-number "W5941", + :street "M Johnson Road", + :city "Holmen", + :state-abbrev "WI", + :zip "54636"} + {:lat 40.6198602, + :lon -101.106952, + :house-number "74384", + :street "Avenue 359", + :city "Hayes Center", + :state-abbrev "NE", + :zip "69032"} + {:lat 46.4128687, + :lon -96.6998875, + :house-number "17701-17799", + :street "69th Street Southeast", + :city "Wahpeton", + :state-abbrev "ND", + :zip "58075"} + {:lat 31.8095954, + :lon -90.04842359999999, + :house-number "795-805", + :street "Shivers Road", + :city "Pinola", + :state-abbrev "MS", + :zip "39149"} + {:lat 31.0174011, + :lon -83.6686545, + :house-number "1301", + :street "Branch Road", + :city "Pavo", + :state-abbrev "GA", + :zip "31778"} + {:lat 62.05103500000001, + :lon -150.721023, + :house-number "50600", + :street "South Oilwell Road", + :city "Willow", + :state-abbrev "AK", + :zip "99688"} + {:lat 39.2767089, + :lon -103.4118139, + :house-number "32372", + :street "County Road 3g", + :city "Genoa", + :state-abbrev "CO", + :zip "80818"} + {:lat 37.1011789, + :lon -77.0112626, + :house-number "6040", + :street "Carsley Road", + :city "Waverly", + :state-abbrev "VA", + :zip "23890"} + {:lat 43.699798, + :lon -71.5391219, + :house-number "115", + :street "East Holderness Road", + :city "Holderness", + :state-abbrev "NH", + :zip "03245"} + {:lat 43.0698422, + :lon -93.67516789999999, + :house-number "2110-2198", + :street "Palm Avenue", + :city "Garner", + :state-abbrev "IA", + :zip "50438"} + {:lat 27.2793536, + :lon -98.32633179999999, + :house-number "1148-1472", + :street "County Road 238", + :city "Concepcion", + :state-abbrev "TX", + :zip "78349"} + {:lat 45.6421455, + :lon -93.67648679999999, + :house-number "6594-6850", + :street "130th Avenue", + :city "Princeton", + :state-abbrev "MN", + :zip "55371"} + {:lat 36.2373819, + :lon -96.18286499999999, + :house-number "4", + :street "Osage Pass", + :city "Sand Springs", + :state-abbrev "OK", + :zip "74063"} + {:lat 40.2783021, + :lon -90.5944555, + :house-number "16000-16498", + :street "County Road 00 North", + :city "Industry", + :state-abbrev "IL", + :zip "61440"} + {:lat 44.1457049, + :lon -91.4179048, + :house-number "W23274", + :street "German Coulee Lane", + :city "Galesville", + :state-abbrev "WI", + :zip "54630"} + {:lat 39.8587032, + :lon -76.1208754, + :house-number "244", + :street "Wesley Road", + :city "Quarryville", + :state-abbrev "PA", + :zip "17566"} + {:lat 43.254214, + :lon -87.97157399999999, + :house-number "4550", + :street "Highland Road", + :city "Thiensville", + :state-abbrev "WI", + :zip "53092"} + {:lat 33.4625962, + :lon -97.20268800000001, + :house-number "2225", + :street "FM 2848", + :city "Valley View", + :state-abbrev "TX", + :zip "76272"} + {:lat 39.906048, + :lon -84.30404, + :house-number "401", + :street "North Main Street", + :city "Englewood", + :state-abbrev "OH", + :zip "45322"} + {:lat 43.7038961, + :lon -89.6077495, + :house-number "3701-3793", + :street "1st Lane", + :city "Oxford", + :state-abbrev "WI", + :zip "53952"} + {:lat 34.8764255, + :lon -79.9231124, + :house-number "815", + :street "Cairo Road", + :city "Morven", + :state-abbrev "NC", + :zip "28119"} + {:lat 46.1814928, + :lon -89.7852925, + :house-number "6945", + :street "County Road P", + :city "Manitowish Waters", + :state-abbrev "WI", + :zip "54545"} + {:lat 48.412799, + :lon -103.836241, + :house-number "6894", + :street "146th Avenue Northwest", + :city "Williston", + :state-abbrev "ND", + :zip "58801"} + {:lat 47.6064546, + :lon -91.6451022, + :house-number "601-685", + :street "US for Service Highway 15", + :city "Isabella", + :state-abbrev "MN", + :zip "55607"} + {:lat 44.0120672, + :lon -83.8757284, + :house-number "1700-1762", + :street "Wyatt Road", + :city "Standish", + :state-abbrev "MI", + :zip "48658"} + {:lat 31.36881, + :lon -94.6864782, + :house-number "9", + :street "Farm to Market 842", + :city "Lufkin", + :state-abbrev "TX", + :zip "75901"} + {:lat 30.8445826, + :lon -84.61763049999999, + :house-number "114", + :street "Pine Street", + :city "Bainbridge", + :state-abbrev "GA", + :zip "39819"} + {:lat 43.200799, + :lon -77.502127, + :house-number "671", + :street "Strand Pond Circle", + :city "Webster", + :state-abbrev "NY", + :zip "14580"} + {:lat 42.755688, + :lon -105.088047, + :house-number "150", + :street "Wintermote Road", + :city "Douglas", + :state-abbrev "WY", + :zip "82633"} + {:lat 46.1078384, + :lon -95.6962695, + :house-number "38045", + :street "County Highway 64", + :city "Ashby", + :state-abbrev "MN", + :zip "56309"} + {:lat 35.499251, + :lon -78.6740839, + :house-number "1220", + :street "Chisenhall Road", + :city "Angier", + :state-abbrev "NC", + :zip "27501"} + {:lat 30.3864414, + :lon -88.69777719999999, + :house-number "5504", + :street "Lighthouse Circle", + :city "Gautier", + :state-abbrev "MS", + :zip "39553"} + {:lat 42.0179096, + :lon -96.80242539999999, + :house-number "1213-1299", + :street "T Road", + :city "Wisner", + :state-abbrev "NE", + :zip "68791"} + {:lat 38.8145304, + :lon -93.7133188, + :house-number "330-422", + :street "Northeast 151 Street", + :city "Warrensburg", + :state-abbrev "MO", + :zip "64093"} + {:lat 31.6480268, + :lon -81.875192, + :house-number "1369", + :street "Beechwood Drive", + :city "Jesup", + :state-abbrev "GA", + :zip "31545"} + {:lat 37.2711474, + :lon -93.6152909, + :house-number "289-499", + :street "Lawrence 1245", + :city "Ash Grove", + :state-abbrev "MO", + :zip "65604"} + {:lat 35.610449, + :lon -88.659165, + :house-number "3864", + :street "Beech Bluff Road", + :city "Beech Bluff", + :state-abbrev "TN", + :zip "38313"} + {:lat 41.6704094, + :lon -75.9024223, + :house-number "604", + :street "Quarry Road", + :city "Springville", + :state-abbrev "PA", + :zip "18844"} + {:lat 35.147371, + :lon -118.869354, + :house-number "2909", + :street "Herring Road", + :city "Arvin", + :state-abbrev "CA", + :zip "93203"} + {:lat 40.8147975, + :lon -73.7218001, + :house-number "448", + :street "East Shore Road", + :city "Kings Point", + :state-abbrev "NY", + :zip "11024"} + {:lat 46.68863899999999, + :lon -91.718226, + :house-number "2078", + :street "South Rudolphs Road", + :city "Maple", + :state-abbrev "WI", + :zip "54854"} + {:lat 42.5379342, + :lon -94.2998509, + :house-number "1700-1776", + :street "175th Street", + :city "Fort Dodge", + :state-abbrev "IA", + :zip "50501"} + {:lat 42.236748, + :lon -79.576594, + :house-number "8053", + :street "Hannum Road", + :city "Mayville", + :state-abbrev "NY", + :zip "14757"} + {:lat 44.1525906, + :lon -103.3311907, + :house-number "6700", + :street "Ridgeview Drive", + :city "Black Hawk", + :state-abbrev "SD", + :zip "57718"} + {:lat 43.7324515, + :lon -90.05891059999999, + :house-number "2622", + :street "County Road K", + :city "Mauston", + :state-abbrev "WI", + :zip "53948"} + {:lat 43.3452106, + :lon -108.5395218, + :house-number "376-586", + :street "North Muddy Road", + :city "Riverton", + :state-abbrev "WY", + :zip "82501"} + {:lat 40.9983, + :lon -96.02075900000001, + :house-number "311", + :street "South 18th Street", + :city "Plattsmouth", + :state-abbrev "NE", + :zip "68048"} + {:lat 37.3743483, + :lon -86.6127781, + :house-number "4623", + :street "Oak Ridge Road", + :city "Morgantown", + :state-abbrev "KY", + :zip "42261"} + {:lat 41.3678818, + :lon -79.4598893, + :house-number "971-1135", + :street "Gowdy Road", + :city "Venus", + :state-abbrev "PA", + :zip "16364"} + {:lat 47.9948865, + :lon -101.7226229, + :house-number "28201-28869", + :street "233rd Avenue Southwest", + :city "Ryder", + :state-abbrev "ND", + :zip "58779"} + {:lat 28.263284, + :lon -82.2049866, + :house-number "7247", + :street "Fort King Road", + :city "Zephyrhills", + :state-abbrev "FL", + :zip "33541"} + {:lat 36.0779349, + :lon -77.813864, + :house-number "9970", + :street "Watson Seed Farm Road", + :city "Whitakers", + :state-abbrev "NC", + :zip "27891"} + {:lat 39.9127209, + :lon -82.4083883, + :house-number "14462-14560", + :street "Township Road 1061", + :city "Thornville", + :state-abbrev "OH", + :zip "43076"} + {:lat 45.0796555, + :lon -122.0547712, + :house-number "59870-61498", + :street "Clackamas Highway", + :city "Estacada", + :state-abbrev "OR", + :zip "97023"} + {:lat 59.83919119999999, + :lon -151.6843778, + :house-number "29675", + :street "Komsolmol Street", + :city "Anchor Point", + :state-abbrev "AK", + :zip "99556"} + {:lat 44.1727998, + :lon -68.69405200000001, + :house-number "3", + :street "Burnt Cove Road", + :city "Stonington", + :state-abbrev "ME", + :zip "04681"} + {:lat 42.981382, + :lon -78.589715, + :house-number "10684", + :street "Main Street", + :city "Clarence", + :state-abbrev "NY", + :zip "14031"} + {:lat 40.3666232, + :lon -96.6132913, + :house-number "16467", + :street "South 82 Road", + :city "Pickrell", + :state-abbrev "NE", + :zip "68422"} + {:lat 37.2269742, + :lon -119.8703536, + :house-number "36728-36798", + :street "Road 606", + :city "Raymond", + :state-abbrev "CA", + :zip "93653"} + {:lat 41.46605, + :lon -100.4432582, + :house-number "198", + :street "County Road 60", + :city "Stapleton", + :state-abbrev "NE", + :zip "69163"} + {:lat 44.3227491, + :lon -90.0942797, + :house-number "7750", + :street "Michalik Lane", + :city "Wisconsin Rapids", + :state-abbrev "WI", + :zip "54495"} + {:lat 48.047291, + :lon -120.37601, + :house-number "25", + :street "Mile Creek Road", + :city "Chelan", + :state-abbrev "WA", + :zip "98816"} + {:lat 37.62904899999999, + :lon -97.736464, + :house-number "3401", + :street "South 343rd Street West", + :city "Cheney", + :state-abbrev "KS", + :zip "67025"} + {:lat 33.5128449, + :lon -97.44727350000001, + :house-number "5477", + :street "County Road 343", + :city "Forestburg", + :state-abbrev "TX", + :zip "76239"} + {:lat 32.6380644, + :lon -83.18637799999999, + :house-number "200", + :street "George Gray Rd", + :city "Danville", + :state-abbrev "GA", + :zip "31017"} + {:lat 35.511519, + :lon -80.68258399999999, + :house-number "6101", + :street "Wright Road", + :city "Kannapolis", + :state-abbrev "NC", + :zip "28081"} + {:lat 40.4097473, + :lon -90.89424960000001, + :house-number "8600", + :street "East 90th Street", + :city "Tennessee", + :state-abbrev "IL", + :zip "62374"} + {:lat 33.4477085, + :lon -80.6425399, + :house-number "175", + :street "Bronze Oak Court", + :city "Bowman", + :state-abbrev "SC", + :zip "29018"} + {:lat 36.5703326, + :lon -88.1879546, + :house-number "3261", + :street "Cherry Corner Road", + :city "Murray", + :state-abbrev "KY", + :zip "42071"} + {:lat 43.1903145, + :lon -74.82838389999999, + :house-number "465", + :street "Jerseyfield Road", + :city "Little Falls", + :state-abbrev "NY", + :zip "13365"} + {:lat 33.3826153, + :lon -117.1752984, + :house-number "395", + :street "Old Highway 395", + :city "Fallbrook", + :state-abbrev "CA", + :zip "92028"} + {:lat 34.0129499, + :lon -81.25298099999999, + :house-number "313", + :street "Newridge Road", + :city "Lexington", + :state-abbrev "SC", + :zip "29072"} + {:lat 34.534264, + :lon -80.3575819, + :house-number "99", + :street "Private Lane", + :city "Bethune", + :state-abbrev "SC", + :zip "29009"} + {:lat 39.9149203, + :lon -76.2466936, + :house-number "384", + :street "Lancaster Pike", + :city "New Providence", + :state-abbrev "PA", + :zip "17560"} + {:lat 44.5399699, + :lon -93.112949, + :house-number "2226", + :street "280th Street West", + :city "Northfield", + :state-abbrev "MN", + :zip "55057"} + {:lat 39.4671914, + :lon -96.5883061, + :house-number "18576-19008", + :street "Long Parkway Road", + :city "Olsburg", + :state-abbrev "KS", + :zip "66520"} + {:lat 39.92080929999999, + :lon -88.70722769999999, + :house-number "850-898", + :street "North 200 East Road", + :city "Cerro Gordo", + :state-abbrev "IL", + :zip "61818"} + {:lat 38.109368, + :lon -120.931083, + :house-number "9414", + :street "Warren Road", + :city "Valley Springs", + :state-abbrev "CA", + :zip "95252"} + {:lat 38.806465, + :lon -97.922608, + :house-number "1801", + :street "South Eff Creek Road", + :city "Brookville", + :state-abbrev "KS", + :zip "67425"} + {:lat 44.1058552, + :lon -89.94022939999999, + :house-number "1003-1075", + :street "County Highway Z", + :city "Arkdale", + :state-abbrev "WI", + :zip "54613"} + {:lat 36.8655304, + :lon -121.4419804, + :house-number "2136-2598", + :street "Wright Road", + :city "Hollister", + :state-abbrev "CA", + :zip "95023"} + {:lat 30.92620659999999, + :lon -93.9435721, + :house-number "766", + :street "County Road 293", + :city "Jasper", + :state-abbrev "TX", + :zip "75951"} + {:lat 34.9475899, + :lon -81.5415228, + :house-number "3057", + :street "State Road S-11-54", + :city "Gaffney", + :state-abbrev "SC", + :zip "29340"} + {:lat 37.1022152, + :lon -96.3491523, + :house-number "1076", + :street "Heritage Road", + :city "Sedan", + :state-abbrev "KS", + :zip "67361"} + {:lat 43.4055569, + :lon -93.6590501, + :house-number "44386", + :street "160th Avenue", + :city "Leland", + :state-abbrev "IA", + :zip "50453"} + {:lat 46.0147823, + :lon -84.0355428, + :house-number "15341-15999", + :street "East North Caribou Lake Road", + :city "De Tour Village", + :state-abbrev "MI", + :zip "49725"} + {:lat 31.9162303, + :lon -98.7254346, + :house-number "801", + :street "County Road 153", + :city "Comanche", + :state-abbrev "TX", + :zip "76442"} + {:lat 40.4061175, + :lon -83.60290359999999, + :house-number "2096-2398", + :street "Hamilton Street", + :city "West Mansfield", + :state-abbrev "OH", + :zip "43358"} + {:lat 33.56305150000001, + :lon -79.94783749999999, + :house-number "1096", + :street "McMillan Road", + :city "Greeleyville", + :state-abbrev "SC", + :zip "29056"} + {:lat 43.1217277, + :lon -91.70698449999999, + :house-number "1543-1583", + :street "U.S. 52", + :city "Castalia", + :state-abbrev "IA", + :zip "52133"} + {:lat 47.1021951, + :lon -118.3224433, + :house-number "1200-1298", + :street "North Benzel Road", + :city "Ritzville", + :state-abbrev "WA", + :zip "99169"} + {:lat 48.8441187, + :lon -105.5076285, + :house-number "481", + :street "French Lane", + :city "Scobey", + :state-abbrev "MT", + :zip "59263"} + {:lat 32.4712106, + :lon -93.7013435, + :house-number "300-1098", + :street "East Preston Avenue", + :city "Shreveport", + :state-abbrev "LA", + :zip "71105"} + {:lat 33.0973029, + :lon -84.2695906, + :house-number "248-298", + :street "East Milner Road", + :city "Zebulon", + :state-abbrev "GA", + :zip "30295"} + {:lat 43.0992814, + :lon -84.7479912, + :house-number "11890", + :street "Fitzpatrick Road", + :city "Fowler", + :state-abbrev "MI", + :zip "48835"} + {:lat 43.16911330000001, + :lon -76.3968835, + :house-number "1585-1593", + :street "West Genesee Road", + :city "Baldwinsville", + :state-abbrev "NY", + :zip "13027"} + {:lat 33.7403946, + :lon -89.64976779999999, + :house-number "735", + :street "Providence Road", + :city "Grenada", + :state-abbrev "MS", + :zip "38901"} + {:lat 36.0559801, + :lon -94.5138154, + :house-number "13683", + :street "Cincinnati Creek Road", + :city "Summers", + :state-abbrev "AR", + :zip "72769"} + {:lat 33.6022302, + :lon -87.9937408, + :house-number "6322-6726", + :street "County Road 49", + :city "Kennedy", + :state-abbrev "AL", + :zip "35574"} + {:lat 29.1215861, + :lon -96.7785579, + :house-number "11434", + :street "State Highway 111 North", + :city "Edna", + :state-abbrev "TX", + :zip "77957"} + {:lat 39.81943580000001, + :lon -88.6510236, + :house-number "151-299", + :street "North 500 East Road", + :city "Hammond", + :state-abbrev "IL", + :zip "61929"} + {:lat 38.4334314, + :lon -102.4183399, + :house-number "11500-11998", + :street "County Road 60", + :city "Sheridan Lake", + :state-abbrev "CO", + :zip "81071"} + {:lat 35.289251, + :lon -77.2919639, + :house-number "585", + :street "Core Creek Landing Road", + :city "Dover", + :state-abbrev "NC", + :zip "28526"} + {:lat 45.8947399, + :lon -104.30428, + :house-number "1131", + :street "Mill Iron Camp Crook Road", + :city "Ekalaka", + :state-abbrev "MT", + :zip "59324"} + {:lat 47.13607529999999, + :lon -105.3248207, + :house-number "640-726", + :street "Road 209", + :city "Terry", + :state-abbrev "MT", + :zip "59349"} + {:lat 29.3509994, + :lon -90.48580489999999, + :house-number "2211", + :street "South Madison Road", + :city "Montegut", + :state-abbrev "LA", + :zip "70377"} + {:lat 38.840185, + :lon -76.679547, + :house-number "801", + :street "Ben Jones Lane", + :city "Lothian", + :state-abbrev "MD", + :zip "20711"} + {:lat 36.068326, + :lon -87.443428, + :house-number "811", + :street "Furnace Hollow Road", + :city "Dickson", + :state-abbrev "TN", + :zip "37055"} + {:lat 44.3350127, + :lon -69.2917356, + :house-number "2383", + :street "Collinstown Road", + :city "Appleton", + :state-abbrev "ME", + :zip "04862"} + {:lat 39.1604578, + :lon -93.84383489999999, + :house-number "16638-16644", + :street "Linwood Lawn Drive", + :city "Lexington", + :state-abbrev "MO", + :zip "64067"} + {:lat 32.4329092, + :lon -90.657001, + :house-number "2307-3099", + :street "Davis Road", + :city "Vicksburg", + :state-abbrev "MS", + :zip "39183"} + {:lat 40.9060745, + :lon -87.415645, + :house-number "6240-6298", + :street "South 125 West", + :city "Brook", + :state-abbrev "IN", + :zip "47922"} + {:lat 33.5161402, + :lon -93.74224780000002, + :house-number "598", + :street "County Road 155", + :city "Fulton", + :state-abbrev "AR", + :zip "71838"} + {:lat 31.701504, + :lon -98.761461, + :house-number "1150", + :street "Farm to Market 590", + :city "Zephyr", + :state-abbrev "TX", + :zip "76890"} + {:lat 26.2982279, + :lon -97.6897418, + :house-number "17789-18511", + :street "Briggs Coleman Road", + :city "Harlingen", + :state-abbrev "TX", + :zip "78550"} + {:lat 42.088683, + :lon -91.975566, + :house-number "6425", + :street "27th Avenue", + :city "Vinton", + :state-abbrev "IA", + :zip "52349"} + {:lat 32.4656338, + :lon -99.8982004, + :house-number "7092", + :street "Interstate 20", + :city "Merkel", + :state-abbrev "TX", + :zip "79536"} + {:lat 40.3663465, + :lon -98.64840679999999, + :house-number "16800-17998", + :street "South Holstein Avenue", + :city "Holstein", + :state-abbrev "NE", + :zip "68950"} + {:lat 31.660927, + :lon -93.00252189999999, + :house-number "4463", + :street "Louisiana 494", + :city "Natchez", + :state-abbrev "LA", + :zip "71456"} + {:lat 38.800619, + :lon -95.31160899999999, + :house-number "424", + :street "East 1000 Road", + :city "Baldwin City", + :state-abbrev "KS", + :zip "66006"} + {:lat 38.693861, + :lon -86.54561, + :house-number "747", + :street "Liberty Church Road", + :city "Mitchell", + :state-abbrev "IN", + :zip "47446"} + {:lat 41.0578547, + :lon -92.54173519999999, + :house-number "11988", + :street "198th Avenue", + :city "Ottumwa", + :state-abbrev "IA", + :zip "52501"} + {:lat 29.3565161, + :lon -95.4306926, + :house-number "2247", + :street "East Fm 1462 Road", + :city "Rosharon", + :state-abbrev "TX", + :zip "77583"} + {:lat 29.8013585, + :lon -97.990414, + :house-number "2872-3380", + :street "South Old Bastrop Highway", + :city "San Marcos", + :state-abbrev "TX", + :zip "78666"} + {:lat 38.4707749, + :lon -89.37418699999999, + :house-number "17752", + :street "Tree Road", + :city "Hoyleton", + :state-abbrev "IL", + :zip "62803"} + {:lat 48.7510492, + :lon -104.8423034, + :house-number "306", + :street "Bolster Road", + :city "Plentywood", + :state-abbrev "MT", + :zip "59254"} + {:lat 43.5180785, + :lon -92.3385188, + :house-number "16037", + :street "110th Street", + :city "Le Roy", + :state-abbrev "MN", + :zip "55951"} + {:lat 41.8676153, + :lon -77.7600223, + :house-number "1820", + :street "Fox Hill Road", + :city "Ulysses", + :state-abbrev "PA", + :zip "16948"} + {:lat 44.7527495, + :lon -93.4898975, + :house-number "2528", + :street "Wood Duck Trail", + :city "Shakopee", + :state-abbrev "MN", + :zip "55379"} + {:lat 30.121331, + :lon -95.752162, + :house-number "25119", + :street "Sea Turtle Lane", + :city "Magnolia", + :state-abbrev "TX", + :zip "77355"} + {:lat 35.47772250000001, + :lon -77.0751494, + :house-number "266", + :street "Warren Chapel Church Road", + :city "Chocowinity", + :state-abbrev "NC", + :zip "27817"} + {:lat 44.4914681, + :lon -116.3766215, + :house-number "2216", + :street "Little Weiser River Road", + :city "Indian Valley", + :state-abbrev "ID", + :zip "83632"} + {:lat 36.3622857, + :lon -80.5073991, + :house-number "498", + :street "Whitaker Road", + :city "Pinnacle", + :state-abbrev "NC", + :zip "27043"} + {:lat 47.0520143, + :lon -93.22411190000001, + :house-number "11719", + :street "County Road 424", + :city "Swan River", + :state-abbrev "MN", + :zip "55784"} + {:lat 47.9614769, + :lon -101.3832517, + :house-number "26200", + :street "62nd Street Southwest", + :city "Douglas", + :state-abbrev "ND", + :zip "58735"} + {:lat 42.99556279999999, + :lon -77.628265, + :house-number "2262-2286", + :street "Rush Mendon Road", + :city "Rush", + :state-abbrev "NY", + :zip "14543"} + {:lat 43.9160787, + :lon -99.1229738, + :house-number "23918", + :street "353 Avenue", + :city "Pukwana", + :state-abbrev "SD", + :zip "57370"} + {:lat 33.2159641, + :lon -92.2871031, + :house-number "100-172", + :street "Christian Road", + :city "Strong", + :state-abbrev "AR", + :zip "71765"} + {:lat 42.650035, + :lon -73.77100100000001, + :house-number "65", + :street "Warren Avenue", + :city "Albany", + :state-abbrev "NY", + :zip "12203"} + {:lat 44.394321, + :lon -102.7478546, + :house-number "16555", + :street "Hope Road", + :city "New Underwood", + :state-abbrev "SD", + :zip "57761"} + {:lat 42.9113731, + :lon -92.19915350000001, + :house-number "3052-3098", + :street "Ridgeway Avenue", + :city "Fredericksburg", + :state-abbrev "IA", + :zip "50630"} + {:lat 40.8734659, + :lon -79.0551088, + :house-number "2082-2474", + :street "Beaver Drive", + :city "Rochester Mills", + :state-abbrev "PA", + :zip "15771"} + {:lat 43.3070802, + :lon -76.72386759999999, + :house-number "8569-8619", + :street "Blind Sodus Bay Road", + :city "Red Creek", + :state-abbrev "NY", + :zip "13143"} + {:lat 30.3173614, + :lon -98.62123969999999, + :house-number "1902", + :street "North Grape Creek Road", + :city "Fredericksburg", + :state-abbrev "TX", + :zip "78624"} + {:lat 33.8240855, + :lon -87.2232792, + :house-number "4230", + :street "Old Birmingham Highway", + :city "Jasper", + :state-abbrev "AL", + :zip "35501"} + {:lat 43.0468514, + :lon -96.9233312, + :house-number "29906-29980", + :street "464th Avenue", + :city "Centerville", + :state-abbrev "SD", + :zip "57014"} + {:lat 37.172418, + :lon -79.618302, + :house-number "1651", + :street "White House Road", + :city "Moneta", + :state-abbrev "VA", + :zip "24121"} + {:lat 39.759614, + :lon -79.951199, + :house-number "267", + :street "Holbert Stretch", + :city "Dilliner", + :state-abbrev "PA", + :zip "15327"} + {:lat 34.1455863, + :lon -90.8787686, + :house-number "516", + :street "Owens Road", + :city "Clarksdale", + :state-abbrev "MS", + :zip "38614"} + {:lat 43.8035213, + :lon -91.9421715, + :house-number "35552", + :street "Flag Road", + :city "Lanesboro", + :state-abbrev "MN", + :zip "55949"} + {:lat 32.966564, + :lon -114.461542, + :house-number "10882", + :street "Fishers Landing Road", + :city "Yuma", + :state-abbrev "AZ", + :zip "85365"} + {:lat 46.9988408, + :lon -118.726044, + :house-number "11", + :street "Roloff Road", + :city "Lind", + :state-abbrev "WA", + :zip "99341"} + {:lat 66.9746522, + :lon -160.4243696, + :house-number "9", + :street "Airport Road", + :city "Kiana", + :state-abbrev "AK", + :zip "99749"} + {:lat 39.287272, + :lon -122.999079, + :house-number "17625", + :street "Elk Mountain Road", + :city "Upper Lake", + :state-abbrev "CA", + :zip "95485"} + {:lat 43.9419724, + :lon -85.5025347, + :house-number "8500-8846", + :street "South 210th Avenue", + :city "Reed City", + :state-abbrev "MI", + :zip "49677"} + {:lat 38.5122, + :lon -95.17989999999999, + :house-number "1780", + :street "Oregon Terrace", + :city "Rantoul", + :state-abbrev "KS", + :zip "66079"} + {:lat 42.1665281, + :lon -94.2723487, + :house-number "1944", + :street "130th Street", + :city "Paton", + :state-abbrev "IA", + :zip "50217"} + {:lat 41.626379, + :lon -97.7899607, + :house-number "51020", + :street "400 Street", + :city "Saint Edward", + :state-abbrev "NE", + :zip "68660"} + {:lat 44.5120082, + :lon -85.3419543, + :house-number "11500-11998", + :street "East No 2 Road", + :city "Fife Lake", + :state-abbrev "MI", + :zip "49633"} + {:lat 41.119998, + :lon -93.193904, + :house-number "55122", + :street "290th Avenue", + :city "Chariton", + :state-abbrev "IA", + :zip "50049"} + {:lat 37.9156339, + :lon -106.0527209, + :house-number "15206", + :street "US Highway 285", + :city "Saguache", + :state-abbrev "CO", + :zip "81149"} + {:lat 30.6939267, + :lon -93.17928409999999, + :house-number "3358-3366", + :street "Harrington Drive", + :city "DeRidder", + :state-abbrev "LA", + :zip "70634"} + {:lat 29.3688762, + :lon -99.7113421, + :house-number "4391", + :street "FM 2690", + :city "Uvalde", + :state-abbrev "TX", + :zip "78801"} + {:lat 44.1423819, + :lon -83.6578768, + :house-number "3598-3608", + :street "Turner Road", + :city "Turner", + :state-abbrev "MI", + :zip "48765"} + {:lat 30.1235445, + :lon -97.8063278, + :house-number "1200", + :street "Estancia Parkway", + :city "Austin", + :state-abbrev "TX", + :zip "78748"} + {:lat 41.7180964, + :lon -80.10770459999999, + :house-number "21794", + :street "Erie Street", + :city "Saegertown", + :state-abbrev "PA", + :zip "16433"} + {:lat 44.1819768, + :lon -94.273006, + :house-number "49846", + :street "222nd Street", + :city "Lake Crystal", + :state-abbrev "MN", + :zip "56055"} + {:lat 40.5394955, + :lon -87.9628141, + :house-number "867", + :street "East 400 North Road", + :city "Cissna Park", + :state-abbrev "IL", + :zip "60924"} + {:lat 32.292281, + :lon -98.3573976, + :house-number "2283", + :street "County Road 406", + :city "Stephenville", + :state-abbrev "TX", + :zip "76401"} + {:lat 44.293816, + :lon -72.322372, + :house-number "6490", + :street "Route 232", + :city "Marshfield", + :state-abbrev "VT", + :zip "05658"} + {:lat 33.0120157, + :lon -80.9379314, + :house-number "3614", + :street "Willow Swamp Road", + :city "Islandton", + :state-abbrev "SC", + :zip "29929"} + {:lat 43.281389, + :lon -75.67327399999999, + :house-number "3180", + :street "Wheeler Hill Road", + :city "Blossvale", + :state-abbrev "NY", + :zip "13308"} + {:lat 47.2294814, + :lon -94.9404157, + :house-number "39001-39999", + :street "205th Avenue", + :city "Laporte", + :state-abbrev "MN", + :zip "56461"} + {:lat 60.0127269, + :lon -151.4986941, + :house-number "60648", + :street "Oil Well Road", + :city "Ninilchik", + :state-abbrev "AK", + :zip "99639"} + {:lat 39.3224735, + :lon -103.4050819, + :house-number "56251", + :street "County Road 36", + :city "Genoa", + :state-abbrev "CO", + :zip "80818"} + {:lat 48.9662213, + :lon -100.7398374, + :house-number "137-199", + :street "107th Street Northwest", + :city "Souris", + :state-abbrev "ND", + :zip "58783"} + {:lat 33.4579417, + :lon -81.967394, + :house-number "1228", + :street "Gordon Highway", + :city "Augusta", + :state-abbrev "GA", + :zip "30901"} + {:lat 36.2712265, + :lon -94.7352872, + :house-number "15200", + :street "State Highway 116", + :city "Colcord", + :state-abbrev "OK", + :zip "74338"} + {:lat 30.9326095, + :lon -89.566231, + :house-number "1147-1149", + :street "Stanford Lake Road", + :city "Poplarville", + :state-abbrev "MS", + :zip "39470"} + {:lat 37.2913799, + :lon -93.56379299999999, + :house-number "13140", + :street "West Farm Road 84", + :city "Ash Grove", + :state-abbrev "MO", + :zip "65604"} + {:lat 42.1301637, + :lon -95.9202078, + :house-number "15652", + :street "County Highway L20", + :city "Castana", + :state-abbrev "IA", + :zip "51010"} + {:lat 44.8608596, + :lon -85.662477, + :house-number "9331", + :street "East Summer Field Drive", + :city "Traverse City", + :state-abbrev "MI", + :zip "49684"} + {:lat 40.7873348, + :lon -98.3402758, + :house-number "9695-9999", + :street "South Locust Street", + :city "Doniphan", + :state-abbrev "NE", + :zip "68832"} + {:lat 37.1774093, + :lon -78.1800899, + :house-number "2499", + :street "Schutt Road", + :city "Burkeville", + :state-abbrev "VA", + :zip "23922"} + {:lat 38.0326862, + :lon -92.47617, + :house-number "344", + :street "Bentown Ridge Road", + :city "Brumley", + :state-abbrev "MO", + :zip "65017"} + {:lat 53.8940388, + :lon -166.5425726, + :house-number "104", + :street "Airport Drive", + :city "Unalaska", + :state-abbrev "AK", + :zip "99692"} + {:lat 39.981358, + :lon -122.273938, + :house-number "20520", + :street "No Name Road", + :city "Corning", + :state-abbrev "CA", + :zip "96021"} + {:lat 61.7038908, + :lon -150.1429048, + :house-number "27515", + :street "West Beryozova Drive", + :city "Willow", + :state-abbrev "AK", + :zip "99688"} + {:lat 42.0000164, + :lon -101.7142596, + :house-number "43863", + :street "Nebraska 2", + :city "Hyannis", + :state-abbrev "NE", + :zip "69350"} + {:lat 42.1885791, + :lon -84.3863756, + :house-number "1005", + :street "Floyd Avenue", + :city "Jackson", + :state-abbrev "MI", + :zip "49203"} + {:lat 37.4040114, + :lon -120.8017099, + :house-number "17315", + :street "Bloss Avenue", + :city "Delhi", + :state-abbrev "CA", + :zip "95315"} + {:lat 30.069701, + :lon -96.276923, + :house-number "11235", + :street "Jones Wilke Road", + :city "Chappell Hill", + :state-abbrev "TX", + :zip "77426"} + {:lat 41.2071017, + :lon -102.8070772, + :house-number "13001-13199", + :street "Road 30", + :city "Sidney", + :state-abbrev "NE", + :zip "69162"} + {:lat 41.291868, + :lon -85.694082, + :house-number "19", + :street "EMS B4 Lane", + :city "Pierceton", + :state-abbrev "IN", + :zip "46562"} + {:lat 41.1399049, + :lon -90.6578637, + :house-number "2151-2297", + :street "50th Avenue", + :city "Aledo", + :state-abbrev "IL", + :zip "61231"} + {:lat 37.814004, + :lon -96.97592399999999, + :house-number "7250", + :street "Southwest 10th Street", + :city "Towanda", + :state-abbrev "KS", + :zip "67144"} + {:lat 34.4809811, + :lon -89.04508229999999, + :house-number "1040", + :street "Bratton Road", + :city "New Albany", + :state-abbrev "MS", + :zip "38652"} + {:lat 30.08156009999999, + :lon -94.2732719, + :house-number "555", + :street "Sandringham", + :city "Beaumont", + :state-abbrev "TX", + :zip "77713"} + {:lat 40.6779436, + :lon -90.31499459999999, + :house-number "6503-6725", + :street "East Curve Road", + :city "Avon", + :state-abbrev "IL", + :zip "61415"} + {:lat 33.48275040000001, + :lon -113.1013913, + :house-number "3451", + :street "North 491st Avenue", + :city "Tonopah", + :state-abbrev "AZ", + :zip "85354"} + {:lat 42.811637, + :lon -93.4376149, + :house-number "1855", + :street "Dogwood Avenue", + :city "Alexander", + :state-abbrev "IA", + :zip "50420"} + {:lat 31.5672052, + :lon -93.5244615, + :house-number "1841", + :street "Shuteye Road", + :city "Many", + :state-abbrev "LA", + :zip "71449"} + {:lat 38.6181693, + :lon -92.1925908, + :house-number "12209", + :street "Renz Farm Road", + :city "Holts Summit", + :state-abbrev "MO", + :zip "65043"} + {:lat 42.3862177, + :lon -99.88273369999999, + :house-number "86683", + :street "Nebraska 7", + :city "Ainsworth", + :state-abbrev "NE", + :zip "69210"} + {:lat 34.39174879999999, + :lon -80.72494530000002, + :house-number "2301-2307", + :street "State Road S-28-697", + :city "Camden", + :state-abbrev "SC", + :zip "29020"} + {:lat 39.3701702, + :lon -105.3760011, + :house-number "19519-19527", + :street "Eos Mill Road", + :city "Pine", + :state-abbrev "CO", + :zip "80470"} + {:lat 48.5438796, + :lon -98.8271975, + :house-number "8216-8298", + :street "Cavalier-Ramsey County Line Road", + :city "Starkweather", + :state-abbrev "ND", + :zip "58377"} + {:lat 38.9132154, + :lon -105.7031712, + :house-number "26800-27176", + :street "Colorado 9", + :city "Guffey", + :state-abbrev "CO", + :zip "80820"} + {:lat 30.73168429999999, + :lon -83.488559, + :house-number "5314", + :street "Dewey Road", + :city "Valdosta", + :state-abbrev "GA", + :zip "31601"} + {:lat 42.940744, + :lon -77.56138399999999, + :house-number "1855", + :street "Hickory Lane", + :city "Honeoye Falls", + :state-abbrev "NY", + :zip "14472"} + {:lat 38.889392, + :lon -95.732744, + :house-number "4501", + :street "Southwest 97th Street", + :city "Wakarusa", + :state-abbrev "KS", + :zip "66546"} + {:lat 45.238332, + :lon -115.8133813, + :house-number "24833", + :street "Warren Wagon Road", + :city "McCall", + :state-abbrev "ID", + :zip "83638"} + {:lat 36.492435, + :lon -86.966335, + :house-number "3724", + :street "Hoods Branch Road", + :city "Springfield", + :state-abbrev "TN", + :zip "37172"} + {:lat 32.9092579, + :lon -103.2659859, + :house-number "24", + :street "Rosebud Lane", + :city "Lovington", + :state-abbrev "NM", + :zip "88260"} + {:lat 34.7794615, + :lon -89.2971718, + :house-number "3535", + :street "Little Snow Creek Road", + :city "Holly Springs", + :state-abbrev "MS", + :zip "38635"} + {:lat 40.9873587, + :lon -77.5623052, + :house-number "246", + :street "Hoy Road", + :city "Howard", + :state-abbrev "PA", + :zip "16841"} + {:lat 48.5014899, + :lon -122.1953009, + :house-number "25502", + :street "Hoehn Road", + :city "Sedro-Woolley", + :state-abbrev "WA", + :zip "98284"} + {:lat 36.1745853, + :lon -83.1806323, + :house-number "4142-4198", + :street "Parrottsville Road", + :city "Bybee", + :state-abbrev "TN", + :zip "37713"} + {:lat 44.0434558, + :lon -92.8169403, + :house-number "21301-21361", + :street "625th Street", + :city "Dodge Center", + :state-abbrev "MN", + :zip "55927"} + {:lat 31.8284709, + :lon -86.6683085, + :house-number "423", + :street "Manningham Loop", + :city "Greenville", + :state-abbrev "AL", + :zip "36037"} + {:lat 30.79443929999999, + :lon -96.29737539999999, + :house-number "9520", + :street "Dilly Shaw Tap Road", + :city "Bryan", + :state-abbrev "TX", + :zip "77808"} + {:lat 42.979323, + :lon -73.958883, + :house-number "1417", + :street "Peaceable Street", + :city "Ballston Spa", + :state-abbrev "NY", + :zip "12020"} + {:lat 33.6856683, + :lon -96.77413949999999, + :house-number "4084", + :street "Old Southmayd Road", + :city "Sherman", + :state-abbrev "TX", + :zip "75092"} + {:lat 40.5598283, + :lon -87.73477539999999, + :house-number "2001-2099", + :street "County Road 500 North", + :city "Wellington", + :state-abbrev "IL", + :zip "60973"} + {:lat 33.6263821, + :lon -91.4221505, + :house-number "308", + :street "Baughman Road", + :city "McGehee", + :state-abbrev "AR", + :zip "71654"} + {:lat 41.367849, + :lon -73.1448755, + :house-number "5", + :street "Sunset Terrace", + :city "Seymour", + :state-abbrev "CT", + :zip "06483"} + {:lat 33.2505184, + :lon -80.7431324, + :house-number "1703-1825", + :street "Banbury Drive", + :city "Branchville", + :state-abbrev "SC", + :zip "29432"} + {:lat 39.614977, + :lon -84.792006, + :house-number "8774", + :street "Fairhaven College CRNR Road", + :city "College Corner", + :state-abbrev "OH", + :zip "45003"} + {:lat 29.80548, + :lon -81.580928, + :house-number "108", + :street "Creek Lane", + :city "Palatka", + :state-abbrev "FL", + :zip "32177"} + {:lat 36.6515872, + :lon -121.6238129, + :house-number "1428", + :street "Abbott Street", + :city "Salinas", + :state-abbrev "CA", + :zip "93901"} + {:lat 42.6929731, + :lon -78.0005899, + :house-number "3980", + :street "Middle Reservation Road", + :city "Perry", + :state-abbrev "NY", + :zip "14530"} + {:lat 48.536864, + :lon -109.697746, + :house-number "1335", + :street "Washington Avenue", + :city "Havre", + :state-abbrev "MT", + :zip "59501"} + {:lat 37.5975641, + :lon -79.0825886, + :house-number "134-164", + :street "Berry Hill Lane", + :city "Amherst", + :state-abbrev "VA", + :zip "24521"} + {:lat 41.17628510000001, + :lon -105.8302223, + :house-number "61", + :street "Mason Lane", + :city "Laramie", + :state-abbrev "WY", + :zip "82070"} + {:lat 45.1875366, + :lon -92.04145539999999, + :house-number "E2302", + :street "County Road V", + :city "Prairie Farm", + :state-abbrev "WI", + :zip "54762"} + {:lat 46.215794, + :lon -104.6251721, + :house-number "312", + :street "Lame Jones Trail", + :city "Plevna", + :state-abbrev "MT", + :zip "59344"} + {:lat 29.7607019, + :lon -97.7324809, + :house-number "1395", + :street "Callihan Road", + :city "Luling", + :state-abbrev "TX", + :zip "78648"} + {:lat 38.6832382, + :lon -106.9923199, + :house-number "7911", + :street "County Road 730", + :city "Gunnison", + :state-abbrev "CO", + :zip "81230"} + {:lat 42.065906, + :lon -91.503693, + :house-number "2818", + :street "Jordans Grove Road", + :city "Marion", + :state-abbrev "IA", + :zip "52302"} + {:lat 34.0908609, + :lon -116.269298, + :house-number "9000", + :street "Rock Haven Road", + :city "Joshua Tree", + :state-abbrev "CA", + :zip "92252"} + {:lat 37.1489336, + :lon -100.1449543, + :house-number "23000-23524", + :street "29 Road", + :city "Meade", + :state-abbrev "KS", + :zip "67864"} + {:lat 40.830092, + :lon -78.328512, + :house-number "1512", + :street "Spring Street", + :city "Houtzdale", + :state-abbrev "PA", + :zip "16651"} + {:lat 40.8326643, + :lon -102.0270649, + :house-number "75915", + :street "Road 312", + :city "Venango", + :state-abbrev "NE", + :zip "69168"} + {:lat 31.2528411, + :lon -93.4338884, + :house-number "230-240", + :street "Farris Cemetary Road", + :city "Anacoco", + :state-abbrev "LA", + :zip "71403"} + {:lat 43.1328381, + :lon -74.11540099999999, + :house-number "551", + :street "Fayville Road", + :city "Broadalbin", + :state-abbrev "NY", + :zip "12025"} + {:lat 61.9880241, + :lon -147.0136133, + :house-number "8110", + :street "Glenn Highway", + :city "Glennallen", + :state-abbrev "AK", + :zip "99588"} + {:lat 30.5900209, + :lon -104.0698574, + :house-number "218", + :street "Cedar Trail", + :city "Fort Davis", + :state-abbrev "TX", + :zip "79734"} + {:lat 37.0965383, + :lon -86.13427329999999, + :house-number "19366", + :street "Louisville Road", + :city "Park City", + :state-abbrev "KY", + :zip "42160"} + {:lat 41.5211048, + :lon -79.9952654, + :house-number "25940-27498", + :street "Stockton Corners Road", + :city "Cochranton", + :state-abbrev "PA", + :zip "16314"} + {:lat 28.913617, + :lon -95.97610309999999, + :house-number "60", + :street "Avenue F North", + :city "Bay City", + :state-abbrev "TX", + :zip "77414"} + {:lat 47.4792833, + :lon -98.7133154, + :house-number "401-499", + :street "86th Avenue Northeast", + :city "Glenfield", + :state-abbrev "ND", + :zip "58443"} + {:lat 46.9018564, + :lon -117.7551321, + :house-number "2551", + :street "Storment Road", + :city "Endicott", + :state-abbrev "WA", + :zip "99125"} + {:lat 41.1757901, + :lon -86.3298392, + :house-number "14036", + :street "Indiana 110", + :city "Rochester", + :state-abbrev "IN", + :zip "46975"} + {:lat 43.101427, + :lon -78.2494959, + :house-number "6444", + :street "Fisher Road", + :city "Oakfield", + :state-abbrev "NY", + :zip "14125"} + {:lat 48.6786822, + :lon -113.8184402, + :house-number "16809", + :street "Going-to-the-Sun Road", + :city "West Glacier", + :state-abbrev "MT", + :zip "59936"} + {:lat 32.025204, + :lon -86.6599078, + :house-number "5187", + :street "County Road 45", + :city "Fort Deposit", + :state-abbrev "AL", + :zip "36032"} + {:lat 43.2662019, + :lon -96.19779609999999, + :house-number "2601-2699", + :street "Grant Avenue", + :city "Doon", + :state-abbrev "IA", + :zip "51235"} + {:lat 46.2787702, + :lon -111.2326623, + :house-number "92", + :street "Upper Greyson Creek Road", + :city "Townsend", + :state-abbrev "MT", + :zip "59644"} + {:lat 46.5612182, + :lon -117.5284755, + :house-number "140", + :street "South Deadman Road", + :city "Pomeroy", + :state-abbrev "WA", + :zip "99347"} + {:lat 38.5058074, + :lon -88.7177379, + :house-number "818-898", + :street "County Road 2300 East", + :city "Iuka", + :state-abbrev "IL", + :zip "62849"} + {:lat 30.9197909, + :lon -94.1673134, + :house-number "1898", + :street "County Road 027", + :city "Jasper", + :state-abbrev "TX", + :zip "75951"} + {:lat 38.8271655, + :lon -98.4199407, + :house-number "411-467", + :street "Avenue D", + :city "Wilson", + :state-abbrev "KS", + :zip "67490"} + {:lat 43.2543472, + :lon -94.0150718, + :house-number "2208", + :street "340th Street", + :city "Titonka", + :state-abbrev "IA", + :zip "50480"} + {:lat 33.1439027, + :lon -94.74486329999999, + :house-number "1564", + :street "County Road 3309", + :city "Omaha", + :state-abbrev "TX", + :zip "75571"} + {:lat 47.459075, + :lon -112.8400399, + :house-number "2335", + :street "Mule Creek Road", + :city "Augusta", + :state-abbrev "MT", + :zip "59410"} + {:lat 39.0120209, + :lon -83.81941479999999, + :house-number "13615-13827", + :street "Matthews Road", + :city "Sardinia", + :state-abbrev "OH", + :zip "45171"} + {:lat 37.990355, + :lon -85.4719301, + :house-number "3900", + :street "Love Lane", + :city "Coxs Creek", + :state-abbrev "KY", + :zip "40013"} + {:lat 30.78410239999999, + :lon -96.24686849999999, + :house-number "11459", + :street "Oak Lake Road", + :city "Bryan", + :state-abbrev "TX", + :zip "77808"} + {:lat 37.570637, + :lon -119.978165, + :house-number "5300", + :street "Rumley Mine Road", + :city "Midpines", + :state-abbrev "CA", + :zip "95345"} + {:lat 35.3526971, + :lon -97.10998529999999, + :house-number "14508", + :street "North South 331", + :city "Shawnee", + :state-abbrev "OK", + :zip "74801"} + {:lat 30.557133, + :lon -97.37338349999999, + :house-number "7197-7805", + :street "Farm to Market Road 619", + :city "Taylor", + :state-abbrev "TX", + :zip "76574"} + {:lat 41.6179294, + :lon -93.46465049999999, + :house-number "2627-2687", + :street "Northeast 72nd Street", + :city "Altoona", + :state-abbrev "IA", + :zip "50009"} + {:lat 35.025246, + :lon -89.559371, + :house-number "1437", + :street "Knox Road", + :city "Rossville", + :state-abbrev "TN", + :zip "38066"} + {:lat 41.86593149999999, + :lon -90.72437719999999, + :house-number "1896", + :street "215th Street", + :city "Grand Mound", + :state-abbrev "IA", + :zip "52751"} + {:lat 42.7013214, + :lon -97.08685129999999, + :house-number "57207", + :street "888th Road", + :city "Wynot", + :state-abbrev "NE", + :zip "68792"} + {:lat 31.184549, + :lon -89.451286, + :house-number "223", + :street "Haden Road", + :city "Purvis", + :state-abbrev "MS", + :zip "39475"} + {:lat 43.91931470000001, + :lon -95.9950034, + :house-number "351", + :street "Valley Road", + :city "Chandler", + :state-abbrev "MN", + :zip "56122"} + {:lat 41.401808, + :lon -82.141712, + :house-number "42270", + :street "Griswold Road", + :city "Elyria", + :state-abbrev "OH", + :zip "44035"} + {:lat 39.625267, + :lon -95.006856, + :house-number "12920", + :street "Southwest 81st Road", + :city "Rushville", + :state-abbrev "MO", + :zip "64484"} + {:lat 26.4487202, + :lon -81.2721499, + :house-number "2424", + :street "Thorp Road", + :city "Immokalee", + :state-abbrev "FL", + :zip "34142"} + {:lat 46.3331733, + :lon -91.53325819999999, + :house-number "52000-52086", + :street "Wisconsin 27", + :city "Solon Springs", + :state-abbrev "WI", + :zip "54873"} + {:lat 32.7212981, + :lon -114.3471867, + :house-number "7115", + :street "South Avenue 17 East", + :city "Yuma", + :state-abbrev "AZ", + :zip "85365"} + {:lat 35.492303, + :lon -78.22756799999999, + :house-number "481", + :street "Wc Braswell Road", + :city "Selma", + :state-abbrev "NC", + :zip "27576"} + {:lat 47.903819, + :lon -94.09948299999999, + :house-number "11802", + :street "South Co Road 6", + :city "Mizpah", + :state-abbrev "MN", + :zip "56660"} + {:lat 47.1241259, + :lon -111.550147, + :house-number "970", + :street "Adel Road", + :city "Cascade", + :state-abbrev "MT", + :zip "59421"} + {:lat 38.8777388, + :lon -107.1770073, + :house-number "12492-13498", + :street "County Road 12", + :city "Somerset", + :state-abbrev "CO", + :zip "81434"} + {:lat 48.4847586, + :lon -114.3989592, + :house-number "67", + :street "Eagle Creek Trail", + :city "Whitefish", + :state-abbrev "MT", + :zip "59937"} + {:lat 26.2078681, + :lon -98.3643379, + :house-number "3104", + :street "Humberto Garza Junior Street", + :city "Mission", + :state-abbrev "TX", + :zip "78572"} + {:lat 43.4984703, + :lon -84.81185909999999, + :house-number "9756", + :street "South Lincoln Road", + :city "Shepherd", + :state-abbrev "MI", + :zip "48883"} + {:lat 38.0396349, + :lon -78.313039, + :house-number "478", + :street "Campbell Road", + :city "Keswick", + :state-abbrev "VA", + :zip "22947"} + {:lat 39.4381138, + :lon -93.8289722, + :house-number "46801", + :street "East 192nd Street", + :city "Richmond", + :state-abbrev "MO", + :zip "64085"} + {:lat 43.9437577, + :lon -94.2440666, + :house-number "13478", + :street "507th Avenue", + :city "Vernon Center", + :state-abbrev "MN", + :zip "56090"} + {:lat 38.885884, + :lon -95.095698, + :house-number "1019", + :street "East 2200 Road", + :city "Eudora", + :state-abbrev "KS", + :zip "66025"} + {:lat 43.3300296, + :lon -82.53618829999999, + :house-number "3384", + :street "Old Orchard Lane", + :city "Lexington", + :state-abbrev "MI", + :zip "48450"} + {:lat 42.5477132, + :lon -85.9206291, + :house-number "3837", + :street "Forest Trail", + :city "Allegan", + :state-abbrev "MI", + :zip "49010"} + {:lat 37.3668518, + :lon -120.6647798, + :house-number "5301-5315", + :street "Olive Avenue", + :city "Atwater", + :state-abbrev "CA", + :zip "95301"} + {:lat 36.411321, + :lon -95.750602, + :house-number "13996", + :street "U.S Highway 169", + :city "Oologah", + :state-abbrev "OK", + :zip "74053"} + {:lat 39.1290762, + :lon -78.88821709999999, + :house-number "3936", + :street "Ashton Woods Drive", + :city "Moorefield", + :state-abbrev "WV", + :zip "26836"} + {:lat 43.0902834, + :lon -98.9564362, + :house-number "36150", + :street "Eldeen Avenue", + :city "Bonesteel", + :state-abbrev "SD", + :zip "57317"} + {:lat 47.150362, + :lon -120.872251, + :house-number "470", + :street "Thornton View Road", + :city "Cle Elum", + :state-abbrev "WA", + :zip "98922"} + {:lat 36.259424, + :lon -79.161563, + :house-number "1986", + :street "North Carolina 49", + :city "Prospect Hill", + :state-abbrev "NC", + :zip "27314"} + {:lat 43.3785313, + :lon -93.675874, + :house-number "42473", + :street "150th Avenue", + :city "Leland", + :state-abbrev "IA", + :zip "50453"} + {:lat 44.403199, + :lon -93.00742400000001, + :house-number "37929", + :street "20th Avenue", + :city "Dennison", + :state-abbrev "MN", + :zip "55018"} + {:lat 33.8434868, + :lon -95.90124290000001, + :house-number "1987", + :street "Farm to Market 79", + :city "Telephone", + :state-abbrev "TX", + :zip "75488"} + {:lat 38.3975096, + :lon -83.6592131, + :house-number "7219", + :street "Morehead Road", + :city "Flemingsburg", + :state-abbrev "KY", + :zip "41041"} + {:lat 39.0705916, + :lon -80.907283, + :house-number "864", + :street "Sunny Hollow Road", + :city "Smithville", + :state-abbrev "WV", + :zip "26178"} + {:lat 33.704565, + :lon -102.5888482, + :house-number "4151-4199", + :street "Kenya Road", + :city "Levelland", + :state-abbrev "TX", + :zip "79336"} + {:lat 33.472702, + :lon -82.48024939999999, + :house-number "1496-1502", + :street "Harrison Road Southeast", + :city "Thomson", + :state-abbrev "GA", + :zip "30824"} + {:lat 33.2287456, + :lon -99.195262, + :house-number "1551", + :street "U.S. 283", + :city "Throckmorton", + :state-abbrev "TX", + :zip "76483"} + {:lat 39.1898386, + :lon -93.3398946, + :house-number "27335", + :street "Durango Avenue", + :city "Malta Bend", + :state-abbrev "MO", + :zip "65339"} + {:lat 40.3536303, + :lon -97.20280249999999, + :house-number "1900", + :street "County Road Y", + :city "Western", + :state-abbrev "NE", + :zip "68464"} + {:lat 37.278886, + :lon -80.752978, + :house-number "167", + :street "Cooper Lane", + :city "Pearisburg", + :state-abbrev "VA", + :zip "24134"} + {:lat 35.1345926, + :lon -76.7618676, + :house-number "369", + :street "McCotter Road", + :city "Bayboro", + :state-abbrev "NC", + :zip "28515"} + {:lat 34.0696338, + :lon -103.2871496, + :house-number "1400-1598", + :street "South Roosevelt Road 13", + :city "Portales", + :state-abbrev "NM", + :zip "88130"} + {:lat 32.429694, + :lon -111.331505, + :house-number "12402", + :street "North Carbine Road", + :city "Marana", + :state-abbrev "AZ", + :zip "85653"} + {:lat 40.792316, + :lon -81.224113, + :house-number "9508", + :street "Lisbon Street Northeast", + :city "East Canton", + :state-abbrev "OH", + :zip "44730"} + {:lat 48.42087129999999, + :lon -98.4923376, + :house-number "6901-6963", + :street "100th Avenue Northeast", + :city "Edmore", + :state-abbrev "ND", + :zip "58330"} + {:lat 30.583725, + :lon -83.7119063, + :house-number "2-282", + :street "West 7th Way", + :city "Greenville", + :state-abbrev "FL", + :zip "32331"} + {:lat 30.714283, + :lon -94.902378, + :house-number "208", + :street "Dogwood Hill Road", + :city "Livingston", + :state-abbrev "TX", + :zip "77351"} + {:lat 45.75160400000001, + :lon -108.604186, + :house-number "22", + :street "Bridlewood Drive", + :city "Billings", + :state-abbrev "MT", + :zip "59102"} + {:lat 33.737525, + :lon -85.2354063, + :house-number "219", + :street "Old Ridgeway Road", + :city "Bremen", + :state-abbrev "GA", + :zip "30110"} + {:lat 46.5844548, + :lon -92.9939555, + :house-number "2612", + :street "South Finn Road", + :city "Kettle River", + :state-abbrev "MN", + :zip "55757"} + {:lat 33.8833259, + :lon -96.21635719999999, + :house-number "5499-5529", + :street "N3820 Road", + :city "Bokchito", + :state-abbrev "OK", + :zip "74726"} + {:lat 31.1658398, + :lon -91.2507991, + :house-number "498", + :street "Walker Lane", + :city "Woodville", + :state-abbrev "MS", + :zip "39669"} + {:lat 39.2245989, + :lon -97.42682169999999, + :house-number "1833-1899", + :street "North 270th Road", + :city "Clay Center", + :state-abbrev "KS", + :zip "67432"} + {:lat 38.6539478, + :lon -98.01970569999999, + :house-number "5", + :street "Bourbon Street", + :city "Geneseo", + :state-abbrev "KS", + :zip "67444"} + {:lat 47.41297850000001, + :lon -105.7740833, + :house-number "844-878", + :street "Mayberry Road", + :city "Circle", + :state-abbrev "MT", + :zip "59215"} + {:lat 46.8850376, + :lon -102.5623717, + :house-number "3639", + :street "100th Avenue Southwest", + :city "Gladstone", + :state-abbrev "ND", + :zip "58630"} + {:lat 38.329125, + :lon -82.6987683, + :house-number "16800-17498", + :street "Country Club Drive", + :city "Catlettsburg", + :state-abbrev "KY", + :zip "41129"} + {:lat 41.3518892, + :lon -82.8445302, + :house-number "500-550", + :street "Southwest Road", + :city "Castalia", + :state-abbrev "OH", + :zip "44824"} + {:lat 38.294071, + :lon -120.601589, + :house-number "15469", + :street "Jesus Maria Road", + :city "Mokelumne Hill", + :state-abbrev "CA", + :zip "95245"} + {:lat 33.9167659, + :lon -94.38419499999999, + :house-number "160", + :street "Riverview Drive", + :city "Horatio", + :state-abbrev "AR", + :zip "71842"} + {:lat 35.472722, + :lon -81.805212, + :house-number "198", + :street "Pearson Moss Drive", + :city "Bostic", + :state-abbrev "NC", + :zip "28018"} + {:lat 42.1307055, + :lon -102.4795368, + :house-number "3466", + :street "183rd Trail", + :city "Lakeside", + :state-abbrev "NE", + :zip "69351"} + {:lat 25.8698057, + :lon -81.173395, + :house-number "47201", + :street "Tamiami Trail East", + :city "Ochopee", + :state-abbrev "FL", + :zip "34141"} + {:lat 44.696725, + :lon -107.214077, + :house-number "37", + :street "Beckton Drive", + :city "Sheridan", + :state-abbrev "WY", + :zip "82801"} + {:lat 39.1864188, + :lon -105.4994581, + :house-number "25800", + :street "County Road 77", + :city "Jefferson", + :state-abbrev "CO", + :zip "80456"} + {:lat 47.0808663, + :lon -98.8165657, + :house-number "7901-7999", + :street "23rd Street Southeast", + :city "Buchanan", + :state-abbrev "ND", + :zip "58420"} + {:lat 37.0223516, + :lon -120.890925, + :house-number "18361-18655", + :street "South Creek Road", + :city "Los Banos", + :state-abbrev "CA", + :zip "93635"} + {:lat 48.37040150000001, + :lon -98.93015109999999, + :house-number "7740-7798", + :street "66th Street Northeast", + :city "Webster", + :state-abbrev "ND", + :zip "58382"} + {:lat 32.8409626, + :lon -81.20136839999999, + :house-number "5016", + :street "Luray Highway", + :city "Brunson", + :state-abbrev "SC", + :zip "29911"} + {:lat 42.4575898, + :lon -84.0450789, + :house-number "15313", + :street "Livermore Road", + :city "Pinckney", + :state-abbrev "MI", + :zip "48169"} + {:lat 41.7911848, + :lon -86.11495819999999, + :house-number "3205-3209", + :street "U.S. 12", + :city "Niles", + :state-abbrev "MI", + :zip "49120"} + {:lat 42.4367078, + :lon -71.8534573, + :house-number "70", + :street "Coal Kiln Road", + :city "Princeton", + :state-abbrev "MA", + :zip "01541"} + {:lat 44.3916327, + :lon -120.93806, + :house-number "7337", + :street "Northwest Ryegrass Road", + :city "Prineville", + :state-abbrev "OR", + :zip "97754"} + {:lat 32.885575, + :lon -94.810548, + :house-number "12231", + :street "Farm to Market Road 2796", + :city "Pittsburg", + :state-abbrev "TX", + :zip "75686"} + {:lat 36.012014, + :lon -96.559501, + :house-number "10431", + :street "South 513th West Avenue", + :city "Drumright", + :state-abbrev "OK", + :zip "74030"} + {:lat 31.9861377, + :lon -83.30078189999999, + :house-number "470", + :street "South Broad Street", + :city "Abbeville", + :state-abbrev "GA", + :zip "31001"} + {:lat 37.67874, + :lon -120.195549, + :house-number "4695", + :street "Crown Lead Road", + :city "Coulterville", + :state-abbrev "CA", + :zip "95311"} + {:lat 48.026764, + :lon -114.5130826, + :house-number "2734", + :street "Browns Meadow Road", + :city "Kila", + :state-abbrev "MT", + :zip "59920"} + {:lat 47.6827165, + :lon -118.234703, + :house-number "28515", + :street "Horwege Road", + :city "Davenport", + :state-abbrev "WA", + :zip "99122"} + {:lat 44.8173148, + :lon -113.6845403, + :house-number "542", + :street "National Forest Development Road 008", + :city "Lemhi", + :state-abbrev "ID", + :zip "83465"} + {:lat 35.22018, + :lon -82.092621, + :house-number "653", + :street "Green Fields Lane", + :city "Columbus", + :state-abbrev "NC", + :zip "28722"} + {:lat 36.8504259, + :lon -87.9559804, + :house-number "6627", + :street "Blue Spring Road", + :city "Cadiz", + :state-abbrev "KY", + :zip "42211"} + {:lat 46.77222, + :lon -104.71361, + :house-number "315", + :street "Pine Unit Road", + :city "Fallon", + :state-abbrev "MT", + :zip "59326"} + {:lat 45.8193559, + :lon -120.4218159, + :house-number "353-551", + :street "Newell Grade Road", + :city "Roosevelt", + :state-abbrev "WA", + :zip "99356"} + {:lat 41.1167214, + :lon -95.33849479999999, + :house-number "1224-1298", + :street "130th Street", + :city "Emerson", + :state-abbrev "IA", + :zip "51533"} + {:lat 32.0650555, + :lon -85.70541229999999, + :house-number "3686", + :street "County Road 31", + :city "Union Springs", + :state-abbrev "AL", + :zip "36089"} + {:lat 43.9630749, + :lon -99.5404085, + :house-number "33151", + :street "South Dakota 47", + :city "Reliance", + :state-abbrev "SD", + :zip "57569"} + {:lat 43.1264718, + :lon -74.08001809999999, + :house-number "139", + :street "Hans Creek Road", + :city "Broadalbin", + :state-abbrev "NY", + :zip "12025"} + {:lat 35.5942876, + :lon -77.098241, + :house-number "360", + :street "Page Road", + :city "Washington", + :state-abbrev "NC", + :zip "27889"} + {:lat 39.835329, + :lon -123.130293, + :house-number "30374", + :street "Mendocino Pass Road", + :city "Covelo", + :state-abbrev "CA", + :zip "95428"} + {:lat 44.0337702, + :lon -90.0817086, + :house-number "700-708", + :street "West Middle Street", + :city "Necedah", + :state-abbrev "WI", + :zip "54646"} + {:lat 44.2286948, + :lon -69.62147089999999, + :house-number "51", + :street "Gorman Lane", + :city "Whitefield", + :state-abbrev "ME", + :zip "04353"} + {:lat 47.8437113, + :lon -96.7390394, + :house-number "22364", + :street "320th Avenue Southwest", + :city "Fisher", + :state-abbrev "MN", + :zip "56723"} + {:lat 34.054148, + :lon -83.75065699999999, + :house-number "546", + :street "Mulberry Road", + :city "Winder", + :state-abbrev "GA", + :zip "30680"} + {:lat 40.8422394, + :lon -86.446347, + :house-number "5001-5637", + :street "North Co Road 375 West", + :city "Royal Center", + :state-abbrev "IN", + :zip "46978"} + {:lat 40.7876069, + :lon -91.96811699999999, + :house-number "17736-18032", + :street "Lark Avenue", + :city "Keosauqua", + :state-abbrev "IA", + :zip "52565"} + {:lat 45.7504108, + :lon -86.91291009999999, + :house-number "8905", + :street "15th Road", + :city "Rapid River", + :state-abbrev "MI", + :zip "49878"} + {:lat 46.9343702, + :lon -102.8765487, + :house-number "11523", + :street "33rd Street Southwest", + :city "Dickinson", + :state-abbrev "ND", + :zip "58601"} + {:lat 39.71045609999999, + :lon -90.60765669999999, + :house-number "225-299", + :street "West Phillips Ferry Road", + :city "Bluffs", + :state-abbrev "IL", + :zip "62621"} + {:lat 42.6513994, + :lon -114.8666274, + :house-number "901-921", + :street "East 4500 North", + :city "Buhl", + :state-abbrev "ID", + :zip "83316"} + {:lat 39.593326, + :lon -106.0200552, + :house-number "51-99", + :street "Circle B", + :city "Dillon", + :state-abbrev "CO", + :zip "80435"} + {:lat 43.863401, + :lon -88.6968, + :house-number "W10733", + :street "Olden Road", + :city "Pickett", + :state-abbrev "WI", + :zip "54964"} + {:lat 38.8216915, + :lon -81.3836443, + :house-number "1501-1517", + :street "Parkersburg Road", + :city "Spencer", + :state-abbrev "WV", + :zip "25276"} + {:lat 58.2438, + :lon -134.296, + :house-number "6020", + :street "Thane Road", + :city "Juneau", + :state-abbrev "AK", + :zip "99801"} + {:lat 43.150971, + :lon -78.580462, + :house-number "7922", + :street "Lincoln Avenue Extension", + :city "Lockport", + :state-abbrev "NY", + :zip "14094"} + {:lat 35.770582, + :lon -84.92956099999999, + :house-number "1100", + :street "Happy Top Road", + :city "Grandview", + :state-abbrev "TN", + :zip "37337"} + {:lat 38.3971132, + :lon -84.7471047, + :house-number "80", + :street "Old Teresita Road", + :city "Owenton", + :state-abbrev "KY", + :zip "40359"} + {:lat 33.2654697, + :lon -96.30465389999999, + :house-number "7667", + :street "County Road 705", + :city "Farmersville", + :state-abbrev "TX", + :zip "75442"} + {:lat 37.7642515, + :lon -106.7990794, + :house-number "36", + :street "Stagecoach Drive", + :city "South Fork", + :state-abbrev "CO", + :zip "81154"} + {:lat 31.8048375, + :lon -93.3722943, + :house-number "2191-2497", + :street "Allen-Beulah Road", + :city "Marthaville", + :state-abbrev "LA", + :zip "71450"} + {:lat 38.02106089999999, + :lon -102.3020413, + :house-number "26496-28658", + :street "County Road 25", + :city "Granada", + :state-abbrev "CO", + :zip "81041"} + {:lat 44.688761, + :lon -87.99036, + :house-number "4660", + :street "Brown Road", + :city "Little Suamico", + :state-abbrev "WI", + :zip "54141"} + {:lat 47.3486486, + :lon -119.9857975, + :house-number "299-373", + :street "Palisades Road", + :city "Palisades", + :state-abbrev "WA", + :zip "98845"} + {:lat 30.6165006, + :lon -102.5659586, + :house-number "185", + :street "Puckett Road", + :city "Fort Stockton", + :state-abbrev "TX", + :zip "79735"} + {:lat 41.5701556, + :lon -101.3875156, + :house-number "1796-1798", + :street "Nebraska 92", + :city "Sutherland", + :state-abbrev "NE", + :zip "69165"} + {:lat 39.8311148, + :lon -121.7066288, + :house-number "11422", + :street "Deer Creek Highway", + :city "Chico", + :state-abbrev "CA", + :zip "95928"} + {:lat 35.2979402, + :lon -80.7213334, + :house-number "1709", + :street "Jeffrey Bryan Drive", + :city "Charlotte", + :state-abbrev "NC", + :zip "28213"} + {:lat 38.9807004, + :lon -90.96349490000001, + :house-number "242-298", + :street "Thompson Drive", + :city "Troy", + :state-abbrev "MO", + :zip "63379"} + {:lat 45.1793539, + :lon -91.082781, + :house-number "29408", + :street "230th Avenue", + :city "Holcombe", + :state-abbrev "WI", + :zip "54745"} + {:lat 35.085311, + :lon -90.246799, + :house-number "1230", + :street "Caldwell Road", + :city "Proctor", + :state-abbrev "AR", + :zip "72376"} + {:lat 29.4831868, + :lon -98.8856216, + :house-number "715", + :street "County Road 2615", + :city "Rio Medina", + :state-abbrev "TX", + :zip "78066"} + {:lat 37.336518, + :lon -108.490367, + :house-number "30205", + :street "Highway 160", + :city "Cortez", + :state-abbrev "CO", + :zip "81321"} + {:lat 38.368046, + :lon -121.904845, + :house-number "6418", + :street "Byrnes Road", + :city "Vacaville", + :state-abbrev "CA", + :zip "95687"} + {:lat 37.5995954, + :lon -105.8436795, + :house-number "1619", + :street "County Road 111", + :city "Mosca", + :state-abbrev "CO", + :zip "81146"} + {:lat 44.55905389999999, + :lon -88.77040559999999, + :house-number "E9075", + :street "Bear Creek Road", + :city "Clintonville", + :state-abbrev "WI", + :zip "54929"} + {:lat 44.657691, + :lon -91.543655, + :house-number "S11780", + :street "County Road B", + :city "Eleva", + :state-abbrev "WI", + :zip "54738"} + {:lat 36.944042, + :lon -81.082864, + :house-number "485", + :street "West Union Street", + :city "Wytheville", + :state-abbrev "VA", + :zip "24382"} + {:lat 42.4936868, + :lon -91.6677739, + :house-number "2010-2054", + :street "Victor Avenue", + :city "Winthrop", + :state-abbrev "IA", + :zip "50682"} + {:lat 35.2039787, + :lon -94.7005509, + :house-number "25540", + :street "Nubbin Ridge", + :city "Spiro", + :state-abbrev "OK", + :zip "74959"} + {:lat 41.3055032, + :lon -96.20784669999999, + :house-number "18465-18849", + :street "Fort Street", + :city "Omaha", + :state-abbrev "NE", + :zip "68022"} + {:lat 41.3314761, + :lon -94.7418039, + :house-number "75848", + :street "Memphis Road", + :city "Anita", + :state-abbrev "IA", + :zip "50020"} + {:lat 63.44814479999999, + :lon -148.8350787, + :house-number "Mile 214.5", + :street "George Parks Highway", + :city "Denali National Park and Preserve", + :state-abbrev "AK", + :zip "99755"} + {:lat 41.3480212, + :lon -76.3170663, + :house-number "459", + :street "Ricketts Drive", + :city "Benton", + :state-abbrev "PA", + :zip "17814"} + {:lat 39.067987, + :lon -104.887748, + :house-number "3245", + :street "Doolittle Road", + :city "Monument", + :state-abbrev "CO", + :zip "80132"} + {:lat 38.044187, + :lon -121.448421, + :house-number "13222", + :street "West Rindge Road", + :city "Stockton", + :state-abbrev "CA", + :zip "95219"} + {:lat 43.4394989, + :lon -75.3129672, + :house-number "8724-8768", + :street "Domser Road", + :city "Boonville", + :state-abbrev "NY", + :zip "13309"} + {:lat 46.368827, + :lon -94.577142, + :house-number "12012", + :street "57th Avenue Southwest", + :city "Pillager", + :state-abbrev "MN", + :zip "56473"} + {:lat 32.3276924, + :lon -91.78834379999999, + :house-number "615", + :street "Whitehall Road", + :city "Mangham", + :state-abbrev "LA", + :zip "71259"} + {:lat 32.1274578, + :lon -86.8248954, + :house-number "19", + :street "Casey Road", + :city "Sardis", + :state-abbrev "AL", + :zip "36775"} + {:lat 46.88974, + :lon -119.6439359, + :house-number "58062", + :street "3rd Street Northeast", + :city "Royal City", + :state-abbrev "WA", + :zip "99357"} + {:lat 34.312091, + :lon -86.018813, + :house-number "2961", + :street "County Road 28", + :city "Crossville", + :state-abbrev "AL", + :zip "35962"} + {:lat 37.8714349, + :lon -92.4307795, + :house-number "19610", + :street "Highway 7", + :city "Richland", + :state-abbrev "MO", + :zip "65556"} + {:lat 42.2611518, + :lon -70.89390490000001, + :house-number "195", + :street "Downer Avenue", + :city "Hingham", + :state-abbrev "MA", + :zip "02043"} + {:lat 42.7077684, + :lon -76.4247091, + :house-number "1", + :street "Joseph Drive", + :city "Moravia", + :state-abbrev "NY", + :zip "13118"} + {:lat 42.5332468, + :lon -95.90447019999999, + :house-number "3559", + :street "120th Street", + :city "Pierson", + :state-abbrev "IA", + :zip "51048"} + {:lat 32.6073554, + :lon -86.11205770000001, + :house-number "6695-7143", + :street "Georgia Road", + :city "Wetumpka", + :state-abbrev "AL", + :zip "36092"} + {:lat 44.7075747, + :lon -100.1775982, + :house-number "29831-29999", + :street "185th Street", + :city "Onida", + :state-abbrev "SD", + :zip "57564"} + {:lat 31.458724, + :lon -82.1390318, + :house-number "6046-7330", + :street "Scenic Drive", + :city "Patterson", + :state-abbrev "GA", + :zip "31557"} + {:lat 39.365628, + :lon -84.85064, + :house-number "5007", + :street "Wesley Chapel Road", + :city "West Harrison", + :state-abbrev "IN", + :zip "47060"} + {:lat 30.7871609, + :lon -87.04861149999999, + :house-number "6478", + :street "Southridge Road", + :city "Milton", + :state-abbrev "FL", + :zip "32570"} + {:lat 47.1320838, + :lon -118.525546, + :house-number "901-939", + :street "Rosenoff Road", + :city "Ritzville", + :state-abbrev "WA", + :zip "99169"} + {:lat 47.8067285, + :lon -110.0965646, + :house-number "11533", + :street "Flat Creek Road", + :city "Geraldine", + :state-abbrev "MT", + :zip "59446"} + {:lat 42.780005, + :lon -78.729653, + :house-number "3837", + :street "Freeman Road", + :city "Orchard Park", + :state-abbrev "NY", + :zip "14127"} + {:lat 30.1642049, + :lon -100.0199915, + :house-number "397", + :street "Sd 32740", + :city "Rocksprings", + :state-abbrev "TX", + :zip "78880"} + {:lat 34.306449, + :lon -89.653038, + :house-number "484", + :street "County Road 343", + :city "Taylor", + :state-abbrev "MS", + :zip "38673"} + {:lat 31.7038236, + :lon -96.1186999, + :house-number "145", + :street "Pr 507", + :city "Fairfield", + :state-abbrev "TX", + :zip "75840"} + {:lat 44.9953209, + :lon -93.641418, + :house-number "4680", + :street "Creekwood Trail", + :city "Maple Plain", + :state-abbrev "MN", + :zip "55359"} + {:lat 41.7620177, + :lon -99.2775448, + :house-number "45908", + :street "825th Road", + :city "Burwell", + :state-abbrev "NE", + :zip "68823"} + {:lat 40.401603, + :lon -98.7812086, + :house-number "401-499", + :street "41 Road", + :city "Minden", + :state-abbrev "NE", + :zip "68959"} + {:lat 48.9263308, + :lon -99.0443299, + :house-number "10414-10498", + :street "76th Avenue Northeast", + :city "Sarles", + :state-abbrev "ND", + :zip "58372"} + {:lat 39.9552359, + :lon -76.9656205, + :house-number "53", + :street "Eisenhart Mill Road", + :city "East Berlin", + :state-abbrev "PA", + :zip "17316"} + {:lat 38.177664, + :lon -107.789232, + :house-number "3073", + :street "County Road 24", + :city "Ridgway", + :state-abbrev "CO", + :zip "81432"} + {:lat 47.1157639, + :lon -98.19402960000001, + :house-number "2050-2100", + :street "109th Avenue Southeast", + :city "Dazey", + :state-abbrev "ND", + :zip "58429"} + {:lat 34.8068352, + :lon -96.3323535, + :house-number "5201", + :street "North 376 Road", + :city "Allen", + :state-abbrev "OK", + :zip "74825"} + {:lat 39.2305368, + :lon -104.3967876, + :house-number "14041-14645", + :street "County Road 102", + :city "Elbert", + :state-abbrev "CO", + :zip "80106"} + {:lat 42.0337619, + :lon -71.6630163, + :house-number "131", + :street "Laurel Street", + :city "Uxbridge", + :state-abbrev "MA", + :zip "01569"} + {:lat 36.1576989, + :lon -76.9172385, + :house-number "915", + :street "Elm Grove Road", + :city "Colerain", + :state-abbrev "NC", + :zip "27924"} + {:lat 46.5779436, + :lon -111.2284119, + :house-number "835", + :street "Camas Creek Road", + :city "White Sulphur Springs", + :state-abbrev "MT", + :zip "59645"} + {:lat 48.39654789999999, + :lon -114.4081103, + :house-number "2875", + :street "U.S. 93", + :city "Whitefish", + :state-abbrev "MT", + :zip "59937"} + {:lat 38.1592284, + :lon -95.5663465, + :house-number "935", + :street "Verdure Road Southeast", + :city "Le Roy", + :state-abbrev "KS", + :zip "66857"} + {:lat 48.4095875, + :lon -95.5801213, + :house-number "46801", + :street "White Wolf Road Northwest", + :city "Grygla", + :state-abbrev "MN", + :zip "56727"} + {:lat 40.9809265, + :lon -86.92298989999999, + :house-number "500", + :street "South 5 Mi W Of 1600w", + :city "Francesville", + :state-abbrev "IN", + :zip "47946"} + {:lat 46.9383783, + :lon -99.5487438, + :house-number "4281-4371", + :street "33rd Street Southeast", + :city "Tappen", + :state-abbrev "ND", + :zip "58487"} + {:lat 30.2242984, + :lon -93.587592, + :house-number "257", + :street "Bigwoods Vinton Road", + :city "Vinton", + :state-abbrev "LA", + :zip "70668"} + {:lat 44.4086145, + :lon -68.4235918, + :house-number "1966", + :street "Bayside Road", + :city "Trenton", + :state-abbrev "ME", + :zip "04605"} + {:lat 41.008783, + :lon -105.5615779, + :house-number "244", + :street "Elk Crossing Road", + :city "Tie Siding", + :state-abbrev "WY", + :zip "82084"} + {:lat 35.7386538, + :lon -81.82084689999999, + :house-number "2510-2684", + :street "Conley Bumgarner Road", + :city "Morganton", + :state-abbrev "NC", + :zip "28655"} + {:lat 32.5134911, + :lon -104.9464219, + :house-number "5-101", + :street "G-032", + :city "Dell City", + :state-abbrev "NM", + :zip "79837"} + {:lat 38.427788, + :lon -106.534436, + :house-number "64103", + :street "U.S. 50", + :city "Gunnison", + :state-abbrev "CO", + :zip "81230"} + {:lat 31.5035347, + :lon -111.284977, + :house-number "2805", + :street "Ruby Road", + :city "Nogales", + :state-abbrev "AZ", + :zip "85621"} + {:lat 43.986271, + :lon -94.18518399999999, + :house-number "52136", + :street "150th Street", + :city "Vernon Center", + :state-abbrev "MN", + :zip "56090"} + {:lat 45.30644900000001, + :lon -91.877477, + :house-number "1363", + :street "7th Avenue", + :city "Hillsdale", + :state-abbrev "WI", + :zip "54733"} + {:lat 43.024329, + :lon -112.824998, + :house-number "2678", + :street "West 1200 South", + :city "Aberdeen", + :state-abbrev "ID", + :zip "83210"} + {:lat 39.8614895, + :lon -108.2667581, + :house-number "21144-22214", + :street "County Road 5", + :city "Rifle", + :state-abbrev "CO", + :zip "81650"} + {:lat 33.6274642, + :lon -105.8989291, + :house-number "12167", + :street "U.S. 54", + :city "Carrizozo", + :state-abbrev "NM", + :zip "88301"} + {:lat 39.9160374, + :lon -75.3583661, + :house-number "201-299", + :street "Papermill Road", + :city "Springfield", + :state-abbrev "PA", + :zip "19064"} + {:lat 34.8792474, + :lon -103.0762503, + :house-number "300-498", + :street "Curry Road 43", + :city "Broadview", + :state-abbrev "NM", + :zip "88112"} + {:lat 39.2079099, + :lon -83.12939790000001, + :house-number "1477-1645", + :street "Ohio 772", + :city "Bainbridge", + :state-abbrev "OH", + :zip "45612"} + {:lat 48.4928326, + :lon -100.6789272, + :house-number "7425", + :street "1st Avenue North", + :city "Towner", + :state-abbrev "ND", + :zip "58788"} + {:lat 47.316261, + :lon -111.2672231, + :house-number "1210", + :street "Eden Road", + :city "Great Falls", + :state-abbrev "MT", + :zip "59405"} + {:lat 40.370281, + :lon -76.759902, + :house-number "1016", + :street "Piketown Road", + :city "Harrisburg", + :state-abbrev "PA", + :zip "17112"} + {:lat 38.358731, + :lon -97.919955, + :house-number "27", + :street "Kiowa Road", + :city "Windom", + :state-abbrev "KS", + :zip "67491"} + {:lat 32.0771818, + :lon -84.0684537, + :house-number "1699", + :street "Georgia 195", + :city "Americus", + :state-abbrev "GA", + :zip "31709"} + {:lat 40.1744085, + :lon -82.5928914, + :house-number "4500-6098", + :street "Dutch Lane Northwest", + :city "Johnstown", + :state-abbrev "OH", + :zip "43031"} + {:lat 39.8006462, + :lon -106.2671922, + :house-number "1465", + :street "Black Creek Road", + :city "Silverthorne", + :state-abbrev "CO", + :zip "80498"} + {:lat 39.031739, + :lon -88.42046599999999, + :house-number "8047", + :street "North 2100th Street", + :city "Dieterich", + :state-abbrev "IL", + :zip "62424"} + {:lat 40.8826954, + :lon -84.1866254, + :house-number "21900-21998", + :street "Road 17", + :city "Columbus Grove", + :state-abbrev "OH", + :zip "45830"} + {:lat 35.8027731, + :lon -80.48833259999999, + :house-number "224-238", + :street "Singleton Road", + :city "Mocksville", + :state-abbrev "NC", + :zip "27028"} + {:lat 39.2667106, + :lon -83.03588740000001, + :house-number "484", + :street "Baptist Hill Road", + :city "Chillicothe", + :state-abbrev "OH", + :zip "45601"} + {:lat 47.9624757, + :lon -106.0816186, + :house-number "339", + :street "Maxwell Hill Road", + :city "Wolf Point", + :state-abbrev "MT", + :zip "59201"} + {:lat 36.609706, + :lon -85.05463, + :house-number "3072", + :street "Chanute Road", + :city "Pall Mall", + :state-abbrev "TN", + :zip "38577"} + {:lat 37.286474, + :lon -121.862554, + :house-number "741", + :street "Batista Drive", + :city "San Jose", + :state-abbrev "CA", + :zip "95136"} + {:lat 36.6981921, + :lon -92.1128186, + :house-number "9700", + :street "County Road 7950", + :city "Pottersville", + :state-abbrev "MO", + :zip "65790"} + {:lat 32.4484452, + :lon -81.8748105, + :house-number "801", + :street "Brannen Cemetery Road", + :city "Statesboro", + :state-abbrev "GA", + :zip "30458"} + {:lat 29.5870185, + :lon -95.8321783, + :house-number "693-799", + :street "Huntington Road", + :city "Rosenberg", + :state-abbrev "TX", + :zip "77471"} + {:lat 32.7711839, + :lon -91.7136507, + :house-number "11638", + :street "Johnson School Road", + :city "Mer Rouge", + :state-abbrev "LA", + :zip "71261"} + {:lat 43.9268497, + :lon -101.7960548, + :house-number "23950", + :street "Recluse Road", + :city "Philip", + :state-abbrev "SD", + :zip "57567"} + {:lat 39.3069469, + :lon -99.3298011, + :house-number "1528-1598", + :street "South Road", + :city "Plainville", + :state-abbrev "KS", + :zip "67663"} + {:lat 35.0778421, + :lon -85.1800033, + :house-number "7700-7706", + :street "Cecelia Drive", + :city "Chattanooga", + :state-abbrev "TN", + :zip "37416"} + {:lat 41.3652452, + :lon -75.2521935, + :house-number "106", + :street "Spruce Lane", + :city "Greentown", + :state-abbrev "PA", + :zip "18426"} + {:lat 33.7798117, + :lon -117.4158073, + :house-number "20090", + :street "Chalon Road", + :city "Perris", + :state-abbrev "CA", + :zip "92570"} + {:lat 43.1617463, + :lon -92.7789888, + :house-number "1350", + :street "River Road", + :city "Floyd", + :state-abbrev "IA", + :zip "50435"} + {:lat 35.1246269, + :lon -87.35063, + :house-number "20", + :street "Hardiman Road South", + :city "Leoma", + :state-abbrev "TN", + :zip "38468"} + {:lat 42.38576, + :lon -96.4448319, + :house-number "1943-1957", + :street "U.S. 75", + :city "Dakota City", + :state-abbrev "NE", + :zip "68731"} + {:lat 41.578093, + :lon -80.631832, + :house-number "6339", + :street "Creek Road", + :city "Andover", + :state-abbrev "OH", + :zip "44003"} + {:lat 41.383577, + :lon -93.369366, + :house-number "9537", + :street "228th Avenue", + :city "Ackworth", + :state-abbrev "IA", + :zip "50001"} + {:lat 37.8261403, + :lon -83.955142, + :house-number "2188-2226", + :street "Spout Springs Road", + :city "Clay City", + :state-abbrev "KY", + :zip "40312"} + {:lat 44.755191, + :lon -106.673704, + :house-number "924", + :street "Ulm Road", + :city "Banner", + :state-abbrev "WY", + :zip "82832"} + {:lat 45.1755568, + :lon -89.4512337, + :house-number "W593", + :street "Spring Brook Avenue", + :city "Merrill", + :state-abbrev "WI", + :zip "54452"} + {:lat 29.633604, + :lon -100.9240923, + :house-number "354", + :street "Summit Drive", + :city "Del Rio", + :state-abbrev "TX", + :zip "78840"} + {:lat 46.640867, + :lon -67.968823, + :house-number "157", + :street "Centerline Road", + :city "Presque Isle", + :state-abbrev "ME", + :zip "04769"} + {:lat 46.9913099, + :lon -104.2749999, + :house-number "372", + :street "Hodges Road", + :city "Wibaux", + :state-abbrev "MT", + :zip "59353"} + {:lat 40.110004, + :lon -75.957448, + :house-number "481", + :street "South Churchtown Road", + :city "Narvon", + :state-abbrev "PA", + :zip "17555"} + {:lat 34.2383253, + :lon -82.8546978, + :house-number "1567", + :street "Coldwater Road", + :city "Dewy Rose", + :state-abbrev "GA", + :zip "30634"} + {:lat 33.92903100000001, + :lon -83.678337, + :house-number "1405", + :street "Gin Mill Court", + :city "Monroe", + :state-abbrev "GA", + :zip "30656"} + {:lat 44.45397810000001, + :lon -75.5898072, + :house-number "701", + :street "Turner Road", + :city "Hammond", + :state-abbrev "NY", + :zip "13646"} + {:lat 45.8027586, + :lon -92.7680673, + :house-number "37656", + :street "Black Pine Road", + :city "Pine City", + :state-abbrev "MN", + :zip "55063"} + {:lat 41.0614976, + :lon -110.5364236, + :house-number "271", + :street "County Road", + :city "Fort Bridger", + :state-abbrev "WY", + :zip "82933"} + {:lat 33.096877, + :lon -116.1325306, + :house-number "6599", + :street "Alvarado Street", + :city "Borrego Springs", + :state-abbrev "CA", + :zip "92004"} + {:lat 46.3109698, + :lon -84.8021358, + :house-number "13785-13865", + :street "Sullivan Creek Trail", + :city "Rudyard", + :state-abbrev "MI", + :zip "49780"} + {:lat 39.8092853, + :lon -121.7420029, + :house-number "342", + :street "4 Acres Sec34 T23nr2e", + :city "Chico", + :state-abbrev "CA", + :zip "95928"} + {:lat 39.0242849, + :lon -82.2628741, + :house-number "30945", + :street "Ohio 325", + :city "Langsville", + :state-abbrev "OH", + :zip "45741"} + {:lat 40.900784, + :lon -86.99796900000001, + :house-number "7577", + :street "South County Road 210 East", + :city "Rensselaer", + :state-abbrev "IN", + :zip "47978"} + {:lat 40.8104888, + :lon -90.2666866, + :house-number "641", + :street "Knox Road 900 East", + :city "Gilson", + :state-abbrev "IL", + :zip "61436"} + {:lat 46.6261015, + :lon -108.6118494, + :house-number "125-177", + :street "Snowy Mountain Road", + :city "Roundup", + :state-abbrev "MT", + :zip "59072"} + {:lat 46.855473, + :lon -117.7997662, + :house-number "272", + :street "Guske Road", + :city "LaCrosse", + :state-abbrev "WA", + :zip "99143"} + {:lat 30.3724246, + :lon -85.43729569999999, + :house-number "12404", + :street "U.S. 231", + :city "Youngstown", + :state-abbrev "FL", + :zip "32466"} + {:lat 42.595562, + :lon -121.743452, + :house-number "41351", + :street "Solomon Drive", + :city "Chiloquin", + :state-abbrev "OR", + :zip "97624"} + {:lat 45.6720056, + :lon -88.93579230000002, + :house-number "9942", + :street "Suring Lane", + :city "Argonne", + :state-abbrev "WI", + :zip "54511"} + {:lat 31.3300691, + :lon -85.97398249999999, + :house-number "23-999", + :street "County Road 545", + :city "New Brockton", + :state-abbrev "AL", + :zip "36351"} + {:lat 37.7946338, + :lon -83.4015648, + :house-number "141", + :street "Blankenship Road", + :city "Hazel Green", + :state-abbrev "KY", + :zip "41332"} + {:lat 40.28411759999999, + :lon -74.2302674, + :house-number "200", + :street "Sunnyside Drive", + :city "Marlboro Township", + :state-abbrev "NJ", + :zip "07746"} + {:lat 40.9640534, + :lon -91.02591079999999, + :house-number "4746-4998", + :street "195th Street", + :city "Burlington", + :state-abbrev "IA", + :zip "52601"} + {:lat 39.516798, + :lon -82.225859, + :house-number "44600", + :street "Dawley-New Pittsburg Road", + :city "Nelsonville", + :state-abbrev "OH", + :zip "45764"} + {:lat 39.2393343, + :lon -79.0159398, + :house-number "71", + :street "Meadow Farms Road", + :city "Old Fields", + :state-abbrev "WV", + :zip "26845"} + {:lat 39.72992869999999, + :lon -106.1713332, + :house-number "2642", + :street "Meadowbrook Lane", + :city "Silverthorne", + :state-abbrev "CO", + :zip "80498"} + {:lat 41.9823355, + :lon -90.4063305, + :house-number "3536-3620", + :street "135th Street", + :city "Charlotte", + :state-abbrev "IA", + :zip "52731"} + {:lat 46.9822916, + :lon -107.2805484, + :house-number "487", + :street "South Sand Creek Road", + :city "Jordan", + :state-abbrev "MT", + :zip "59337"} + {:lat 41.4528115, + :lon -74.9944647, + :house-number "157-171", + :street "Neil Thompson Road", + :city "Shohola", + :state-abbrev "PA", + :zip "18458"} + {:lat 44.733007, + :lon -73.408199, + :house-number "275", + :street "Allen Road", + :city "Plattsburgh", + :state-abbrev "NY", + :zip "12901"} + {:lat 42.765204, + :lon -89.598237, + :house-number "N7109", + :street "Wettach Road", + :city "Monticello", + :state-abbrev "WI", + :zip "53570"} + {:lat 40.997771, + :lon -85.092398, + :house-number "9431", + :street "Hessen Cassel Road", + :city "Fort Wayne", + :state-abbrev "IN", + :zip "46816"} + {:lat 36.5902185, + :lon -98.8046696, + :house-number "35860", + :street "Oklahoma 45", + :city "Waynoka", + :state-abbrev "OK", + :zip "73860"} + {:lat 39.342818, + :lon -89.420245, + :house-number "23000-23220", + :street "East 15th Road", + :city "Morrisonville", + :state-abbrev "IL", + :zip "62546"} + {:lat 35.9029025, + :lon -77.8467766, + :house-number "11905", + :street "North Carolina 97", + :city "Rocky Mount", + :state-abbrev "NC", + :zip "27803"} + {:lat 45.9727092, + :lon -91.1340783, + :house-number "9032-9056", + :street "West County Highway B", + :city "Hayward", + :state-abbrev "WI", + :zip "54843"} + {:lat 41.4097631, + :lon -112.431176, + :house-number "5311", + :street "Southeast Promontory Road", + :city "Corinne", + :state-abbrev "UT", + :zip "84307"} + {:lat 34.9942849, + :lon -89.7761688, + :house-number "12577", + :street "State Line Road", + :city "Olive Branch", + :state-abbrev "MS", + :zip "38654"} + {:lat 34.4754932, + :lon -109.5960396, + :house-number "37232", + :street "Arizona 61", + :city "Concho", + :state-abbrev "AZ", + :zip "85924"} + {:lat 46.03810379999999, + :lon -91.0169315, + :house-number "11715", + :street "North Fr 174", + :city "Hayward", + :state-abbrev "WI", + :zip "54843"} + {:lat 38.0881508, + :lon -94.7917923, + :house-number "4708", + :street "Paine Road", + :city "Mound City", + :state-abbrev "KS", + :zip "66056"} + {:lat 36.489802, + :lon -88.5697129, + :house-number "740", + :street "Harrison Road", + :city "Palmersville", + :state-abbrev "TN", + :zip "38241"} + {:lat 38.9832517, + :lon -122.1164638, + :house-number "6225", + :street "Boles Road", + :city "Arbuckle", + :state-abbrev "CA", + :zip "95912"} + {:lat 42.1780297, + :lon -72.9587426, + :house-number "168", + :street "Otis Stage Road", + :city "Blandford", + :state-abbrev "MA", + :zip "01008"} + {:lat 40.1638939, + :lon -78.13666529999999, + :house-number "97", + :street "Po Box", + :city "Wood", + :state-abbrev "PA", + :zip "16694"} + {:lat 38.0254997, + :lon -85.1213398, + :house-number "3800", + :street "Murphy Lane", + :city "Mount Eden", + :state-abbrev "KY", + :zip "40046"} + {:lat 34.0996574, + :lon -102.5112853, + :house-number "1563", + :street "FM 303", + :city "Sudan", + :state-abbrev "TX", + :zip "79371"} + {:lat 44.15514599999999, + :lon -87.875097, + :house-number "14428", + :street "San Road", + :city "Cato", + :state-abbrev "WI", + :zip "54230"} + {:lat 46.7882987, + :lon -119.1126247, + :house-number "800-942", + :street "South Steele Road", + :city "Othello", + :state-abbrev "WA", + :zip "99344"} + {:lat 35.5156836, + :lon -77.82581379999999, + :house-number "384", + :street "Landis Road", + :city "Stantonsburg", + :state-abbrev "NC", + :zip "27883"} + {:lat 35.3017766, + :lon -118.8785867, + :house-number "5064-6362", + :street "South Edison Road", + :city "Bakersfield", + :state-abbrev "CA", + :zip "93307"} + {:lat 35.6617665, + :lon -81.5617742, + :house-number "1916", + :street "Off Road", + :city "Morganton", + :state-abbrev "NC", + :zip "28655"} + {:lat 40.264102, + :lon -85.24811199999999, + :house-number "11910", + :street "East Co Road 500 North", + :city "Albany", + :state-abbrev "IN", + :zip "47320"} + {:lat 34.3691713, + :lon -85.9096969, + :house-number "509", + :street "County Road 49", + :city "Dawson", + :state-abbrev "AL", + :zip "35963"} + {:lat 40.639914, + :lon -91.537768, + :house-number "1939", + :street "Iowa 2", + :city "Donnellson", + :state-abbrev "IA", + :zip "52625"} + {:lat 48.367617, + :lon -106.386911, + :house-number "1172", + :street "Geer Road", + :city "Nashua", + :state-abbrev "MT", + :zip "59248"} + {:lat 37.5534413, + :lon -80.6684452, + :house-number "153", + :street "Laurel Creek Road", + :city "Greenville", + :state-abbrev "WV", + :zip "24945"} + {:lat 48.5129061, + :lon -107.450546, + :house-number "4972", + :street "Lake Road", + :city "Saco", + :state-abbrev "MT", + :zip "59261"} + {:lat 44.303249, + :lon -122.693011, + :house-number "42750", + :street "Upper Calapooia Drive", + :city "Sweet Home", + :state-abbrev "OR", + :zip "97386"} + {:lat 37.968924, + :lon -92.4927889, + :house-number "1185", + :street "Mountview Road", + :city "Richland", + :state-abbrev "MO", + :zip "65556"} + {:lat 40.3180455, + :lon -99.34456770000001, + :house-number "72382", + :street "O Road", + :city "Holdrege", + :state-abbrev "NE", + :zip "68949"} + {:lat 37.7455954, + :lon -75.7387931, + :house-number "22317", + :street "Deep Creek Road", + :city "Onancock", + :state-abbrev "VA", + :zip "23417"} + {:lat 35.266777, + :lon -87.098073, + :house-number "305", + :street "Ball Hollow Estate Road", + :city "Pulaski", + :state-abbrev "TN", + :zip "38478"} + {:lat 60.04188310000001, + :lon -151.3121206, + :house-number "54117", + :street "Sisler Avenue", + :city "Ninilchik", + :state-abbrev "AK", + :zip "99639"} + {:lat 45.1750936, + :lon -88.81815429999999, + :house-number "4098", + :street "Fire Lane Road", + :city "White Lake", + :state-abbrev "WI", + :zip "54491"} + {:lat 32.3732398, + :lon -98.9501281, + :house-number "15151", + :street "Interstate 20", + :city "Cisco", + :state-abbrev "TX", + :zip "76437"} + {:lat 42.8860049, + :lon -84.9869425, + :house-number "4381", + :street "Klotz Road", + :city "Portland", + :state-abbrev "MI", + :zip "48875"} + {:lat 34.9344866, + :lon -89.4021398, + :house-number "2355-2665", + :street "Roberts Chapel Road", + :city "Lamar", + :state-abbrev "MS", + :zip "38642"} + {:lat 33.67856039999999, + :lon -97.8830539, + :house-number "159", + :street "East Rc Road", + :city "Bowie", + :state-abbrev "TX", + :zip "76230"} + {:lat 42.5489375, + :lon -85.1945466, + :house-number "4770", + :street "Maple Grove Road", + :city "Hastings", + :state-abbrev "MI", + :zip "49058"} + {:lat 41.52784, + :lon -91.725444, + :house-number "2096", + :street "560th Street Southwest", + :city "Kalona", + :state-abbrev "IA", + :zip "52247"} + {:lat 35.6193554, + :lon -85.92264879999999, + :house-number "724-1350", + :street "Jacksboro Road", + :city "Morrison", + :state-abbrev "TN", + :zip "37357"} + {:lat 36.399259, + :lon -76.2899489, + :house-number "102", + :street "Robin Drive", + :city "South Mills", + :state-abbrev "NC", + :zip "27976"} + {:lat 36.5620566, + :lon -80.0153004, + :house-number "175", + :street "Democrat Road", + :city "Spencer", + :state-abbrev "VA", + :zip "24165"} + {:lat 48.5784473, + :lon -101.3426061, + :house-number "8000-8054", + :street "29th Avenue Northwest", + :city "Lansford", + :state-abbrev "ND", + :zip "58750"} + {:lat 46.1444883, + :lon -91.37233309999999, + :house-number "14108", + :street "North Sonby Road", + :city "Hayward", + :state-abbrev "WI", + :zip "54843"} + {:lat 42.7471583, + :lon -82.90010029999999, + :house-number "21875-22399", + :street "28 Mile Road", + :city "Ray", + :state-abbrev "MI", + :zip "48096"} + {:lat 41.11358389999999, + :lon -86.38228640000001, + :house-number "7346", + :street "West 400 North", + :city "Rochester", + :state-abbrev "IN", + :zip "46975"} + {:lat 38.5410306, + :lon -105.030577, + :house-number "7502", + :street "Upper Beaver Creek Road", + :city "Penrose", + :state-abbrev "CO", + :zip "81240"} + {:lat 36.0810719, + :lon -81.57605339999999, + :house-number "2300-2332", + :street "Timber Run Drive", + :city "Lenoir", + :state-abbrev "NC", + :zip "28645"} + {:lat 48.5178342, + :lon -103.8474234, + :house-number "14601-14649", + :street "76th Street Northwest", + :city "Grenora", + :state-abbrev "ND", + :zip "58845"} + {:lat 35.3585085, + :lon -100.3074713, + :house-number "7329-7499", + :street "County Road 13", + :city "Wheeler", + :state-abbrev "TX", + :zip "79096"} + {:lat 46.1712919, + :lon -118.8883021, + :house-number "3500-3568", + :street "East Humorist Road", + :city "Burbank", + :state-abbrev "WA", + :zip "99323"} + {:lat 37.9988509, + :lon -92.65144699999999, + :house-number "1443", + :street "Lowell Williams Road", + :city "Linn Creek", + :state-abbrev "MO", + :zip "65052"} + {:lat 44.7876962, + :lon -94.28539289999999, + :house-number "11701", + :street "Melody Avenue", + :city "Glencoe", + :state-abbrev "MN", + :zip "55336"} + {:lat 28.375405, + :lon -81.649492, + :house-number "17600", + :street "Flemmings Road", + :city "Winter Garden", + :state-abbrev "FL", + :zip "34787"} + {:lat 42.6134894, + :lon -82.9754203, + :house-number "43314", + :street "Hillcrest Drive", + :city "Sterling Heights", + :state-abbrev "MI", + :zip "48313"} + {:lat 35.229939, + :lon -88.711297, + :house-number "1263", + :street "Curtis Hill Church Lane", + :city "Bethel Springs", + :state-abbrev "TN", + :zip "38315"} + {:lat 28.2967559, + :lon -81.84640279999999, + :house-number "A3", + :street "Van Fleet Road", + :city "Polk City", + :state-abbrev "FL", + :zip "33868"} + {:lat 48.94182, + :lon -94.92195559999999, + :house-number "6298", + :street "Northwest Sandy Shores Drive", + :city "Williams", + :state-abbrev "MN", + :zip "56686"} + {:lat 32.146161, + :lon -86.406871, + :house-number "1224", + :street "Plantation Road", + :city "Hope Hull", + :state-abbrev "AL", + :zip "36043"} + {:lat 27.472142, + :lon -80.431026, + :house-number "2001", + :street "Old Ffa Road", + :city "Fort Pierce", + :state-abbrev "FL", + :zip "34951"} + {:lat 32.270188, + :lon -90.9108059, + :house-number "400", + :street "Wilbert Lane", + :city "Vicksburg", + :state-abbrev "MS", + :zip "39180"} + {:lat 34.8768839, + :lon -85.24801699999999, + :house-number "1174", + :street "Red Belt Road", + :city "Chickamauga", + :state-abbrev "GA", + :zip "30707"} + {:lat 35.5362926, + :lon -98.2195119, + :house-number "23861", + :street "Route 66", + :city "Calumet", + :state-abbrev "OK", + :zip "73014"} + {:lat 38.6162698, + :lon -89.3774191, + :house-number "1511-1591", + :street "Kane Street", + :city "Carlyle", + :state-abbrev "IL", + :zip "62231"} + {:lat 43.4144182, + :lon -98.64741860000001, + :house-number "27358-27398", + :street "377th Avenue", + :city "Corsica", + :state-abbrev "SD", + :zip "57328"} + {:lat 34.271229, + :lon -87.1796677, + :house-number "99", + :street "County Road 3706", + :city "Addison", + :state-abbrev "AL", + :zip "35540"} + {:lat 32.862158, + :lon -108.5860274, + :house-number "473", + :street "Bill Evans Road", + :city "Silver City", + :state-abbrev "NM", + :zip "88061"} + {:lat 37.18846610000001, + :lon -77.9081442, + :house-number "3543", + :street "Rocky Hill Road", + :city "Blackstone", + :state-abbrev "VA", + :zip "23824"} + {:lat 47.959813, + :lon -106.0802946, + :house-number "339", + :street "Maxwell Hill Road", + :city "Wolf Point", + :state-abbrev "MT", + :zip "59201"} + {:lat 32.09018080000001, + :lon -84.41532629999999, + :house-number "1460", + :street "Youngs Mill Road", + :city "Plains", + :state-abbrev "GA", + :zip "31780"} + {:lat 35.2428253, + :lon -78.0706555, + :house-number "756", + :street "Country Club Road", + :city "Mount Olive", + :state-abbrev "NC", + :zip "28365"} + {:lat 46.4567953, + :lon -98.09833069999999, + :house-number "11079-11099", + :street "66th Street Southeast", + :city "Verona", + :state-abbrev "ND", + :zip "58490"} + {:lat 36.8523157, + :lon -87.5900484, + :house-number "3095", + :street "Hensley Lane", + :city "Hopkinsville", + :state-abbrev "KY", + :zip "42240"} + {:lat 29.836327, + :lon -97.43976699999999, + :house-number "1026", + :street "Pine Gap Drive", + :city "Dale", + :state-abbrev "TX", + :zip "78616"} + {:lat 34.0710594, + :lon -79.6735174, + :house-number "4139", + :street "Planer Road", + :city "Effingham", + :state-abbrev "SC", + :zip "29541"} + {:lat 31.477583, + :lon -98.133838, + :house-number "1636", + :street "U.S. 84", + :city "Evant", + :state-abbrev "TX", + :zip "76525"} + {:lat 41.5298448, + :lon -92.1890238, + :house-number "1564", + :street "325th Street", + :city "North English", + :state-abbrev "IA", + :zip "52316"} + {:lat 35.6221391, + :lon -79.2516907, + :house-number "1118", + :street "Cole Thomas Road", + :city "Bear Creek", + :state-abbrev "NC", + :zip "27207"} + {:lat 38.760812, + :lon -77.74101399999999, + :house-number "6742", + :street "Kirk Lane", + :city "Warrenton", + :state-abbrev "VA", + :zip "20187"} + {:lat 37.7204679, + :lon -119.934325, + :house-number "8622", + :street "Bull Creek Road", + :city "Coulterville", + :state-abbrev "CA", + :zip "95311"} + {:lat 31.4295568, + :lon -85.08885599999999, + :house-number "2-8", + :street "Henry County 47", + :city "Shorterville", + :state-abbrev "AL", + :zip "36373"} + {:lat 44.25241339999999, + :lon -86.02472209999999, + :house-number "13841", + :street "Chicago Avenue", + :city "Wellston", + :state-abbrev "MI", + :zip "49689"} + {:lat 40.9866609, + :lon -124.0667605, + :house-number "240", + :street "Wagle Lane", + :city "McKinleyville", + :state-abbrev "CA", + :zip "95519"} + {:lat 44.94850520000001, + :lon -117.9031187, + :house-number "46973", + :street "Haines Dump Road", + :city "Haines", + :state-abbrev "OR", + :zip "97833"} + {:lat 44.4356448, + :lon -121.9428254, + :house-number "13000", + :street "U.S. 20", + :city "Sisters", + :state-abbrev "OR", + :zip "97759"} + {:lat 30.2388971, + :lon -91.3172204, + :house-number "27729-27757", + :street "Intracoastal Road", + :city "Plaquemine", + :state-abbrev "LA", + :zip "70764"} + {:lat 45.1555286, + :lon -91.9936049, + :house-number "3254-3294", + :street "1300th Avenue", + :city "Ridgeland", + :state-abbrev "WI", + :zip "54763"} + {:lat 32.308539, + :lon -110.793313, + :house-number "5419", + :street "Shandon Place", + :city "Tucson", + :state-abbrev "AZ", + :zip "85749"} + {:lat 45.6170256, + :lon -90.4052562, + :house-number "N6687", + :street "Aspen Road", + :city "Phillips", + :state-abbrev "WI", + :zip "54555"} + {:lat 38.5529525, + :lon -86.2359341, + :house-number "8335", + :street "West Vincennes Trail", + :city "Campbellsburg", + :state-abbrev "IN", + :zip "47108"} + {:lat 44.407822, + :lon -92.6516345, + :house-number "37000-37912", + :street "190th Avenue", + :city "Goodhue", + :state-abbrev "MN", + :zip "55027"} + {:lat 41.2424746, + :lon -75.7229472, + :house-number "3001", + :street "Bald Mountain Road", + :city "Bear Creek Village", + :state-abbrev "PA", + :zip "18702"} + {:lat 40.8950958, + :lon -100.366425, + :house-number "36844", + :street "East Palmer Road", + :city "Brady", + :state-abbrev "NE", + :zip "69123"} + {:lat 33.4835755, + :lon -97.5134693, + :house-number "2840", + :street "Seldom Seen Road", + :city "Forestburg", + :state-abbrev "TX", + :zip "76239"} + {:lat 45.304834, + :lon -91.5486899, + :house-number "2963", + :street "7th Avenue", + :city "Chetek", + :state-abbrev "WI", + :zip "54728"} + {:lat 43.782105, + :lon -76.125839, + :house-number "7859", + :street "Lake Road", + :city "Belleville", + :state-abbrev "NY", + :zip "13611"} + {:lat 44.32127819999999, + :lon -97.9138131, + :house-number "21101-21199", + :street "415th Avenue", + :city "Iroquois", + :state-abbrev "SD", + :zip "57353"} + {:lat 42.7269199, + :lon -83.8923518, + :house-number "6876", + :street "Dean Road", + :city "Howell", + :state-abbrev "MI", + :zip "48855"} + {:lat 39.4287889, + :lon -84.1343097, + :house-number "3824-3938", + :street "Wilmington Road", + :city "Lebanon", + :state-abbrev "OH", + :zip "45036"} + {:lat 34.361765, + :lon -93.3366379, + :house-number "122", + :street "Falls Lane", + :city "Bonnerdale", + :state-abbrev "AR", + :zip "71933"} + {:lat 29.770311, + :lon -98.291823, + :house-number "628-998", + :street "Seay Lane", + :city "New Braunfels", + :state-abbrev "TX", + :zip "78132"} + {:lat 40.133207, + :lon -108.191913, + :house-number "3200", + :street "Penny Drive", + :city "Meeker", + :state-abbrev "CO", + :zip "81641"} + {:lat 40.77145489999999, + :lon -111.8558905, + :house-number "1100-1116", + :street "East 2nd Avenue", + :city "Salt Lake City", + :state-abbrev "UT", + :zip "84103"} + {:lat 37.84206289999999, + :lon -90.8244859, + :house-number "12949", + :street "Delbridge Road", + :city "Potosi", + :state-abbrev "MO", + :zip "63664"} + {:lat 37.3419049, + :lon -80.828667, + :house-number "510", + :street "Stock Pen Mountain Road", + :city "Narrows", + :state-abbrev "VA", + :zip "24124"} + {:lat 41.0536659, + :lon -91.01103529999999, + :house-number "25504-25986", + :street "42nd Avenue", + :city "Oakville", + :state-abbrev "IA", + :zip "52646"} + {:lat 30.493264, + :lon -98.97051499999999, + :house-number "721", + :street "Wasserfall Road", + :city "Fredericksburg", + :state-abbrev "TX", + :zip "78624"} + {:lat 46.1178152, + :lon -105.8201727, + :house-number "1086", + :street "Tongue River Road", + :city "Miles City", + :state-abbrev "MT", + :zip "59301"} + {:lat 33.156414, + :lon -105.782114, + :house-number "111-199", + :street "Cottonwood Drive", + :city "Mescalero", + :state-abbrev "NM", + :zip "88340"} + {:lat 37.2891389, + :lon -88.05956570000001, + :house-number "1272-1980", + :street "Weldon Road", + :city "Marion", + :state-abbrev "KY", + :zip "42064"} + {:lat 42.30973350000001, + :lon -105.0921531, + :house-number "355", + :street "Coleman Road", + :city "Wheatland", + :state-abbrev "WY", + :zip "82201"} + {:lat 45.008201, + :lon -92.406273, + :house-number "1950", + :street "County Road E", + :city "Baldwin", + :state-abbrev "WI", + :zip "54002"} + {:lat 44.1305376, + :lon -94.9190122, + :house-number "11560", + :street "370th Avenue", + :city "Comfrey", + :state-abbrev "MN", + :zip "56019"} + {:lat 46.7091378, + :lon -101.0908937, + :house-number "4816-4820", + :street "30th Avenue", + :city "Mandan", + :state-abbrev "ND", + :zip "58554"} + {:lat 42.1507858, + :lon -104.9566127, + :house-number "251", + :street "East Johnson Road", + :city "Wheatland", + :state-abbrev "WY", + :zip "82201"} + {:lat 44.9003897, + :lon -102.8874754, + :house-number "17224", + :street "Old 212", + :city "Mud Butte", + :state-abbrev "SD", + :zip "57758"} + {:lat 36.46426599999999, + :lon -89.352084, + :house-number "2460", + :street "Tennessee 213", + :city "Tiptonville", + :state-abbrev "TN", + :zip "38079"} + {:lat 46.7180626, + :lon -113.1962918, + :house-number "195", + :street "Big Sky Ridge Lane", + :city "Drummond", + :state-abbrev "MT", + :zip "59832"} + {:lat 42.927046, + :lon -74.853465, + :house-number "1049", + :street "Travis Road", + :city "Jordanville", + :state-abbrev "NY", + :zip "13361"} + {:lat 35.6341398, + :lon -91.7455191, + :house-number "1000", + :street "Old Union Road", + :city "Floral", + :state-abbrev "AR", + :zip "72534"} + {:lat 37.179507, + :lon -112.386285, + :house-number "7295", + :street "Johnson Canyon Road", + :city "Kanab", + :state-abbrev "UT", + :zip "84741"} + {:lat 43.479399, + :lon -88.583489, + :house-number "N7851", + :street "Bay View Road", + :city "Horicon", + :state-abbrev "WI", + :zip "53032"} + {:lat 30.9974104, + :lon -91.0533414, + :house-number "6310", + :street "Highway 422", + :city "Norwood", + :state-abbrev "LA", + :zip "70761"} + {:lat 38.5767965, + :lon -96.4920547, + :house-number "2099-2199", + :street "South 850 Road", + :city "Council Grove", + :state-abbrev "KS", + :zip "66846"} + {:lat 32.828037, + :lon -115.574871, + :house-number "2420", + :street "Enterprise Way", + :city "Imperial", + :state-abbrev "CA", + :zip "92251"} + {:lat 44.630733, + :lon -73.586221, + :house-number "41", + :street "Austin Road", + :city "Morrisonville", + :state-abbrev "NY", + :zip "12962"} + {:lat 45.0889128, + :lon -123.5819218, + :house-number "24118-24500", + :street "Shadow Lane", + :city "Grand Ronde", + :state-abbrev "OR", + :zip "97347"} + {:lat 39.3111942, + :lon -79.09821339999999, + :house-number "410", + :street "Burgess Hollow Road", + :city "New Creek", + :state-abbrev "WV", + :zip "26743"} + {:lat 30.484739, + :lon -84.59702999999999, + :house-number "7439", + :street "Old Federal Road", + :city "Quincy", + :state-abbrev "FL", + :zip "32351"} + {:lat 47.6658559, + :lon -105.793456, + :house-number "306-318", + :street "Thorgaard Road", + :city "Vida", + :state-abbrev "MT", + :zip "59274"} + {:lat 29.779582, + :lon -96.8117989, + :house-number "12016", + :street "Mazoch Road Holman Area", + :city "La Grange", + :state-abbrev "TX", + :zip "78945"} + {:lat 38.698893, + :lon -85.82955799999999, + :house-number "2999", + :street "County Road 50 North", + :city "Scottsburg", + :state-abbrev "IN", + :zip "47170"} + {:lat 26.630951, + :lon -80.910077, + :house-number "10000", + :street "CR 835", + :city "Clewiston", + :state-abbrev "FL", + :zip "33440"} + {:lat 37.3210317, + :lon -90.1330998, + :house-number "1572", + :street "RR 1", + :city "Glenallen", + :state-abbrev "MO", + :zip "63751"} + {:lat 39.6197165, + :lon -85.6859594, + :house-number "6352-6746", + :street "North Range Line Road", + :city "Shelbyville", + :state-abbrev "IN", + :zip "46176"} + {:lat 46.8348647, + :lon -99.59147390000001, + :house-number "4120", + :street "40th Street Southeast", + :city "Tappen", + :state-abbrev "ND", + :zip "58487"} + {:lat 40.5671369, + :lon -88.81208720000001, + :house-number "21766", + :street "East 1750 North Road", + :city "Towanda", + :state-abbrev "IL", + :zip "61776"} + {:lat 34.8072897, + :lon -87.6318849, + :house-number "800", + :street "South Cox Creek Parkway", + :city "Florence", + :state-abbrev "AL", + :zip "35630"} + {:lat 61.53484349999999, + :lon -166.0989777, + :house-number "1", + :street "Uinaq Road", + :city "Hooper Bay", + :state-abbrev "AK", + :zip "99604"} + {:lat 48.66046, + :lon -112.765961, + :house-number "375", + :street "Meriwether Road", + :city "Cut Bank", + :state-abbrev "MT", + :zip "59427"} + {:lat 48.6799332, + :lon -102.6469106, + :house-number "8701-8767", + :street "91st Avenue Northwest", + :city "Powers Lake", + :state-abbrev "ND", + :zip "58773"} + {:lat 42.5187101, + :lon -74.94718449999999, + :house-number "460", + :street "Burrillo Road", + :city "Maryland", + :state-abbrev "NY", + :zip "12116"} + {:lat 46.1642053, + :lon -96.67889219999999, + :house-number "8601-8649", + :street "178th Avenue Southeast", + :city "Wahpeton", + :state-abbrev "ND", + :zip "58075"} + {:lat 32.0780224, + :lon -85.57917119999999, + :house-number "662", + :street "Foster Road", + :city "Union Springs", + :state-abbrev "AL", + :zip "36089"} + {:lat 36.7672839, + :lon -119.225824, + :house-number "1444", + :street "Crane Lane", + :city "Squaw Valley", + :state-abbrev "CA", + :zip "93675"} + {:lat 31.982205, + :lon -99.095485, + :house-number "5580", + :street "County Road 411", + :city "Brownwood", + :state-abbrev "TX", + :zip "76801"} + {:lat 29.60951, + :lon -98.283165, + :house-number "7015", + :street "Farm to Market Road 3009", + :city "Schertz", + :state-abbrev "TX", + :zip "78154"} + {:lat 44.8499904, + :lon -121.0645029, + :house-number "85104", + :street "South Junction Road", + :city "Maupin", + :state-abbrev "OR", + :zip "97037"} + {:lat 40.281903, + :lon -76.52353099999999, + :house-number "1581", + :street "Horseshoe Pike", + :city "Lebanon", + :state-abbrev "PA", + :zip "17042"} + {:lat 34.9047695, + :lon -90.2370013, + :house-number "14036", + :street "Star Landing Road", + :city "Lake Cormorant", + :state-abbrev "MS", + :zip "38641"} + {:lat 39.514265, + :lon -83.53760559999999, + :house-number "5765-5981", + :street "U.S. 22", + :city "Washington Court House", + :state-abbrev "OH", + :zip "43160"} + {:lat 37.369013, + :lon -79.1272089, + :house-number "1986", + :street "Old Rustburg Road", + :city "Lynchburg", + :state-abbrev "VA", + :zip "24501"} + {:lat 32.3126203, + :lon -85.5588867, + :house-number "4615", + :street "County Road 16", + :city "Tuskegee", + :state-abbrev "AL", + :zip "36083"} + {:lat 40.9447254, + :lon -95.26528990000001, + :house-number "1601-1629", + :street "250 Street", + :city "Red Oak", + :state-abbrev "IA", + :zip "51566"} + {:lat 32.3376667, + :lon -80.528605, + :house-number "1", + :street "Whitams Island", + :city "Saint Helena Island", + :state-abbrev "SC", + :zip "29920"} + {:lat 30.1073029, + :lon -99.6336451, + :house-number "1-5", + :street "Texas 41", + :city "Mountain Home", + :state-abbrev "TX", + :zip "78058"} + {:lat 44.2163394, + :lon -84.8966808, + :house-number "8001-9999", + :street "East Finkle Road", + :city "Falmouth", + :state-abbrev "MI", + :zip "49632"} + {:lat 37.4752539, + :lon -91.1805078, + :house-number "1272", + :street "County Road 900", + :city "Bunker", + :state-abbrev "MO", + :zip "63629"} + {:lat 43.1691463, + :lon -95.1268561, + :house-number "2301-2307", + :street "East 30th Street", + :city "Spencer", + :state-abbrev "IA", + :zip "51301"} + {:lat 40.6847803, + :lon -86.688831, + :house-number "900", + :street "East North Street", + :city "Delphi", + :state-abbrev "IN", + :zip "46923"} + {:lat 54.8488896, + :lon -163.4073178, + :house-number "180", + :street "Unimak Drive", + :city "False Pass", + :state-abbrev "AK", + :zip "99583"} + {:lat 34.0070049, + :lon -91.5941429, + :house-number "755", + :street "Cades Lane", + :city "Gould", + :state-abbrev "AR", + :zip "71643"} + {:lat 38.5792025, + :lon -106.887352, + :house-number "321", + :street "Magnolia", + :city "Gunnison", + :state-abbrev "CO", + :zip "81230"} + {:lat 46.1661469, + :lon -106.2884395, + :house-number "895", + :street "Sweeney Creek Road", + :city "Rosebud", + :state-abbrev "MT", + :zip "59347"} + {:lat 29.1169075, + :lon -96.3296448, + :house-number "505", + :street "South FM 441 Road", + :city "Louise", + :state-abbrev "TX", + :zip "77455"} + {:lat 33.8299358, + :lon -93.5065874, + :house-number "593", + :street "Hempstead 207 Road", + :city "Prescott", + :state-abbrev "AR", + :zip "71857"} + {:lat 48.133432, + :lon -96.9065397, + :house-number "39583", + :street "180th Street Northwest", + :city "Warren", + :state-abbrev "MN", + :zip "56762"} + {:lat 40.041491, + :lon -85.717243, + :house-number "2602", + :street "Enterprise Drive", + :city "Anderson", + :state-abbrev "IN", + :zip "46013"} + {:lat 48.0997898, + :lon -103.2000809, + :house-number "11851", + :street "46th Street Northwest", + :city "Watford City", + :state-abbrev "ND", + :zip "58854"} + {:lat 31.1115134, + :lon -89.6005565, + :house-number "287", + :street "Tatum Salt Dome Road", + :city "Lumberton", + :state-abbrev "MS", + :zip "39455"} + {:lat 58.4085, + :lon -134.7549999, + :house-number "17900", + :street "Glacier Highway", + :city "Juneau", + :state-abbrev "AK", + :zip "99801"} + {:lat 46.701241, + :lon -119.5692451, + :house-number "7026", + :street "Mallard Drive Southeast", + :city "Warden", + :state-abbrev "WA", + :zip "98857"} + {:lat 29.9892952, + :lon -93.08805, + :house-number "1019-1299", + :street "Louisiana 27", + :city "Bell City", + :state-abbrev "LA", + :zip "70630"} + {:lat 41.676099, + :lon -73.378096, + :house-number "106", + :street "New Preston Hill Road", + :city "Washington", + :state-abbrev "CT", + :zip "06777"} + {:lat 46.1091442, + :lon -107.0786299, + :house-number "173", + :street "Bear Creek Road", + :city "Hysham", + :state-abbrev "MT", + :zip "59038"} + {:lat 35.65109899999999, + :lon -91.54011799999999, + :house-number "560", + :street "Goodie Creek Road", + :city "Rosie", + :state-abbrev "AR", + :zip "72571"} + {:lat 32.761235, + :lon -100.8384885, + :house-number "5000", + :street "East County Road 126", + :city "Snyder", + :state-abbrev "TX", + :zip "79549"} + {:lat 43.6587436, + :lon -97.7557446, + :house-number "42259", + :street "257th Street", + :city "Alexandria", + :state-abbrev "SD", + :zip "57311"} + {:lat 48.1777173, + :lon -105.7346406, + :house-number "5689", + :street "Road 1078", + :city "Wolf Point", + :state-abbrev "MT", + :zip "59201"} + {:lat 38.7081942, + :lon -105.1350515, + :house-number "114", + :street "Cedar Street", + :city "Victor", + :state-abbrev "CO", + :zip "80860"} + {:lat 44.0779761, + :lon -86.2130723, + :house-number "2705", + :street "East Townline Road", + :city "Free Soil", + :state-abbrev "MI", + :zip "49411"} + {:lat 38.938575, + :lon -77.883971, + :house-number "2518", + :street "Rectortown Road", + :city "Marshall", + :state-abbrev "VA", + :zip "20115"} + {:lat 38.4676563, + :lon -102.678174, + :house-number "44000-46998", + :street "Colorado 96", + :city "Eads", + :state-abbrev "CO", + :zip "81036"} + {:lat 35.03465, + :lon -113.681162, + :house-number "11735", + :street "Desert Fire Trail", + :city "Kingman", + :state-abbrev "AZ", + :zip "86401"} + {:lat 29.165482, + :lon -95.8897788, + :house-number "7500", + :street "FM 1728", + :city "Pledger", + :state-abbrev "TX", + :zip "77468"} + {:lat 45.2686766, + :lon -112.508021, + :house-number "3663", + :street "Stone Creek Road", + :city "Dillon", + :state-abbrev "MT", + :zip "59725"} + {:lat 38.5078724, + :lon -82.7571526, + :house-number "4478", + :street "Indian Run Road", + :city "Flatwoods", + :state-abbrev "KY", + :zip "41139"} + {:lat 47.3844573, + :lon -98.400517, + :house-number "10064", + :street "2nd Street Southeast", + :city "Sutton", + :state-abbrev "ND", + :zip "58484"} + {:lat 29.732441, + :lon -91.5441532, + :house-number "9071", + :street "Highway 182", + :city "Franklin", + :state-abbrev "LA", + :zip "70538"} + {:lat 37.8210517, + :lon -90.56398279999999, + :house-number "1700-1710", + :street "Ellis Road", + :city "Park Hills", + :state-abbrev "MO", + :zip "63601"} + {:lat 37.5612611, + :lon -85.9417637, + :house-number "128", + :street "Shady Bower Lane", + :city "Sonora", + :state-abbrev "KY", + :zip "42776"} + {:lat 37.4489193, + :lon -89.2712877, + :house-number "309-311", + :street "South Locust Street", + :city "Jonesboro", + :state-abbrev "IL", + :zip "62952"} + {:lat 29.4095736, + :lon -82.12125309999999, + :house-number "1579", + :street "Northeast 178th Place", + :city "Citra", + :state-abbrev "FL", + :zip "32113"} + {:lat 41.851276, + :lon -71.623779, + :house-number "164", + :street "Rocky Hill Road", + :city "Scituate", + :state-abbrev "RI", + :zip "02857"} + {:lat 36.8993552, + :lon -93.37103309999999, + :house-number "955-1163", + :street "V-20 C", + :city "Highlandville", + :state-abbrev "MO", + :zip "65669"} + {:lat 45.871341, + :lon -118.7664048, + :house-number "81140-81160", + :street "County 983 Road", + :city "Helix", + :state-abbrev "OR", + :zip "97835"} + {:lat 42.42348260000001, + :lon -91.4372509, + :house-number "1831", + :street "255th Street", + :city "Manchester", + :state-abbrev "IA", + :zip "52057"} + {:lat 36.8977589, + :lon -97.2649208, + :house-number "6945", + :street "North O Street", + :city "Braman", + :state-abbrev "OK", + :zip "74632"} + {:lat 37.75066169999999, + :lon -121.4455218, + :house-number "1600-1620", + :street "Hurley Court", + :city "Tracy", + :state-abbrev "CA", + :zip "95376"} + {:lat 46.2182794, + :lon -85.8061999, + :house-number "W18444", + :street "Hoffy Road", + :city "Germfask", + :state-abbrev "MI", + :zip "49836"} + {:lat 41.75712559999999, + :lon -96.7505654, + :house-number "1476-1498", + :street "B Road", + :city "West Point", + :state-abbrev "NE", + :zip "68788"} + {:lat 26.764708, + :lon -81.20095599999999, + :house-number "340", + :street "Witt Road", + :city "Clewiston", + :state-abbrev "FL", + :zip "33440"} + {:lat 42.69133370000001, + :lon -105.3532553, + :house-number "323-355", + :street "Irvine Road", + :city "Douglas", + :state-abbrev "WY", + :zip "82633"} + {:lat 34.2297964, + :lon -96.58023569999999, + :house-number "1002", + :street "Condon Grove Lane", + :city "Milburn", + :state-abbrev "OK", + :zip "73450"} + {:lat 42.19197279999999, + :lon -96.9166444, + :house-number "58101-58155", + :street "853rd Road", + :city "Wakefield", + :state-abbrev "NE", + :zip "68784"} + {:lat 44.8080149, + :lon -90.2971223, + :house-number "1095", + :street "Buxton Road", + :city "Spencer", + :state-abbrev "WI", + :zip "54479"} + {:lat 32.1473028, + :lon -96.08273989999999, + :house-number "350", + :street "North League Line Road", + :city "Trinidad", + :state-abbrev "TX", + :zip "75163"} + {:lat 44.1879289, + :lon -94.9584548, + :house-number "15436", + :street "County Highway 5", + :city "Springfield", + :state-abbrev "MN", + :zip "56087"} + {:lat 42.40922399999999, + :lon -78.112782, + :house-number "6642", + :street "Shongo Valley Road", + :city "Fillmore", + :state-abbrev "NY", + :zip "14735"} + {:lat 36.061302, + :lon -79.71980200000002, + :house-number "3435", + :street "McConnell Road", + :city "Greensboro", + :state-abbrev "NC", + :zip "27405"} + {:lat 38.961543, + :lon -85.862494, + :house-number "192-198", + :street "Agrico Lane", + :city "Seymour", + :state-abbrev "IN", + :zip "47274"} + {:lat 39.8072259, + :lon -120.3030839, + :house-number "86204", + :street "California 70", + :city "Beckwourth", + :state-abbrev "CA", + :zip "96129"} + {:lat 37.5284349, + :lon -87.6383283, + :house-number "1422", + :street "Kentucky 138", + :city "Dixon", + :state-abbrev "KY", + :zip "42409"} + {:lat 38.327386, + :lon -98.5166596, + :house-number "227-257", + :street "Southeast 140 Avenue", + :city "Ellinwood", + :state-abbrev "KS", + :zip "67526"} + {:lat 63.63418100000001, + :lon -148.7870271, + :house-number "229", + :street "George Parks Highway", + :city "Denali National Park and Preserve", + :state-abbrev "AK", + :zip "99755"} + {:lat 46.7353089, + :lon -104.7412436, + :house-number "1602", + :street "Cabin Creek Road", + :city "Fallon", + :state-abbrev "MT", + :zip "59326"} + {:lat 35.4267215, + :lon -94.4967037, + :house-number "201", + :street "Lennington Street", + :city "Roland", + :state-abbrev "OK", + :zip "74954"} + {:lat 43.36129890000001, + :lon -116.8722702, + :house-number "13684", + :street "US Highway 95", + :city "Marsing", + :state-abbrev "ID", + :zip "83639"} + {:lat 32.0403894, + :lon -95.5530392, + :house-number "3930", + :street "County Road 312", + :city "Frankston", + :state-abbrev "TX", + :zip "75763"} + {:lat 35.0765497, + :lon -106.5560287, + :house-number "8101-8199", + :street "Chico Road Northeast", + :city "Albuquerque", + :state-abbrev "NM", + :zip "87108"} + {:lat 39.1612492, + :lon -89.81132269999999, + :house-number "18240", + :street "Quarry Road", + :city "Gillespie", + :state-abbrev "IL", + :zip "62033"} + {:lat 40.5954602, + :lon -78.5604488, + :house-number "201", + :street "Dysart Drive", + :city "Dysart", + :state-abbrev "PA", + :zip "16636"} + {:lat 46.55166, + :lon -116.021097, + :house-number "2869", + :street "Bashaw Road", + :city "Orofino", + :state-abbrev "ID", + :zip "83544"} + {:lat 36.8637617, + :lon -94.4574351, + :house-number "14119", + :street "Haven Lane", + :city "Neosho", + :state-abbrev "MO", + :zip "64850"} + {:lat 38.0967399, + :lon -118.990192, + :house-number "9954", + :street "California 167", + :city "Bridgeport", + :state-abbrev "CA", + :zip "93517"} + {:lat 37.2872133, + :lon -84.9102649, + :house-number "1217", + :street "Gosser Ridge Road", + :city "Liberty", + :state-abbrev "KY", + :zip "42539"} + {:lat 32.9386966, + :lon -83.3117592, + :house-number "1748", + :street "Fred Hall Road", + :city "Gordon", + :state-abbrev "GA", + :zip "31031"} + {:lat 48.7637566, + :lon -102.1365474, + :house-number "53401-54799", + :street "506th Avenue Northwest", + :city "Bowbells", + :state-abbrev "ND", + :zip "58721"} + {:lat 35.3441291, + :lon -106.207004, + :house-number "2327-2391", + :street "Turquoise Trail", + :city "Los Cerrillos", + :state-abbrev "NM", + :zip "87010"} + {:lat 47.0220841, + :lon -111.6467656, + :house-number "2265", + :street "Adel Lane", + :city "Cascade", + :state-abbrev "MT", + :zip "59421"} + {:lat 47.761038, + :lon -100.7330401, + :house-number "601-681", + :street "24th Street Northwest", + :city "Butte", + :state-abbrev "ND", + :zip "58723"} + {:lat 34.991869, + :lon -120.187257, + :house-number "752", + :street "Tepusquet Road", + :city "Santa Maria", + :state-abbrev "CA", + :zip "93454"} + {:lat 42.5428125, + :lon -92.0625377, + :house-number "1678-1684", + :street "Baxter Avenue", + :city "Jesup", + :state-abbrev "IA", + :zip "50648"} + {:lat 30.7372104, + :lon -89.0382218, + :house-number "1085-1249", + :street "Wire Road East", + :city "Perkinston", + :state-abbrev "MS", + :zip "39573"} + {:lat 46.259491, + :lon -119.473502, + :house-number "31703", + :street "North River Road Way", + :city "Benton City", + :state-abbrev "WA", + :zip "99320"} + {:lat 45.65729959999999, + :lon -111.2469468, + :house-number "10722", + :street "Pine Butte Road", + :city "Bozeman", + :state-abbrev "MT", + :zip "59718"} + {:lat 36.6521744, + :lon -82.1788784, + :house-number "9438", + :street "Goose Creek Road", + :city "Bristol", + :state-abbrev "VA", + :zip "24202"} + {:lat 28.95668, + :lon -98.4589081, + :house-number "1393", + :street "Coughran Road", + :city "Pleasanton", + :state-abbrev "TX", + :zip "78064"} + {:lat 34.689707, + :lon -112.329328, + :house-number "10150", + :street "North Antelope Meadows Drive", + :city "Prescott Valley", + :state-abbrev "AZ", + :zip "86315"} + {:lat 42.7290184, + :lon -85.65195179999999, + :house-number "4260-4264", + :street "Cloverfield Court", + :city "Wayland", + :state-abbrev "MI", + :zip "49348"} + {:lat 41.1628398, + :lon -91.5400306, + :house-number "1004", + :street "Henry-Washington Road", + :city "Crawfordsville", + :state-abbrev "IA", + :zip "52621"} + {:lat 32.8014353, + :lon -97.2434668, + :house-number "2524", + :street "Minnis Drive", + :city "Fort Worth", + :state-abbrev "TX", + :zip "76117"} + {:lat 35.5192539, + :lon -96.793336, + :house-number "103615", + :street "South 3490 Road", + :city "Prague", + :state-abbrev "OK", + :zip "74864"} + {:lat 61.3830338, + :lon -145.2370339, + :house-number "56", + :street "Richardson Highway", + :city "Valdez", + :state-abbrev "AK", + :zip "99686"} + {:lat 31.951665, + :lon -85.0065432, + :house-number "1704", + :street "Georgia 27", + :city "Georgetown", + :state-abbrev "GA", + :zip "39854"} + {:lat 35.3371225, + :lon -89.548907, + :house-number "2785", + :street "Porter Road", + :city "Mason", + :state-abbrev "TN", + :zip "38049"} + {:lat 35.702655, + :lon -119.7611477, + :house-number "1667", + :street "Shelco Road", + :city "Lost Hills", + :state-abbrev "CA", + :zip "93249"} + {:lat 43.963342, + :lon -88.031961, + :house-number "23933", + :street "Point Creek Road", + :city "Kiel", + :state-abbrev "WI", + :zip "53042"} + {:lat 47.4609631, + :lon -111.3595861, + :house-number "4700-5230", + :street "31st Street Southwest", + :city "Great Falls", + :state-abbrev "MT", + :zip "59404"} + {:lat 40.1041264, + :lon -94.8880025, + :house-number "1507", + :street "County Road 34", + :city "Bolckow", + :state-abbrev "MO", + :zip "64427"} + {:lat 46.9547514, + :lon -94.328178, + :house-number "4081-4157", + :street "McKeown Lake Road Northwest", + :city "Hackensack", + :state-abbrev "MN", + :zip "56452"} + {:lat 41.2161147, + :lon -84.5251529, + :house-number "15492", + :street "Road 218", + :city "Cecil", + :state-abbrev "OH", + :zip "45821"} + {:lat 31.1858, + :lon -85.54050000000001, + :house-number "441", + :street "Pilgrims Rest Road", + :city "Slocomb", + :state-abbrev "AL", + :zip "36375"} + {:lat 39.5623999, + :lon -82.899913, + :house-number "7117", + :street "Tarlton Road", + :city "Circleville", + :state-abbrev "OH", + :zip "43113"} + {:lat 61.51703819999999, + :lon -148.7885501, + :house-number "20000", + :street "East Plumley Road", + :city "Palmer", + :state-abbrev "AK", + :zip "99645"} + {:lat 46.176571, + :lon -102.2612163, + :house-number "1928-1968", + :street "13th Avenue Northeast", + :city "Mott", + :state-abbrev "ND", + :zip "58646"} + {:lat 47.380622, + :lon -119.385335, + :house-number "4917", + :street "Road 20 Northeast", + :city "Soap Lake", + :state-abbrev "WA", + :zip "98851"} + {:lat 33.6260388, + :lon -82.5395871, + :house-number "1055", + :street "Smith Mill Road", + :city "Thomson", + :state-abbrev "GA", + :zip "30824"} + {:lat 31.4103163, + :lon -83.5762767, + :house-number "675", + :street "Isabella-Nashville Road", + :city "Tifton", + :state-abbrev "GA", + :zip "31793"} + {:lat 44.306777, + :lon -85.9339515, + :house-number "18310", + :street "North Coates Highway", + :city "Brethren", + :state-abbrev "MI", + :zip "49619"} + {:lat 61.52757399999999, + :lon -149.0397359, + :house-number "16385", + :street "Sullivan Avenue", + :city "Palmer", + :state-abbrev "AK", + :zip "99645"} + {:lat 38.9740243, + :lon -93.6939909, + :house-number "4766", + :street "Mock Road", + :city "Concordia", + :state-abbrev "MO", + :zip "64020"} + {:lat 46.8588589, + :lon -117.7999214, + :house-number "3898", + :street "Union Flat Creek Road", + :city "Endicott", + :state-abbrev "WA", + :zip "99125"} + {:lat 30.4446267, + :lon -90.4252506, + :house-number "211-231", + :street "North Rateau Road", + :city "Ponchatoula", + :state-abbrev "LA", + :zip "70454"} + {:lat 70.6355001, + :lon -160.043015, + :house-number "1355", + :street "Milikruak Street", + :city "Wainwright", + :state-abbrev "AK", + :zip "99782"} + {:lat 33.374294, + :lon -96.021456, + :house-number "1622", + :street "Farm to Market Road 1563", + :city "Wolfe City", + :state-abbrev "TX", + :zip "75496"} + {:lat 44.9507935, + :lon -105.634956, + :house-number "116-118", + :street "Weischedel Road", + :city "Recluse", + :state-abbrev "WY", + :zip "82725"} + {:lat 41.6255455, + :lon -92.124358, + :house-number "2625", + :street "J Avenue", + :city "Williamsburg", + :state-abbrev "IA", + :zip "52361"} + {:lat 37.0615106, + :lon -83.92517699999999, + :house-number "1547", + :street "Blackwater Road", + :city "London", + :state-abbrev "KY", + :zip "40744"} + {:lat 30.0033359, + :lon -94.6902803, + :house-number "1150", + :street "County Road 118", + :city "Liberty", + :state-abbrev "TX", + :zip "77575"} + {:lat 40.1186724, + :lon -94.4976959, + :house-number "4130", + :street "470 Road", + :city "King City", + :state-abbrev "MO", + :zip "64463"} + {:lat 35.1502163, + :lon -90.5974762, + :house-number "344-654", + :street "3 Way Road West", + :city "Heth", + :state-abbrev "AR", + :zip "72346"} + {:lat 32.6073483, + :lon -105.4085629, + :house-number "4408", + :street "Owen Prather Highway", + :city "Piñon", + :state-abbrev "NM", + :zip "88344"} + {:lat 32.3870464, + :lon -82.37761669999999, + :house-number "755", + :street "Georgia 46", + :city "Lyons", + :state-abbrev "GA", + :zip "30436"} + {:lat 39.6597005, + :lon -87.8228669, + :house-number "7393-7999", + :street "East 1300th Road", + :city "Paris", + :state-abbrev "IL", + :zip "61944"} + {:lat 42.13567, + :lon -78.173823, + :house-number "3149", + :street "Clair Carrier Road", + :city "Friendship", + :state-abbrev "NY", + :zip "14739"} + {:lat 34.13038179999999, + :lon -89.8219354, + :house-number "2824", + :street "County Road 41", + :city "Oakland", + :state-abbrev "MS", + :zip "38948"} + {:lat 35.5366119, + :lon -91.6837504, + :house-number "204-310", + :street "Staggs Drive", + :city "Pleasant Plains", + :state-abbrev "AR", + :zip "72568"} + {:lat 59.7496642, + :lon -161.9046606, + :house-number "101", + :street "Qanirtuuq Road", + :city "Quinhagak", + :state-abbrev "AK", + :zip "99655"} + {:lat 36.8587898, + :lon -86.06222819999999, + :house-number "1149", + :street "State Park Road", + :city "Lucas", + :state-abbrev "KY", + :zip "42156"} + {:lat 44.51793199999999, + :lon -87.595322, + :house-number "N5334", + :street "Hemlock Lane", + :city "Kewaunee", + :state-abbrev "WI", + :zip "54216"} + {:lat 48.7385587, + :lon -113.2676118, + :house-number "309-605", + :street "Livermore Creek Road", + :city "Browning", + :state-abbrev "MT", + :zip "59417"} + {:lat 36.2050501, + :lon -91.950527, + :house-number "1325", + :street "Wideman Road", + :city "Oxford", + :state-abbrev "AR", + :zip "72565"} + {:lat 39.79783990000001, + :lon -98.91230829999999, + :house-number "150", + :street "Road", + :city "Athol", + :state-abbrev "KS", + :zip "66932"} + {:lat 32.3045134, + :lon -95.5923265, + :house-number "11332-11338", + :street "Sunrise Drive", + :city "Brownsboro", + :state-abbrev "TX", + :zip "75756"} + {:lat 33.966146, + :lon -83.170627, + :house-number "1709", + :street "Crawford Smithonia Road", + :city "Colbert", + :state-abbrev "GA", + :zip "30628"} + {:lat 36.0135077, + :lon -79.83430729999999, + :house-number "34100-34248", + :street "Interstate 85 Business", + :city "Greensboro", + :state-abbrev "NC", + :zip "27406"} + {:lat 31.3418073, + :lon -84.7140546, + :house-number "10993", + :street "Georgia 45", + :city "Damascus", + :state-abbrev "GA", + :zip "39841"} + {:lat 39.6956233, + :lon -93.3732881, + :house-number "21609", + :street "Liv 306", + :city "Hale", + :state-abbrev "MO", + :zip "64643"} + {:lat 59.8500631, + :lon -151.7150523, + :house-number "67945", + :street "Stoddard Avenue", + :city "Anchor Point", + :state-abbrev "AK", + :zip "99556"} + {:lat 38.8952257, + :lon -93.2806047, + :house-number "12851", + :street "Range Line Road", + :city "Houstonia", + :state-abbrev "MO", + :zip "65333"} + {:lat 33.2018945, + :lon -104.568924, + :house-number "242-254", + :street "Old Y O Crossing Road", + :city "Dexter", + :state-abbrev "NM", + :zip "88230"} + {:lat 41.029585, + :lon -93.153317, + :house-number "48908", + :street "310th Avenue", + :city "Russell", + :state-abbrev "IA", + :zip "50238"} + {:lat 26.9229735, + :lon -99.2503988, + :house-number "1803", + :street "Mier Avenue", + :city "Zapata", + :state-abbrev "TX", + :zip "78076"} + {:lat 32.249878, + :lon -88.457056, + :house-number "6311", + :street "Wallace Road", + :city "Meridian", + :state-abbrev "MS", + :zip "39301"} + {:lat 42.0848745, + :lon -90.31647199999999, + :house-number "5874", + :street "500th Avenue", + :city "Miles", + :state-abbrev "IA", + :zip "52064"} + {:lat 38.2981465, + :lon -95.24883729999999, + :house-number "27687", + :street "Northwest 1980th Road", + :city "Garnett", + :state-abbrev "KS", + :zip "66032"} + {:lat 37.8417416, + :lon -106.9536837, + :house-number "463", + :street "County Road 517", + :city "Creede", + :state-abbrev "CO", + :zip "81130"} + {:lat 43.0470953, + :lon -89.9016754, + :house-number "7866-8194", + :street "Byrn Grwyn Road", + :city "Barneveld", + :state-abbrev "WI", + :zip "53507"} + {:lat 43.8723442, + :lon -91.15840879999999, + :house-number "4000", + :street "South Kinney Coulee Road", + :city "Onalaska", + :state-abbrev "WI", + :zip "54650"} + {:lat 33.0377127, + :lon -116.8830116, + :house-number "100-112", + :street "Day Street", + :city "Ramona", + :state-abbrev "CA", + :zip "92065"} + {:lat 46.06438, + :lon -114.161997, + :house-number "2525", + :street "Rocky Mountain Road", + :city "Darby", + :state-abbrev "MT", + :zip "59829"} + {:lat 44.77638719999999, + :lon -69.3937615, + :house-number "407", + :street "Stinson Street", + :city "Pittsfield", + :state-abbrev "ME", + :zip "04967"} + {:lat 46.1943865, + :lon -109.4390499, + :house-number "105-361", + :street "Haase Road", + :city "Ryegate", + :state-abbrev "MT", + :zip "59074"} + {:lat 40.852284, + :lon -86.351699, + :house-number "1213", + :street "East Co Road 600 North", + :city "Logansport", + :state-abbrev "IN", + :zip "46947"} + {:lat 32.486036, + :lon -80.571241, + :house-number "43", + :street "Snapper Lane", + :city "Beaufort", + :state-abbrev "SC", + :zip "29907"} + {:lat 32.706736, + :lon -92.3541071, + :house-number "539", + :street "Mann Road", + :city "Downsville", + :state-abbrev "LA", + :zip "71234"} + {:lat 46.2678617, + :lon -84.752438, + :house-number "18640", + :street "U S F S 3136 Road", + :city "Rudyard", + :state-abbrev "MI", + :zip "49780"} + {:lat 44.0269686, + :lon -95.984054, + :house-number "1201-1239", + :street "40th Avenue", + :city "Lake Wilson", + :state-abbrev "MN", + :zip "56151"} + {:lat 39.5259232, + :lon -118.6169201, + :house-number "8400", + :street "Austin Road", + :city "Fallon", + :state-abbrev "NV", + :zip "89406"} + {:lat 64.86752899999999, + :lon -147.660289, + :house-number "271", + :street "Peters Road", + :city "Fairbanks", + :state-abbrev "AK", + :zip "99712"} + {:lat 37.499158, + :lon -120.553747, + :house-number "3728", + :street "Turlock Road", + :city "Snelling", + :state-abbrev "CA", + :zip "95369"} + {:lat 42.5105867, + :lon -96.0165025, + :house-number "1358-1398", + :street "Ida Avenue", + :city "Moville", + :state-abbrev "IA", + :zip "51039"} + {:lat 32.1078929, + :lon -94.4963762, + :house-number "893", + :street "County Road 151", + :city "Carthage", + :state-abbrev "TX", + :zip "75633"} + {:lat 29.56428, + :lon -98.71687899999999, + :house-number "11220", + :street "Indian Trail", + :city "Helotes", + :state-abbrev "TX", + :zip "78023"} + {:lat 46.871891, + :lon -109.003212, + :house-number "13160", + :street "Surenuff Road", + :city "Forest Grove", + :state-abbrev "MT", + :zip "59441"} + {:lat 37.4667781, + :lon -92.8629958, + :house-number "2059-2087", + :street "Country Trails Road", + :city "Conway", + :state-abbrev "MO", + :zip "65632"} + {:lat 44.6845383, + :lon -116.3997104, + :house-number "2368-2380", + :street "National Forest Development Road 199", + :city "Council", + :state-abbrev "ID", + :zip "83612"} + {:lat 30.0520353, + :lon -95.5334307, + :house-number "19020", + :street "Doerre Road", + :city "Spring", + :state-abbrev "TX", + :zip "77379"} + {:lat 35.250475, + :lon -91.03065199999999, + :house-number "93", + :street "U.S. Highway 64", + :city "McCrory", + :state-abbrev "AR", + :zip "72101"} + {:lat 38.809142, + :lon -85.04777299999999, + :house-number "325", + :street "Plum Creek Road", + :city "Vevay", + :state-abbrev "IN", + :zip "47043"} + {:lat 44.6804526, + :lon -98.13950709999999, + :house-number "18622", + :street "404th Avenue", + :city "Hitchcock", + :state-abbrev "SD", + :zip "57348"} + {:lat 40.7801538, + :lon -98.8175242, + :house-number "10870", + :street "Range Road", + :city "Gibbon", + :state-abbrev "NE", + :zip "68840"} + {:lat 40.9412188, + :lon -104.0093035, + :house-number "64920", + :street "County Road 111", + :city "Grover", + :state-abbrev "CO", + :zip "80729"} + {:lat 46.6835719, + :lon -91.49065929999999, + :house-number "76525", + :street "Middleman Road", + :city "Iron River", + :state-abbrev "WI", + :zip "54847"} + {:lat 46.3307976, + :lon -107.0807389, + :house-number "1360", + :street "Mission Valley Road", + :city "Hysham", + :state-abbrev "MT", + :zip "59038"} + {:lat 41.1401692, + :lon -80.383175, + :house-number "4529", + :street "New Castle Road", + :city "New Wilmington", + :state-abbrev "PA", + :zip "16142"} + {:lat 38.5239585, + :lon -90.2220954, + :house-number "108", + :street "Coulter Road", + :city "Dupo", + :state-abbrev "IL", + :zip "62240"} + {:lat 35.7222386, + :lon -95.5312242, + :house-number "2300", + :street "South 114th Street West", + :city "Muskogee", + :state-abbrev "OK", + :zip "74401"} + {:lat 39.4734539, + :lon -77.850335, + :house-number "1261", + :street "Cedar Lane", + :city "Shepherdstown", + :state-abbrev "WV", + :zip "25443"} + {:lat 47.6225856, + :lon -94.76550069999999, + :house-number "15753", + :street "Gull Lake Loop Road Northeast", + :city "Bemidji", + :state-abbrev "MN", + :zip "56601"} + {:lat 47.8606576, + :lon -97.8522739, + :house-number "4758-4798", + :street "13th Avenue Northeast", + :city "Larimore", + :state-abbrev "ND", + :zip "58251"} + {:lat 42.9046042, + :lon -71.5083034, + :house-number "16", + :street "Cottage Walk", + :city "Bedford", + :state-abbrev "NH", + :zip "03110"} + {:lat 38.0211479, + :lon -84.579594, + :house-number "1501", + :street "Beaumont Centre Lane", + :city "Lexington", + :state-abbrev "KY", + :zip "40513"} + {:lat 37.4740047, + :lon -90.7621156, + :house-number "27645", + :street "Missouri 21", + :city "Lesterville", + :state-abbrev "MO", + :zip "63654"} + {:lat 45.0111897, + :lon -122.9340654, + :house-number "6225-6403", + :street "62nd Avenue Northeast", + :city "Salem", + :state-abbrev "OR", + :zip "97305"} + {:lat 30.34925699999999, + :lon -81.810532, + :house-number "9278", + :street "Derby Acres Lane", + :city "Jacksonville", + :state-abbrev "FL", + :zip "32220"} + {:lat 42.9353519, + :lon -96.8425834, + :house-number "30716", + :street "Greenfield Road", + :city "Burbank", + :state-abbrev "SD", + :zip "57010"} + {:lat 36.4962399, + :lon -76.1600948, + :house-number "193-195", + :street "Eagle Creek Road", + :city "Moyock", + :state-abbrev "NC", + :zip "27958"} + {:lat 38.237254, + :lon -86.96439, + :house-number "9426", + :street "U.S. 231", + :city "Huntingburg", + :state-abbrev "IN", + :zip "47542"} + {:lat 43.557459, + :lon -84.6285341, + :house-number "5782", + :street "South Chippewa Road", + :city "Shepherd", + :state-abbrev "MI", + :zip "48883"} + {:lat 36.9634275, + :lon -84.72491509999999, + :house-number "148", + :street "Cedar Bluff Drive", + :city "Monticello", + :state-abbrev "KY", + :zip "42633"} + {:lat 45.3888078, + :lon -98.7651605, + :house-number "37119", + :street "138th Street", + :city "Mina", + :state-abbrev "SD", + :zip "57451"} + {:lat 48.5907054, + :lon -107.675887, + :house-number "2030", + :street "River Road", + :city "Malta", + :state-abbrev "MT", + :zip "59538"} + {:lat 46.7991253, + :lon -93.42379489999999, + :house-number "26989", + :street "560th Street", + :city "Palisade", + :state-abbrev "MN", + :zip "56469"} + {:lat 25.8698057, + :lon -81.173395, + :house-number "47201", + :street "Tamiami Trail East", + :city "Ochopee", + :state-abbrev "FL", + :zip "34141"} + {:lat 41.4273558, + :lon -89.5586106, + :house-number "19304", + :street "County Road 1550 East", + :city "Princeton", + :state-abbrev "IL", + :zip "61356"} + {:lat 33.3223142, + :lon -85.85702479999999, + :house-number "256", + :street "Mountain View Road", + :city "Ashland", + :state-abbrev "AL", + :zip "36251"} + {:lat 28.0595066, + :lon -82.32739939999999, + :house-number "11898", + :street "Tom Folsom Road", + :city "Thonotosassa", + :state-abbrev "FL", + :zip "33592"} + {:lat 48.686101, + :lon -104.741158, + :house-number "515", + :street "Welliver Road", + :city "Plentywood", + :state-abbrev "MT", + :zip "59254"} + {:lat 42.728378, + :lon -75.58789999999999, + :house-number "132", + :street "Merrifield Road", + :city "Earlville", + :state-abbrev "NY", + :zip "13332"} + {:lat 43.02555660000001, + :lon -123.3808739, + :house-number "2817", + :street "Boomer Hill Road", + :city "Myrtle Creek", + :state-abbrev "OR", + :zip "97457"} + {:lat 41.570531, + :lon -81.368055, + :house-number "7698", + :street "Hidden Valley Drive", + :city "Kirtland", + :state-abbrev "OH", + :zip "44094"} + {:lat 42.6449933, + :lon -99.955286, + :house-number "88413", + :street "426th Avenue", + :city "Ainsworth", + :state-abbrev "NE", + :zip "69210"} + {:lat 37.695751, + :lon -82.15174499999999, + :house-number "455", + :street "Lower Curry Branch Road", + :city "Delbarton", + :state-abbrev "WV", + :zip "25670"} + {:lat 40.425112, + :lon -75.74838, + :house-number "143", + :street "Lobachsville Road", + :city "Fleetwood", + :state-abbrev "PA", + :zip "19522"} + {:lat 29.2867264, + :lon -82.15236399999999, + :house-number "9370", + :street "US-441 South", + :city "Ocala", + :state-abbrev "FL", + :zip "34475"} + {:lat 34.1727417, + :lon -103.3267628, + :house-number "1680", + :street "South Roosevelt Road Q 1/2", + :city "Portales", + :state-abbrev "NM", + :zip "88130"} + {:lat 32.7751699, + :lon -81.56260879999999, + :house-number "3169", + :street "Brannens Bridge Road", + :city "Sylvania", + :state-abbrev "GA", + :zip "30467"} + {:lat 64.71566419999999, + :lon -148.5639821, + :house-number "328", + :street "George Parks Highway", + :city "Fairbanks", + :state-abbrev "AK", + :zip "99709"} + {:lat 43.816909, + :lon -75.421206, + :house-number "6459", + :street "Benton Road", + :city "Lowville", + :state-abbrev "NY", + :zip "13367"} + {:lat 48.4882457, + :lon -101.677415, + :house-number "4412-4472", + :street "74th Street Northwest", + :city "Carpio", + :state-abbrev "ND", + :zip "58725"} + {:lat 39.665793, + :lon -83.993715, + :house-number "812", + :street "Van Eaton Road", + :city "Xenia", + :state-abbrev "OH", + :zip "45385"} + {:lat 38.4189036, + :lon -86.25064789999999, + :house-number "14701-14899", + :street "West Pay Drive Northwest", + :city "Depauw", + :state-abbrev "IN", + :zip "47115"} + {:lat 45.6316468, + :lon -94.4986029, + :house-number "18995", + :street "Quaker Road", + :city "Albany", + :state-abbrev "MN", + :zip "56307"} + {:lat 45.79399, + :lon -120.9666936, + :house-number "1", + :street "Appaloosa Court", + :city "Centerville", + :state-abbrev "WA", + :zip "98613"} + {:lat 45.5686723, + :lon -89.3084379, + :house-number "3507", + :street "North Faust Lake Road", + :city "Rhinelander", + :state-abbrev "WI", + :zip "54501"} + {:lat 42.590495, + :lon -91.2598897, + :house-number "2754", + :street "137th Street", + :city "Earlville", + :state-abbrev "IA", + :zip "52041"} + {:lat 44.64831, + :lon -89.135053, + :house-number "N11196", + :street "Fisher Road", + :city "Iola", + :state-abbrev "WI", + :zip "54945"} + {:lat 36.7122979, + :lon -79.1152288, + :house-number "2150", + :street "Lewis Ferrell Road", + :city "South Boston", + :state-abbrev "VA", + :zip "24592"} + {:lat 44.136243, + :lon -93.1205566, + :house-number "5653", + :street "Northeast 46th Street", + :city "Owatonna", + :state-abbrev "MN", + :zip "55060"} + {:lat 31.8220068, + :lon -86.9211897, + :house-number "911", + :street "George Swamp Road", + :city "Pine Apple", + :state-abbrev "AL", + :zip "36768"} + {:lat 33.1818402, + :lon -93.25339939999999, + :house-number "70", + :street "Columbia Road 265", + :city "Magnolia", + :state-abbrev "AR", + :zip "71753"} + {:lat 32.4786567, + :lon -100.6970409, + :house-number "1-7", + :street "County Road 462", + :city "Loraine", + :state-abbrev "TX", + :zip "79532"} + {:lat 32.098464, + :lon -94.9092257, + :house-number "9798", + :street "County Road 471", + :city "Henderson", + :state-abbrev "TX", + :zip "75654"} + {:lat 47.6184996, + :lon -118.5427508, + :house-number "19602", + :street "7 Springs Dairy Road", + :city "Creston", + :state-abbrev "WA", + :zip "99117"} + {:lat 35.1566478, + :lon -103.9041753, + :house-number "6000-6398", + :street "Q R Az", + :city "Tucumcari", + :state-abbrev "NM", + :zip "88401"} + {:lat 37.3314546, + :lon -103.75748, + :house-number "21000-23904", + :street "County Road 153", + :city "Branson", + :state-abbrev "CO", + :zip "81027"} + {:lat 40.308699, + :lon -81.3817131, + :house-number "4325-4699", + :street "Watson Creek Road Southeast", + :city "Uhrichsville", + :state-abbrev "OH", + :zip "44683"} + {:lat 46.0871158, + :lon -90.2699255, + :house-number "1008-1164", + :street "County Highway FF", + :city "Butternut", + :state-abbrev "WI", + :zip "54514"} + {:lat 43.14766710000001, + :lon -97.7165775, + :house-number "330", + :street "Washington Street", + :city "Scotland", + :state-abbrev "SD", + :zip "57059"} + {:lat 36.9358921, + :lon -87.41141139999999, + :house-number "7330", + :street "Greenville Road", + :city "Hopkinsville", + :state-abbrev "KY", + :zip "42240"} + {:lat 48.51422489999999, + :lon -96.9010531, + :house-number "39296-39998", + :street "440th Street Northwest", + :city "Stephen", + :state-abbrev "MN", + :zip "56757"} + {:lat 48.6913934, + :lon -94.6949867, + :house-number "869", + :street "25th Avenue Southwest", + :city "Baudette", + :state-abbrev "MN", + :zip "56623"} + {:lat 40.1747307, + :lon -79.07656639999999, + :house-number "324", + :street "Spangler Road", + :city "Boswell", + :state-abbrev "PA", + :zip "15531"} + {:lat 35.630887, + :lon -86.95428799999999, + :house-number "834", + :street "Cranford Hollow Road", + :city "Columbia", + :state-abbrev "TN", + :zip "38401"} + {:lat 33.15448140000001, + :lon -111.5760606, + :house-number "899", + :street "West Daniel Road", + :city "San Tan Valley", + :state-abbrev "AZ", + :zip "85143"} + {:lat 63.88414579999999, + :lon -152.3099922, + :house-number "123", + :street "Airport way", + :city "Lake Minchumina", + :state-abbrev "AK", + :zip "99757"} + {:lat 47.0809647, + :lon -99.7587157, + :house-number "2290-2398", + :street "35th Avenue Southeast", + :city "Robinson", + :state-abbrev "ND", + :zip "58478"} + {:lat 40.493902, + :lon -82.893883, + :house-number "512", + :street "South Marion Street", + :city "Cardington", + :state-abbrev "OH", + :zip "43315"} + {:lat 40.8077553, + :lon -88.1398961, + :house-number "1700-1794", + :street "East 2800N Road", + :city "Piper City", + :state-abbrev "IL", + :zip "60959"} + {:lat 39.337351, + :lon -80.111975, + :house-number "539", + :street "Beverly Pike", + :city "Grafton", + :state-abbrev "WV", + :zip "26354"} + {:lat 40.88742420000001, + :lon -99.7145264, + :house-number "76299", + :street "Wiley Canyon Road", + :city "Lexington", + :state-abbrev "NE", + :zip "68850"} + {:lat 40.1645279, + :lon -90.5143044, + :house-number "20518", + :street "Carty Road", + :city "Rushville", + :state-abbrev "IL", + :zip "62681"} + {:lat 39.9229843, + :lon -94.17851689999999, + :house-number "12175-12179", + :street "Oval Avenue", + :city "Winston", + :state-abbrev "MO", + :zip "64689"} + {:lat 33.9322874, + :lon -99.0809671, + :house-number "15551-16213", + :street "Thaxton Pens Road", + :city "Electra", + :state-abbrev "TX", + :zip "76360"} + {:lat 36.035844, + :lon -77.00750239999999, + :house-number "748", + :street "Governors Road", + :city "Windsor", + :state-abbrev "NC", + :zip "27983"} + {:lat 40.6770766, + :lon -101.6959374, + :house-number "74868", + :street "330 Avenue", + :city "Imperial", + :state-abbrev "NE", + :zip "69033"} + {:lat 44.47460299999999, + :lon -93.2118069, + :house-number "32811", + :street "Garrett Avenue", + :city "Northfield", + :state-abbrev "MN", + :zip "55057"} + {:lat 44.711761, + :lon -89.80506299999999, + :house-number "1706", + :street "Fisher Lane", + :city "Mosinee", + :state-abbrev "WI", + :zip "54455"} + {:lat 30.9204382, + :lon -94.80944989999999, + :house-number "181", + :street "Rock Island Road", + :city "Moscow", + :state-abbrev "TX", + :zip "75960"} + {:lat 43.0761346, + :lon -83.8429441, + :house-number "5001-5129", + :street "Deland Road", + :city "Flushing", + :state-abbrev "MI", + :zip "48433"} + {:lat 64.67863249999999, + :lon -148.8758712, + :house-number "67", + :street "George Parks Highway", + :city "Nenana", + :state-abbrev "AK", + :zip "99760"} + {:lat 41.6846909, + :lon -74.4484736, + :house-number "102-270", + :street "Tempaloni Road", + :city "Ellenville", + :state-abbrev "NY", + :zip "12428"} + {:lat 40.2436957, + :lon -92.6596596, + :house-number "22971", + :street "Rye Creek Road", + :city "Kirksville", + :state-abbrev "MO", + :zip "63501"} + {:lat 28.59424, + :lon -96.735123, + :house-number "2412", + :street "Farm to Market 1679", + :city "Port Lavaca", + :state-abbrev "TX", + :zip "77979"} + {:lat 31.0430463, + :lon -81.9712313, + :house-number "13071", + :street "Winokur Road", + :city "Nahunta", + :state-abbrev "GA", + :zip "31553"} + {:lat 37.576548, + :lon -97.8057989, + :house-number "40502", + :street "West 63rd Street South", + :city "Cheney", + :state-abbrev "KS", + :zip "67025"} + {:lat 45.9095164, + :lon -91.19431709999999, + :house-number "10094", + :street "Campfire Circle", + :city "Hayward", + :state-abbrev "WI", + :zip "54843"} + {:lat 36.3751784, + :lon -92.2747564, + :house-number "7039", + :street "U.S. Highway 62", + :city "Mountain Home", + :state-abbrev "AR", + :zip "72653"} + {:lat 45.25263940000001, + :lon -102.3141182, + :house-number "18635", + :street "Usta Road", + :city "Faith", + :state-abbrev "SD", + :zip "57626"} + {:lat 36.8311004, + :lon -95.0253779, + :house-number "447534", + :street "East 130 Road", + :city "Bluejacket", + :state-abbrev "OK", + :zip "74333"} + {:lat 45.2530768, + :lon -90.206575, + :house-number "W3110", + :street "Wood Creek Avenue", + :city "Medford", + :state-abbrev "WI", + :zip "54451"} + {:lat 30.2842935, + :lon -95.5964412, + :house-number "4739-5421", + :street "Honea Egypt Road", + :city "Montgomery", + :state-abbrev "TX", + :zip "77316"} + {:lat 40.8287855, + :lon -98.0202961, + :house-number "1461-1493", + :street "West 10 Road", + :city "Aurora", + :state-abbrev "NE", + :zip "68818"} + {:lat 35.618299, + :lon -81.070032, + :house-number "3427", + :street "Joe Johnson Road", + :city "Catawba", + :state-abbrev "NC", + :zip "28609"} + {:lat 38.1662389, + :lon -95.68379499999999, + :house-number "901-999", + :street "Planter Road", + :city "Burlington", + :state-abbrev "KS", + :zip "66839"} + {:lat 38.3983727, + :lon -100.5071491, + :house-number "74", + :street "West Road 130", + :city "Dighton", + :state-abbrev "KS", + :zip "67839"} + {:lat 35.715097, + :lon -82.821319, + :house-number "650", + :street "Halfmoon Ridge", + :city "Hot Springs", + :state-abbrev "NC", + :zip "28743"} + {:lat 34.4179036, + :lon -102.0900064, + :house-number "2303-2349", + :street "County Road 526", + :city "Dimmitt", + :state-abbrev "TX", + :zip "79027"} + {:lat 37.4976312, + :lon -80.745508, + :house-number "204", + :street "Fritz Run Road", + :city "Forest Hill", + :state-abbrev "WV", + :zip "24935"} + {:lat 40.3159813, + :lon -96.3239148, + :house-number "61345", + :street "724 Road", + :city "Tecumseh", + :state-abbrev "NE", + :zip "68450"} + {:lat 37.7264844, + :lon -92.7464256, + :house-number "26496", + :street "Noland Drive", + :city "Lebanon", + :state-abbrev "MO", + :zip "65536"} + {:lat 46.2965995, + :lon -106.246808, + :house-number "2446", + :street "Cartersville Road", + :city "Rosebud", + :state-abbrev "MT", + :zip "59347"} + {:lat 27.4748437, + :lon -82.3284395, + :house-number "20890", + :street "Florida 64", + :city "Bradenton", + :state-abbrev "FL", + :zip "34212"} + {:lat 31.5021188, + :lon -88.2518348, + :house-number "565", + :street "Ellis Jordan Sawmill Road", + :city "Chatom", + :state-abbrev "AL", + :zip "36518"} + {:lat 35.572079, + :lon -85.157082, + :house-number "701", + :street "Cherokee Ridge Road", + :city "Pikeville", + :state-abbrev "TN", + :zip "37367"} + {:lat 40.5912064, + :lon -85.77687759999999, + :house-number "5399", + :street "West Henderson Court", + :city "Marion", + :state-abbrev "IN", + :zip "46952"} + {:lat 29.693961, + :lon -97.501752, + :house-number "5142", + :street "Fm 1386 Harwood", + :city "Luling", + :state-abbrev "TX", + :zip "78648"} + {:lat 31.2508327, + :lon -103.9209099, + :house-number "300", + :street "County Road 229", + :city "Toyah", + :state-abbrev "TX", + :zip "79785"} + {:lat 44.7851097, + :lon -91.92887069999999, + :house-number "4400-4410", + :street "290th Avenue", + :city "Menomonie", + :state-abbrev "WI", + :zip "54751"} + {:lat 42.8251985, + :lon -102.276917, + :house-number "6680", + :street "240th Lane", + :city "Gordon", + :state-abbrev "NE", + :zip "69343"} + {:lat 44.3388163, + :lon -83.42027739999999, + :house-number "1861", + :street "Davison Road", + :city "East Tawas", + :state-abbrev "MI", + :zip "48730"} + {:lat 62.327013, + :lon -150.0131199, + :house-number "21871", + :street "Hancock Street", + :city "Talkeetna", + :state-abbrev "AK", + :zip "99676"} + {:lat 39.9534575, + :lon -77.6047306, + :house-number "3199", + :street "Fox Hill Drive", + :city "Chambersburg", + :state-abbrev "PA", + :zip "17202"} + {:lat 31.6590638, + :lon -87.53021179999999, + :house-number "4000-5282", + :street "Mabien Lake Road", + :city "Franklin", + :state-abbrev "AL", + :zip "36444"} + {:lat 37.1956175, + :lon -103.9941708, + :house-number "13623-14999", + :street "County Road 127", + :city "Trinchera", + :state-abbrev "CO", + :zip "81081"} + {:lat 46.6717504, + :lon -116.7632031, + :house-number "1043-1057", + :street "Bethel Road", + :city "Troy", + :state-abbrev "ID", + :zip "83871"} + {:lat 39.7178499, + :lon -88.9226398, + :house-number "8417-9203", + :street "Walmsley Road", + :city "Macon", + :state-abbrev "IL", + :zip "62544"} + {:lat 39.30385709999999, + :lon -87.4732437, + :house-number "3675", + :street "West Evans Drive", + :city "Terre Haute", + :state-abbrev "IN", + :zip "47802"} + {:lat 31.7716629, + :lon -91.59559, + :house-number "974", + :street "Highway 567", + :city "Clayton", + :state-abbrev "LA", + :zip "71326"} + {:lat 42.4732457, + :lon -89.1208698, + :house-number "4002", + :street "Yale Bridge Road", + :city "Rockton", + :state-abbrev "IL", + :zip "61072"} + {:lat 40.2688392, + :lon -96.9157705, + :house-number "26028", + :street "Southwest 142 Road", + :city "Beatrice", + :state-abbrev "NE", + :zip "68310"} + {:lat 44.2619463, + :lon -121.1254701, + :house-number "2899", + :street "Oregon 126", + :city "Redmond", + :state-abbrev "OR", + :zip "97756"} + {:lat 34.90789, + :lon -84.214333, + :house-number "100", + :street "Echo Valley Road", + :city "Morganton", + :state-abbrev "GA", + :zip "30560"} + {:lat 41.2484208, + :lon -89.6577463, + :house-number "10000-10484", + :street "County Road 700 North", + :city "Buda", + :state-abbrev "IL", + :zip "61314"} + {:lat 31.6778338, + :lon -95.05052529999999, + :house-number "2200", + :street "County Road 2501", + :city "Alto", + :state-abbrev "TX", + :zip "75925"} + {:lat 34.788219, + :lon -87.5892764, + :house-number "1815", + :street "River Road", + :city "Muscle Shoals", + :state-abbrev "AL", + :zip "35661"} + {:lat 44.0660442, + :lon -96.8579036, + :house-number "46756", + :street "229th Street", + :city "Colman", + :state-abbrev "SD", + :zip "57017"} + {:lat 35.1008912, + :lon -93.7577212, + :house-number "602-606", + :street "Catlett Lane", + :city "Magazine", + :state-abbrev "AR", + :zip "72943"} + {:lat 42.0012328, + :lon -93.5811332, + :house-number "1813-1899", + :street "560th Avenue", + :city "Ames", + :state-abbrev "IA", + :zip "50010"} + {:lat 42.1118798, + :lon -86.07009219999999, + :house-number "84000-87998", + :street "54th Street", + :city "Decatur", + :state-abbrev "MI", + :zip "49045"} + {:lat 36.8677232, + :lon -84.2397771, + :house-number "813", + :street "Rosetown Church Road", + :city "Corbin", + :state-abbrev "KY", + :zip "40701"} + {:lat 44.77214, + :lon -73.4780679, + :house-number "207", + :street "Ashley Road", + :city "Plattsburgh", + :state-abbrev "NY", + :zip "12901"} + {:lat 45.3850832, + :lon -105.9586621, + :house-number "108", + :street "15 Mile Road", + :city "Sonnette", + :state-abbrev "MT", + :zip "59317"} + {:lat 40.29477139999999, + :lon -74.108205, + :house-number "7", + :street "Duke Court", + :city "Tinton Falls", + :state-abbrev "NJ", + :zip "07724"} + {:lat 29.796155, + :lon -96.17647799999999, + :house-number "972", + :street "FM 2187 Road", + :city "Sealy", + :state-abbrev "TX", + :zip "77474"} + {:lat 48.5031535, + :lon -122.8041454, + :house-number "134-372", + :street "Armitage Road", + :city "Blakely Island", + :state-abbrev "WA", + :zip "98222"} + {:lat 46.1421636, + :lon -102.9751943, + :house-number "1001-1075", + :street "17th Street Northwest", + :city "Reeder", + :state-abbrev "ND", + :zip "58649"} + {:lat 43.886111, + :lon -92.16992599999999, + :house-number "9241", + :street "County Road 10 Southeast", + :city "Chatfield", + :state-abbrev "MN", + :zip "55923"} + {:lat 30.9297181, + :lon -90.5465336, + :house-number "74212", + :street "Wyndotte Road", + :city "Kentwood", + :state-abbrev "LA", + :zip "70444"} + {:lat 38.920534, + :lon -94.180622, + :house-number "34604", + :street "East Hammond Road", + :city "Lone Jack", + :state-abbrev "MO", + :zip "64070"} + {:lat 39.8700699, + :lon -96.69368109999999, + :house-number "1000-1098", + :street "7th Road", + :city "Marysville", + :state-abbrev "KS", + :zip "66508"} + {:lat 41.69481, + :lon -87.57314679999999, + :house-number "11001", + :street "South Jeffery Avenue", + :city "Chicago", + :state-abbrev "IL", + :zip "60617"} + {:lat 32.2916274, + :lon -110.8232995, + :house-number "4645-4653", + :street "North Black Rock Place", + :city "Tucson", + :state-abbrev "AZ", + :zip "85750"} + {:lat 44.5974832, + :lon -85.6202182, + :house-number "2125", + :street "Clous Road", + :city "Kingsley", + :state-abbrev "MI", + :zip "49649"} + {:lat 33.43045740000001, + :lon -80.2814899, + :house-number "729", + :street "Ferguson Landing Way", + :city "Eutawville", + :state-abbrev "SC", + :zip "29048"} + {:lat 38.52552, + :lon -106.162496, + :house-number "7005", + :street "County Road 221", + :city "Salida", + :state-abbrev "CO", + :zip "81201"} + {:lat 41.1347313, + :lon -75.27136829999999, + :house-number "588", + :street "Cranberry Creek Road", + :city "Cresco", + :state-abbrev "PA", + :zip "18326"} + {:lat 44.805811, + :lon -106.662583, + :house-number "366", + :street "South R Buffalo Creek Road", + :city "Sheridan", + :state-abbrev "WY", + :zip "82801"} + {:lat 43.105184, + :lon -92.559314, + :house-number "3383", + :street "180th Street", + :city "Ionia", + :state-abbrev "IA", + :zip "50645"} + {:lat 40.171608, + :lon -88.153183, + :house-number "1652", + :street "County Road 2000 North", + :city "Urbana", + :state-abbrev "IL", + :zip "61802"} + {:lat 35.175917, + :lon -78.318257, + :house-number "163", + :street "Holiday Lane", + :city "Newton Grove", + :state-abbrev "NC", + :zip "28366"} + {:lat 37.26021110000001, + :lon -100.1818605, + :house-number "15454-15806", + :street "27 Road", + :city "Fowler", + :state-abbrev "KS", + :zip "67844"} + {:lat 44.3558952, + :lon -102.4925634, + :house-number "17938-17944", + :street "River Road", + :city "Wasta", + :state-abbrev "SD", + :zip "57791"} + {:lat 35.820176, + :lon -87.7719951, + :house-number "719", + :street "Lost Creek Road", + :city "Lobelville", + :state-abbrev "TN", + :zip "37097"} + {:lat 38.3082323, + :lon -89.4215566, + :house-number "15093-15749", + :street "Nixon Road", + :city "Nashville", + :state-abbrev "IL", + :zip "62263"} + {:lat 35.8872101, + :lon -78.68693979999999, + :house-number "3001", + :street "Howard Road", + :city "Raleigh", + :state-abbrev "NC", + :zip "27613"} + {:lat 34.1413335, + :lon -96.00751199999999, + :house-number "3078-3088", + :street "Mossy Lake Road", + :city "Bennington", + :state-abbrev "OK", + :zip "74723"} + {:lat 46.48775879999999, + :lon -90.99776849999999, + :house-number "63201-63227", + :street "Franciskovich Road", + :city "Mason", + :state-abbrev "WI", + :zip "54856"} + {:lat 37.7011357, + :lon -86.0150568, + :house-number "6626-7298", + :street "Long Grove Road", + :city "Elizabethtown", + :state-abbrev "KY", + :zip "42701"} + {:lat 41.77538, + :lon -92.8673, + :house-number "11449", + :street "North 67th Avenue East", + :city "Kellogg", + :state-abbrev "IA", + :zip "50135"} + {:lat 39.778217, + :lon -105.29644, + :house-number "26102", + :street "Golden Gate Canyon Road", + :city "Golden", + :state-abbrev "CO", + :zip "80403"} + {:lat 38.620669, + :lon -104.406894, + :house-number "22771", + :street "Sagebrush Lane", + :city "Colorado Springs", + :state-abbrev "CO", + :zip "80928"} + {:lat 42.287588, + :lon -104.8312269, + :house-number "262-278", + :street "Wendover Road", + :city "Guernsey", + :state-abbrev "WY", + :zip "82214"} + {:lat 46.6744302, + :lon -98.6800164, + :house-number "8453-8499", + :street "51st Street Southeast", + :city "Montpelier", + :state-abbrev "ND", + :zip "58472"} + {:lat 29.355971, + :lon -95.628168, + :house-number "22416", + :street "East Farm to Market 1462", + :city "Damon", + :state-abbrev "TX", + :zip "77430"} + {:lat 32.154442, + :lon -90.16464719999999, + :house-number "193-203", + :street "Zelma Lane", + :city "Florence", + :state-abbrev "MS", + :zip "39073"} + {:lat 36.3033271, + :lon -85.7327644, + :house-number "391", + :street "Frizzell Hollow Road", + :city "Gainesboro", + :state-abbrev "TN", + :zip "38562"} + {:lat 40.5445495, + :lon -100.6019921, + :house-number "38619", + :street "Road 740", + :city "Curtis", + :state-abbrev "NE", + :zip "69025"} + {:lat 26.249905, + :lon -97.63499449999999, + :house-number "21344", + :street "Krupala Road", + :city "Harlingen", + :state-abbrev "TX", + :zip "78550"} + {:lat 29.7187481, + :lon -97.91061529999999, + :house-number "4201", + :street "Old Lehman Road", + :city "Kingsbury", + :state-abbrev "TX", + :zip "78638"} + {:lat 35.114211, + :lon -81.7128989, + :house-number "1403", + :street "Chesnee Highway", + :city "Gaffney", + :state-abbrev "SC", + :zip "29341"} + {:lat 44.0759145, + :lon -89.10181390000001, + :house-number "4441", + :street "Buttercup Drive", + :city "Redgranite", + :state-abbrev "WI", + :zip "54970"} + {:lat 35.6755522, + :lon -78.76927649999999, + :house-number "3904", + :street "Summer Brook Drive", + :city "Apex", + :state-abbrev "NC", + :zip "27539"} + {:lat 44.7139688, + :lon -105.1429957, + :house-number "1311", + :street "Heald Road", + :city "Gillette", + :state-abbrev "WY", + :zip "82731"} + {:lat 36.397726, + :lon -88.528498, + :house-number "2285", + :street "Hunt Road", + :city "Cottage Grove", + :state-abbrev "TN", + :zip "38224"} + {:lat 38.136482, + :lon -80.790978, + :house-number "4182", + :street "Upper Anglins Creek", + :city "Mount Nebo", + :state-abbrev "WV", + :zip "26679"} + {:lat 43.9163659, + :lon -73.3899132, + :house-number "1800-1952", + :street "Lake Street", + :city "Shoreham", + :state-abbrev "VT", + :zip "05770"} + {:lat 35.0182001, + :lon -76.76226489999999, + :house-number "106", + :street "Creekside Lane", + :city "Oriental", + :state-abbrev "NC", + :zip "28571"} + {:lat 48.9439658, + :lon -104.8569088, + :house-number "295-299", + :street "Big Valley Road", + :city "Outlook", + :state-abbrev "MT", + :zip "59252"} + {:lat 40.4370093, + :lon -79.6483465, + :house-number "3475", + :street "Lake Ridge Drive", + :city "Murrysville", + :state-abbrev "PA", + :zip "15668"} + {:lat 40.711495, + :lon -74.929238, + :house-number "1", + :street "Spring Brook Lane", + :city "Glen Gardner", + :state-abbrev "NJ", + :zip "08826"} + {:lat 32.4896481, + :lon -99.7583769, + :house-number "3060", + :street "West Overland Trail", + :city "Abilene", + :state-abbrev "TX", + :zip "79603"} + {:lat 34.6214936, + :lon -78.91809429999999, + :house-number "166", + :street "Bessie", + :city "Lumberton", + :state-abbrev "NC", + :zip "28358"} + {:lat 42.2472548, + :lon -121.3915386, + :house-number "1517-4093", + :street "Bly Mountain Cutoff Road", + :city "Bonanza", + :state-abbrev "OR", + :zip "97623"} + {:lat 42.6855014, + :lon -76.2889201, + :house-number "1-661", + :street "Poverty Lane", + :city "Cortland", + :state-abbrev "NY", + :zip "13045"} + {:lat 44.5892651, + :lon -94.2497736, + :house-number "48824", + :street "250th Street", + :city "Gaylord", + :state-abbrev "MN", + :zip "55334"} + {:lat 47.272479, + :lon -116.3010206, + :house-number "4500", + :street "Gold Ridge Road", + :city "Saint Maries", + :state-abbrev "ID", + :zip "83861"} + {:lat 39.5533571, + :lon -97.2362132, + :house-number "801-881", + :street "30th Road", + :city "Clifton", + :state-abbrev "KS", + :zip "66937"} + {:lat 40.6242207, + :lon -93.865386, + :house-number "28765", + :street "U.S. 69", + :city "Davis City", + :state-abbrev "IA", + :zip "50065"} + {:lat 41.3743424, + :lon -94.6883993, + :house-number "1054-1076", + :street "190th Street", + :city "Anita", + :state-abbrev "IA", + :zip "50020"} + {:lat 41.00518599999999, + :lon -91.153881, + :house-number "136", + :street "Centennial Drive", + :city "Mediapolis", + :state-abbrev "IA", + :zip "52637"} + {:lat 36.8508045, + :lon -76.24297589999999, + :house-number "3448-3460", + :street "Trant Avenue", + :city "Norfolk", + :state-abbrev "VA", + :zip "23502"} + {:lat 35.5537, + :lon -85.543933, + :house-number "17162", + :street "Tennessee 8", + :city "McMinnville", + :state-abbrev "TN", + :zip "37110"} + {:lat 48.5054476, + :lon -113.9800674, + :house-number "113", + :street "Logan Lane", + :city "West Glacier", + :state-abbrev "MT", + :zip "59936"} + {:lat 44.6072478, + :lon -97.04753799999999, + :house-number "19149-19199", + :street "458th Avenue", + :city "Castlewood", + :state-abbrev "SD", + :zip "57223"} + {:lat 42.1794235, + :lon -90.1845962, + :house-number "6456-7908", + :street "Camp Creek Road", + :city "Savanna", + :state-abbrev "IL", + :zip "61074"} + {:lat 47.1812463, + :lon -94.1146838, + :house-number "3037-3167", + :street "104th Street Northeast", + :city "Remer", + :state-abbrev "MN", + :zip "56672"} + {:lat 63.5664678, + :lon -148.8142708, + :house-number "224", + :street "George Parks Highway", + :city "Denali National Park and Preserve", + :state-abbrev "AK", + :zip "99755"} + {:lat 42.54258309999999, + :lon -99.48099270000002, + :house-number "87743", + :street "450th Avenue", + :city "Bassett", + :state-abbrev "NE", + :zip "68714"} + {:lat 33.3037867, + :lon -91.7920324, + :house-number "244", + :street "Ashley Road", + :city "Hamburg", + :state-abbrev "AR", + :zip "71646"} + {:lat 36.607847, + :lon -96.9313729, + :house-number "939", + :street "80 Road", + :city "Ponca City", + :state-abbrev "OK", + :zip "74604"} + {:lat 46.466885, + :lon -120.392185, + :house-number "2141", + :street "Donald Wapato Road", + :city "Wapato", + :state-abbrev "WA", + :zip "98951"} + {:lat 42.6573649, + :lon -98.5648155, + :house-number "88536", + :street "496th Avenue", + :city "O'Neill", + :state-abbrev "NE", + :zip "68763"} + {:lat 40.97705, + :lon -104.282042, + :house-number "67125", + :street "County Road 83", + :city "Grover", + :state-abbrev "CO", + :zip "80729"} + {:lat 43.2851418, + :lon -115.1254857, + :house-number "9546", + :street "County Line Road", + :city "Hill City", + :state-abbrev "ID", + :zip "83337"} + {:lat 34.5428605, + :lon -88.07217159999999, + :house-number "2508", + :street "County Road 90", + :city "Red Bay", + :state-abbrev "AL", + :zip "35582"} + {:lat 33.3112533, + :lon -88.9530561, + :house-number "444", + :street "York Road", + :city "Sturgis", + :state-abbrev "MS", + :zip "39769"} + {:lat 38.290215, + :lon -75.33184709999999, + :house-number "8410", + :street "Ninepin Branch Road", + :city "Berlin", + :state-abbrev "MD", + :zip "21811"} + {:lat 48.3250683, + :lon -99.4660873, + :house-number "5317-5323", + :street "63rd Street Northeast", + :city "Leeds", + :state-abbrev "ND", + :zip "58346"} + {:lat 32.85935, + :lon -89.90156449999999, + :house-number "504-766", + :street "Burrell Road", + :city "Pickens", + :state-abbrev "MS", + :zip "39146"} + {:lat 32.0280591, + :lon -91.83638619999999, + :house-number "572-798", + :street "Louisiana 871", + :city "Winnsboro", + :state-abbrev "LA", + :zip "71295"} + {:lat 32.2929204, + :lon -88.7441599, + :house-number "5396-5492", + :street "Valley Road", + :city "Meridian", + :state-abbrev "MS", + :zip "39307"} + {:lat 43.318729, + :lon -85.77300699999999, + :house-number "1152", + :street "East 128th Street", + :city "Grant", + :state-abbrev "MI", + :zip "49327"} + {:lat 36.4598366, + :lon -85.3474748, + :house-number "100-312", + :street "Hunter Cove Road", + :city "Allons", + :state-abbrev "TN", + :zip "38541"} + {:lat 43.4705134, + :lon -88.0708804, + :house-number "7701-7759", + :street "Meadow Road", + :city "West Bend", + :state-abbrev "WI", + :zip "53090"} + {:lat 28.854199, + :lon -96.92175739999999, + :house-number "611", + :street "Foster Field Drive", + :city "Victoria", + :state-abbrev "TX", + :zip "77904"} + {:lat 39.27953000000001, + :lon -76.01646099999999, + :house-number "11020", + :street "Perkins Hill Road", + :city "Chestertown", + :state-abbrev "MD", + :zip "21620"} + {:lat 30.8716958, + :lon -81.5971895, + :house-number "155", + :street "Sunrise Drive", + :city "Woodbine", + :state-abbrev "GA", + :zip "31569"} + {:lat 33.9551463, + :lon -83.8301824, + :house-number "2200-2998", + :street "Harfield Court Southeast", + :city "Bethlehem", + :state-abbrev "GA", + :zip "30620"} + {:lat 44.5646367, + :lon -100.3644203, + :house-number "19400-19484", + :street "288th Avenue", + :city "Pierre", + :state-abbrev "SD", + :zip "57501"} + {:lat 40.46128239999999, + :lon -80.6199252, + :house-number "443", + :street "Fairview Heights Drive", + :city "Toronto", + :state-abbrev "OH", + :zip "43964"} + {:lat 46.22892179999999, + :lon -93.0031521, + :house-number "14256", + :street "Dahlstein Road", + :city "Finlayson", + :state-abbrev "MN", + :zip "55735"} + {:lat 28.171865, + :lon -99.040449, + :house-number "2065", + :street "Huajuco Lane Lasalle Co", + :city "Cotulla", + :state-abbrev "TX", + :zip "78014"} + {:lat 40.0093863, + :lon -105.4231668, + :house-number "6181", + :street "Sugarloaf Road", + :city "Boulder", + :state-abbrev "CO", + :zip "80302"} + {:lat 47.58456349999999, + :lon -106.1512125, + :house-number "1798", + :street "Montana 24", + :city "Circle", + :state-abbrev "MT", + :zip "59215"} + {:lat 40.510231, + :lon -104.974479, + :house-number "5317", + :street "South Co Road 3f", + :city "Fort Collins", + :state-abbrev "CO", + :zip "80528"} + {:lat 42.3615365, + :lon -71.3522355, + :house-number "8", + :street "Melody Lane", + :city "Wayland", + :state-abbrev "MA", + :zip "01778"} + {:lat 47.873104, + :lon -117.388124, + :house-number "23626", + :street "North Perry Road", + :city "Colbert", + :state-abbrev "WA", + :zip "99005"} + {:lat 41.2992808, + :lon -99.6296247, + :house-number "79369", + :street "Drive 439", + :city "Broken Bow", + :state-abbrev "NE", + :zip "68822"} + {:lat 35.8696915, + :lon -79.6465757, + :house-number "5346", + :street "Ramseur Julian Road", + :city "Liberty", + :state-abbrev "NC", + :zip "27298"} + {:lat 45.7665632, + :lon -99.2717316, + :house-number "34601-34605", + :street "112th Street", + :city "Eureka", + :state-abbrev "SD", + :zip "57437"} + {:lat 34.808124, + :lon -86.91013699999999, + :house-number "17400", + :street "Oakdale Road", + :city "Athens", + :state-abbrev "AL", + :zip "35613"} + {:lat 62.9602798, + :lon -143.353976, + :house-number "800", + :street "Tok Highway", + :city "Gakona", + :state-abbrev "AK", + :zip "99586"} + {:lat 44.005569, + :lon -69.320786, + :house-number "308", + :street "Cushing Road", + :city "Friendship", + :state-abbrev "ME", + :zip "04547"} + {:lat 41.3181687, + :lon -96.17180270000001, + :house-number "16500-16548", + :street "Bauman Circle", + :city "Omaha", + :state-abbrev "NE", + :zip "68116"} + {:lat 45.2915529, + :lon -96.4570057, + :house-number "101-151", + :street "Main Street", + :city "Big Stone City", + :state-abbrev "SD", + :zip "57216"} + {:lat 41.046256, + :lon -87.95983199999999, + :house-number "4777", + :street "West 5000 Road South", + :city "Kankakee", + :state-abbrev "IL", + :zip "60901"} + {:lat 30.415433, + :lon -87.54827499999999, + :house-number "13151", + :street "County Road 95", + :city "Elberta", + :state-abbrev "AL", + :zip "36530"} + {:lat 29.0252997, + :lon -98.50175449999999, + :house-number "300", + :street "Hillside", + :city "Pleasanton", + :state-abbrev "TX", + :zip "78064"} + {:lat 44.10125660000001, + :lon -96.5869936, + :house-number "22656", + :street "South Dakota 13", + :city "Flandreau", + :state-abbrev "SD", + :zip "57028"} + {:lat 47.6908723, + :lon -117.5036908, + :house-number "3201-4915", + :street "North Indian Bluff Road", + :city "Spokane", + :state-abbrev "WA", + :zip "99224"} + {:lat 32.3482789, + :lon -98.16811640000002, + :house-number "27485", + :street "North Sh108", + :city "Stephenville", + :state-abbrev "TX", + :zip "76401"} + {:lat 65.068141, + :lon -146.1060121, + :house-number "17030", + :street "Chena Hot Springs Road", + :city "Fairbanks", + :state-abbrev "AK", + :zip "99712"} + {:lat 46.807219, + :lon -68.121844, + :house-number "1747", + :street "Washburn Road", + :city "Washburn", + :state-abbrev "ME", + :zip "04786"} + {:lat 41.4077159, + :lon -91.79534540000002, + :house-number "1795", + :street "170th Street", + :city "Wellman", + :state-abbrev "IA", + :zip "52356"} + {:lat 29.9355215, + :lon -96.1070745, + :house-number "42560", + :street "Harpers Church Road", + :city "Bellville", + :state-abbrev "TX", + :zip "77418"} + {:lat 29.7829284, + :lon -97.2045977, + :house-number "6921", + :street "Three Mile Road", + :city "Flatonia", + :state-abbrev "TX", + :zip "78941"} + {:lat 37.299598, + :lon -113.101774, + :house-number "9", + :street "Kolob Terrace Road", + :city "Virgin", + :state-abbrev "UT", + :zip "84779"} + {:lat 36.190323, + :lon -96.37033199999999, + :house-number "2229", + :street "North Cocomo Loop", + :city "Mannford", + :state-abbrev "OK", + :zip "74044"} + {:lat 44.9343023, + :lon -71.68654409999999, + :house-number "185-1883", + :street "Sable Mountain Road", + :city "Canaan", + :state-abbrev "VT", + :zip "05903"} + {:lat 33.9722673, + :lon -91.6113769, + :house-number "15729", + :street "Arkansas 114", + :city "Gould", + :state-abbrev "AR", + :zip "71643"} + {:lat 40.1208677, + :lon -78.13654430000001, + :house-number "2139", + :street "Schenck Road", + :city "Wells Tannery", + :state-abbrev "PA", + :zip "16691"} + {:lat 32.5836029, + :lon -85.344824, + :house-number "575", + :street "Lee Road 40", + :city "Opelika", + :state-abbrev "AL", + :zip "36804"} + {:lat 32.1291719, + :lon -98.48731199999999, + :house-number "770", + :street "County Road 462", + :city "De Leon", + :state-abbrev "TX", + :zip "76444"} + {:lat 39.5802385, + :lon -103.5189258, + :house-number "9794-10792", + :street "County Road 1", + :city "Genoa", + :state-abbrev "CO", + :zip "80818"} + {:lat 37.46576719999999, + :lon -103.4180605, + :house-number "32523-32943", + :street "County Road 193.5", + :city "Kim", + :state-abbrev "CO", + :zip "81049"} + {:lat 43.5907349, + :lon -100.0809238, + :house-number "26279", + :street "304th Avenue", + :city "Winner", + :state-abbrev "SD", + :zip "57580"} + {:lat 40.7147525, + :lon -85.39709859999999, + :house-number "2748", + :street "East 800 South", + :city "Warren", + :state-abbrev "IN", + :zip "46792"} + {:lat 35.6193693, + :lon -80.34309979999999, + :house-number "1550", + :street "Poole Road", + :city "Salisbury", + :state-abbrev "NC", + :zip "28146"} + {:lat 41.5718425, + :lon -74.151854, + :house-number "334-336", + :street "Lake Osiris Road", + :city "Walden", + :state-abbrev "NY", + :zip "12586"} + {:lat 43.314036, + :lon -96.83119699999999, + :house-number "46865", + :street "281st Street", + :city "Lennox", + :state-abbrev "SD", + :zip "57039"} + {:lat 42.841993, + :lon -92.182942, + :house-number "1444", + :street "Tahoe Avenue", + :city "Sumner", + :state-abbrev "IA", + :zip "50674"} + {:lat 36.4249024, + :lon -90.1034226, + :house-number "29846", + :street "County Road 305", + :city "Campbell", + :state-abbrev "MO", + :zip "63933"} + {:lat 42.2602933, + :lon -121.5564791, + :house-number "19047", + :street "Highway 140 East", + :city "Dairy", + :state-abbrev "OR", + :zip "97625"} + {:lat 35.145161, + :lon -113.511055, + :house-number "9161", + :street "North Concho Drive", + :city "Kingman", + :state-abbrev "AZ", + :zip "86401"} + {:lat 44.9144835, + :lon -69.1501892, + :house-number "256", + :street "Clark's Hill Road", + :city "Stetson", + :state-abbrev "ME", + :zip "04488"} + {:lat 46.133132, + :lon -112.668086, + :house-number "2627", + :street "Telegraph Gulch", + :city "Butte", + :state-abbrev "MT", + :zip "59701"} + {:lat 36.1289603, + :lon -87.55618969999999, + :house-number "1588", + :street "Tummins Road", + :city "McEwen", + :state-abbrev "TN", + :zip "37101"} + {:lat 40.4173527, + :lon -74.3017223, + :house-number "145-175", + :street "Jake Brown Road", + :city "Old Bridge Township", + :state-abbrev "NJ", + :zip "08857"} + {:lat 48.0951728, + :lon -101.3786815, + :house-number "6001", + :street "135th Avenue Southwest", + :city "Minot", + :state-abbrev "ND", + :zip "58701"} + {:lat 41.514026, + :lon -93.362155, + :house-number "402", + :street "Molly Court", + :city "Runnells", + :state-abbrev "IA", + :zip "50237"} + {:lat 38.358538, + :lon -87.102161, + :house-number "4410", + :street "South 3rd Street", + :city "Velpen", + :state-abbrev "IN", + :zip "47590"} + {:lat 30.6230283, + :lon -91.0465433, + :house-number "12512", + :street "Greenwell Spring Pt Hudso Road", + :city "Zachary", + :state-abbrev "LA", + :zip "70791"} + {:lat 40.7192098, + :lon -78.11898719999999, + :house-number "1446", + :street "Centre Line Road", + :city "Warriors Mark", + :state-abbrev "PA", + :zip "16877"} + {:lat 39.3107383, + :lon -122.5328865, + :house-number "4475", + :street "Lodoga Stonyford Road", + :city "Stonyford", + :state-abbrev "CA", + :zip "95979"} + {:lat 43.9247077, + :lon -69.8961209, + :house-number "261", + :street "Old Bath Road", + :city "Brunswick", + :state-abbrev "ME", + :zip "04011"} + {:lat 38.2939375, + :lon -95.76334899999999, + :house-number "1825", + :street "Kafir Road", + :city "Burlington", + :state-abbrev "KS", + :zip "66839"} + {:lat 38.7039277, + :lon -93.25010549999999, + :house-number "901", + :street "South Limit Avenue", + :city "Sedalia", + :state-abbrev "MO", + :zip "65301"} + {:lat 31.6810774, + :lon -92.2265779, + :house-number "118", + :street "Belah Cemetery Road", + :city "Trout", + :state-abbrev "LA", + :zip "71371"} + {:lat 39.169834, + :lon -83.60190999999999, + :house-number "5794", + :street "South R 247", + :city "Hillsboro", + :state-abbrev "OH", + :zip "45133"} + {:lat 61.99231690000001, + :lon -146.7686644, + :house-number "2446", + :street "Glenn Highway", + :city "Glennallen", + :state-abbrev "AK", + :zip "99588"} + {:lat 38.3477305, + :lon -95.6188909, + :house-number "2200-2298", + :street "Shetland Road", + :city "Waverly", + :state-abbrev "KS", + :zip "66871"} + {:lat 36.9082415, + :lon -76.90512439999999, + :house-number "8332", + :street "Bell Avenue", + :city "Ivor", + :state-abbrev "VA", + :zip "23866"} + {:lat 41.1928259, + :lon -74.66955899999999, + :house-number "111", + :street "Haggerty Road", + :city "Wantage", + :state-abbrev "NJ", + :zip "07461"} + {:lat 32.7562537, + :lon -83.8201728, + :house-number "5700", + :street "Stokes Road", + :city "Lizella", + :state-abbrev "GA", + :zip "31052"} + {:lat 29.3102213, + :lon -95.74301249999999, + :house-number "19028-19030", + :street "Old Guy Road", + :city "Damon", + :state-abbrev "TX", + :zip "77430"} + {:lat 39.7652264, + :lon -93.3202542, + :house-number "28757", + :street "Bear Drive", + :city "Meadville", + :state-abbrev "MO", + :zip "64659"} + {:lat 39.2299951, + :lon -87.61143679999999, + :house-number "21000-22464", + :street "County Road 550 North", + :city "West Union", + :state-abbrev "IL", + :zip "62477"} + {:lat 39.2699827, + :lon -77.43403359999999, + :house-number "1619-1631", + :street "Park Mills Road", + :city "Adamstown", + :state-abbrev "MD", + :zip "21710"} + {:lat 33.9215182, + :lon -80.05810799999999, + :house-number "8080", + :street "Forge Road", + :city "Turbeville", + :state-abbrev "SC", + :zip "29162"} + {:lat 34.6621039, + :lon -102.139477, + :house-number "650-698", + :street "County Road 523", + :city "Happy", + :state-abbrev "TX", + :zip "79042"} + {:lat 31.809885, + :lon -86.4015411, + :house-number "1183", + :street "Center Ridge Road", + :city "Luverne", + :state-abbrev "AL", + :zip "36049"} + {:lat 41.3964881, + :lon -84.4180001, + :house-number "3001-3941", + :street "Wieland Road", + :city "Defiance", + :state-abbrev "OH", + :zip "43512"} + {:lat 29.7936609, + :lon -93.18750399999999, + :house-number "632", + :street "Wakefield Road", + :city "Cameron", + :state-abbrev "LA", + :zip "70631"} + {:lat 41.272848, + :lon -104.617244, + :house-number "2080", + :street "Road 136", + :city "Cheyenne", + :state-abbrev "WY", + :zip "82009"} + {:lat 32.645886, + :lon -87.5942093, + :house-number "12771", + :street "Al Highway 25", + :city "Greensboro", + :state-abbrev "AL", + :zip "36744"} + {:lat 31.804467, + :lon -98.63238, + :house-number "2000", + :street "Ranch Road 573", + :city "Comanche", + :state-abbrev "TX", + :zip "76442"} + {:lat 41.14615440000001, + :lon -92.5845738, + :house-number "22087", + :street "Columbia Road", + :city "Eddyville", + :state-abbrev "IA", + :zip "52553"} + {:lat 39.3056362, + :lon -83.0713213, + :house-number "1329-2599", + :street "Alum Cliff Road", + :city "Chillicothe", + :state-abbrev "OH", + :zip "45601"} + {:lat 37.271048, + :lon -92.973175, + :house-number "1482", + :street "Ridge Road", + :city "Marshfield", + :state-abbrev "MO", + :zip "65706"} + {:lat 42.082152, + :lon -78.66308099999999, + :house-number "648", + :street "Parkside Drive", + :city "Limestone", + :state-abbrev "NY", + :zip "14753"} + {:lat 44.5315223, + :lon -106.2157111, + :house-number "2799", + :street "Tipperary Road", + :city "Arvada", + :state-abbrev "WY", + :zip "82831"} + {:lat 46.4295937, + :lon -101.6249409, + :house-number "5746-5768", + :street "68th Street Southwest", + :city "Carson", + :state-abbrev "ND", + :zip "58529"} + {:lat 36.5835077, + :lon -92.247591, + :house-number "220", + :street "Bluetick Ridge Lane", + :city "Tecumseh", + :state-abbrev "MO", + :zip "65760"} + {:lat 38.1613425, + :lon -91.60582130000002, + :house-number "1183", + :street "State Highway U", + :city "Bland", + :state-abbrev "MO", + :zip "65014"} + {:lat 37.7563672, + :lon -95.098474, + :house-number "151", + :street "4800th Street", + :city "Savonburg", + :state-abbrev "KS", + :zip "66772"} + {:lat 36.8673735, + :lon -90.289456, + :house-number "859", + :street "County Road 572", + :city "Poplar Bluff", + :state-abbrev "MO", + :zip "63901"} + {:lat 40.26783959999999, + :lon -80.86239540000001, + :house-number "4225-4433", + :street "Smithfield-Adena Road", + :city "Adena", + :state-abbrev "OH", + :zip "43901"} + {:lat 36.4587022, + :lon -93.9533701, + :house-number "19057", + :street "U.S. Highway 62", + :city "Garfield", + :state-abbrev "AR", + :zip "72732"} + {:lat 41.7480865, + :lon -89.4574213, + :house-number "1101-1185", + :street "Illinois 26", + :city "Dixon", + :state-abbrev "IL", + :zip "61021"} + {:lat 41.8207449, + :lon -83.91851009999999, + :house-number "6949", + :street "Crockett Highway", + :city "Blissfield", + :state-abbrev "MI", + :zip "49228"} + {:lat 36.5258829, + :lon -105.4877284, + :house-number "281", + :street "Cuchilla Road", + :city "Taos", + :state-abbrev "NM", + :zip "87571"} + {:lat 44.9585579, + :lon -73.69073999999999, + :house-number "507", + :street "Lamberton Road", + :city "Mooers Forks", + :state-abbrev "NY", + :zip "12959"} + {:lat 35.4931658, + :lon -95.1962375, + :house-number "936", + :street "U.S. Highway 64", + :city "Webbers Falls", + :state-abbrev "OK", + :zip "74470"} + {:lat 62.599373, + :lon -150.2273, + :house-number "4142", + :street "North Parks Highway", + :city "Talkeetna", + :state-abbrev "AK", + :zip "99676"} + {:lat 25.775827, + :lon -80.2161845, + :house-number "219", + :street "Northwest 13th Avenue", + :city "Miami", + :state-abbrev "FL", + :zip "33125"} + {:lat 63.55269000000001, + :lon -145.866618, + :house-number "1221", + :street "Richardson Highway", + :city "Delta Junction", + :state-abbrev "AK", + :zip "99737"} + {:lat 32.5791956, + :lon -102.6688225, + :house-number "1068", + :street "County Road 305", + :city "Seminole", + :state-abbrev "TX", + :zip "79360"} + {:lat 33.943168, + :lon -78.9874791, + :house-number "6000-6244", + :street "Hucks Road", + :city "Conway", + :state-abbrev "SC", + :zip "29526"} + {:lat 41.9291494, + :lon -122.4036239, + :house-number "11100", + :street "Heather Lane", + :city "Hornbrook", + :state-abbrev "CA", + :zip "96044"} + {:lat 31.3419491, + :lon -88.0256923, + :house-number "435", + :street "Toinette Road", + :city "Wagarville", + :state-abbrev "AL", + :zip "36585"} + {:lat 44.954284, + :lon -116.875041, + :house-number "54253", + :street "Oregon 86", + :city "Halfway", + :state-abbrev "OR", + :zip "97834"} + {:lat 41.583846, + :lon -80.54728399999999, + :house-number "6123", + :street "Pymatuning Lake Road", + :city "Andover", + :state-abbrev "OH", + :zip "44003"} + {:lat 45.9565305, + :lon -114.1214446, + :house-number "100-198", + :street "Lazy Pine Road", + :city "Darby", + :state-abbrev "MT", + :zip "59829"} + {:lat 35.150464, + :lon -89.92967600000001, + :house-number "3887", + :street "Faxon Avenue", + :city "Memphis", + :state-abbrev "TN", + :zip "38122"} + {:lat 41.3616879, + :lon -96.9324879, + :house-number "2351", + :street "43 Road", + :city "Linwood", + :state-abbrev "NE", + :zip "68036"} + {:lat 48.8364755, + :lon -96.1627155, + :house-number "30000-30236", + :street "210th Avenue", + :city "Greenbush", + :state-abbrev "MN", + :zip "56726"} + {:lat 44.3398472, + :lon -97.4646326, + :house-number "43701-43745", + :street "210th Street", + :city "De Smet", + :state-abbrev "SD", + :zip "57231"} + {:lat 47.97314060000001, + :lon -110.1031342, + :house-number "3091", + :street "White Rocks Road", + :city "Big Sandy", + :state-abbrev "MT", + :zip "59520"} + {:lat 32.7834189, + :lon -116.8897473, + :house-number "804-880", + :street "Willow Glen Drive", + :city "El Cajon", + :state-abbrev "CA", + :zip "92019"} + {:lat 29.329903, + :lon -98.287882, + :house-number "9417", + :street "Stuart Road", + :city "San Antonio", + :state-abbrev "TX", + :zip "78263"} + {:lat 41.0161228, + :lon -94.4307601, + :house-number "1990", + :street "Clover Avenue", + :city "Creston", + :state-abbrev "IA", + :zip "50801"} + {:lat 43.3737186, + :lon -98.3097361, + :house-number "27600-27698", + :street "394th Avenue", + :city "Armour", + :state-abbrev "SD", + :zip "57313"} + {:lat 33.9705571, + :lon -88.6153678, + :house-number "30001-30019", + :street "Quail Cove", + :city "Okolona", + :state-abbrev "MS", + :zip "38860"} + {:lat 32.9355839, + :lon -92.8595831, + :house-number "290", + :street "Ella Ford Road", + :city "Haynesville", + :state-abbrev "LA", + :zip "71038"} + {:lat 37.9335619, + :lon -78.3050973, + :house-number "399", + :street "Two Rivers Drive", + :city "Troy", + :state-abbrev "VA", + :zip "22974"} + {:lat 47.4686878, + :lon -105.4528496, + :house-number "467-605", + :street "Montana 200", + :city "Circle", + :state-abbrev "MT", + :zip "59215"} + {:lat 44.1133821, + :lon -93.1815084, + :house-number "2697", + :street "Kenyon Road", + :city "Owatonna", + :state-abbrev "MN", + :zip "55060"} + {:lat 44.287157, + :lon -76.142338, + :house-number "42662", + :street "Thurso Bay", + :city "Clayton", + :state-abbrev "NY", + :zip "13624"} + {:lat 41.0662326, + :lon -104.1784873, + :house-number "400-496", + :street "County Road 158", + :city "Carpenter", + :state-abbrev "WY", + :zip "82054"} + {:lat 40.3243005, + :lon -93.0029117, + :house-number "14470", + :street "East Gate Road", + :city "Green City", + :state-abbrev "MO", + :zip "63545"} + {:lat 42.7346338, + :lon -75.5133221, + :house-number "198-422", + :street "Castle Hill Road", + :city "Sherburne", + :state-abbrev "NY", + :zip "13460"} + {:lat 34.337856, + :lon -82.322595, + :house-number "219", + :street "Timms Road", + :city "Donalds", + :state-abbrev "SC", + :zip "29638"} + {:lat 43.6759365, + :lon -88.2080613, + :house-number "W1722", + :street "County Road B", + :city "Eden", + :state-abbrev "WI", + :zip "53019"} + {:lat 38.8787613, + :lon -91.8143237, + :house-number "8290", + :street "County Road 134", + :city "Fulton", + :state-abbrev "MO", + :zip "65251"} + {:lat 32.3607249, + :lon -97.95839389999999, + :house-number "2400", + :street "Rock Church Highway", + :city "Tolar", + :state-abbrev "TX", + :zip "76476"} + {:lat 38.51624109999999, + :lon -94.0457396, + :house-number "1585-1615", + :street "Northwest 1050 Road", + :city "Urich", + :state-abbrev "MO", + :zip "64788"} + {:lat 41.97421019999999, + :lon -100.5062283, + :house-number "83820", + :street "Gaston Road", + :city "Thedford", + :state-abbrev "NE", + :zip "69166"} + {:lat 41.6188953, + :lon -112.3642282, + :house-number "13469-14379", + :street "Utah 102", + :city "Tremonton", + :state-abbrev "UT", + :zip "84337"} + {:lat 46.5532026, + :lon -106.0391607, + :house-number "1370", + :street "Montana 59", + :city "Miles City", + :state-abbrev "MT", + :zip "59301"} + {:lat 43.641095, + :lon -111.048747, + :house-number "2699", + :street "East 5000 South", + :city "Victor", + :state-abbrev "ID", + :zip "83455"} + {:lat 29.4590646, + :lon -99.1169827, + :house-number "3883", + :street "Texas 173", + :city "Hondo", + :state-abbrev "TX", + :zip "78861"} + {:lat 33.3409668, + :lon -102.4427318, + :house-number "932", + :street "County Road 230", + :city "Meadow", + :state-abbrev "TX", + :zip "79345"} + {:lat 30.0251478, + :lon -91.00133609999999, + :house-number "7035", + :street "Louisiana 70", + :city "Belle Rose", + :state-abbrev "LA", + :zip "70341"} + {:lat 34.9966929, + :lon -81.89568729999999, + :house-number "295", + :street "Mule Farm Road", + :city "Spartanburg", + :state-abbrev "SC", + :zip "29303"} + {:lat 37.0135164, + :lon -106.9052055, + :house-number "7390F", + :street "County Road 359", + :city "Pagosa Springs", + :state-abbrev "CO", + :zip "81147"} + {:lat 34.3648639, + :lon -80.7861429, + :house-number "814", + :street "Rolling Hills Road", + :city "Ridgeway", + :state-abbrev "SC", + :zip "29130"} + {:lat 35.5054713, + :lon -80.67574979999999, + :house-number "6551-6651", + :street "Miller Road", + :city "Kannapolis", + :state-abbrev "NC", + :zip "28081"} + {:lat 32.3409754, + :lon -91.7168365, + :house-number "275", + :street "Highway 584", + :city "Rayville", + :state-abbrev "LA", + :zip "71269"} + {:lat 33.0372336, + :lon -115.5756934, + :house-number "401-499", + :street "Boarts Road", + :city "Brawley", + :state-abbrev "CA", + :zip "92227"} + {:lat 30.8796596, + :lon -84.6495801, + :house-number "944", + :street "John Sam Road", + :city "Bainbridge", + :state-abbrev "GA", + :zip "39817"} + {:lat 35.7023803, + :lon -119.5085531, + :house-number "2479", + :street "Gun Club Road", + :city "Wasco", + :state-abbrev "CA", + :zip "93280"} + {:lat 32.1138939, + :lon -94.367318, + :house-number "382", + :street "County Road 302", + :city "Carthage", + :state-abbrev "TX", + :zip "75633"} + {:lat 40.7513715, + :lon -85.4486616, + :house-number "5112-5510", + :street "South Warren Road", + :city "Warren", + :state-abbrev "IN", + :zip "46792"} + {:lat 43.5870284, + :lon -122.0120442, + :house-number "26150", + :street "Oregon 58", + :city "Crescent", + :state-abbrev "OR", + :zip "97733"} + {:lat 30.1014042, + :lon -96.0686004, + :house-number "145", + :street "Calvit Street", + :city "Hempstead", + :state-abbrev "TX", + :zip "77445"} + {:lat 39.9977088, + :lon -91.7154652, + :house-number "27525", + :street "State Highway N", + :city "Ewing", + :state-abbrev "MO", + :zip "63440"} + {:lat 43.9067968, + :lon -92.8634064, + :house-number "18756-18998", + :street "720th Street", + :city "Hayfield", + :state-abbrev "MN", + :zip "55940"} + {:lat 38.319152, + :lon -75.838825, + :house-number "22358", + :street "Deep Branch Road", + :city "Quantico", + :state-abbrev "MD", + :zip "21856"} + {:lat 39.7386659, + :lon -75.0294809, + :house-number "346", + :street "Johnson Road", + :city "Sicklerville", + :state-abbrev "NJ", + :zip "08081"} + {:lat 41.9543142, + :lon -76.7888085, + :house-number "260", + :street "Monkey Run Road", + :city "Gillett", + :state-abbrev "PA", + :zip "16925"} + {:lat 41.1009, + :lon -86.6770219, + :house-number "3828", + :street "West 300 North", + :city "Winamac", + :state-abbrev "IN", + :zip "46996"} + {:lat 44.011282, + :lon -89.2391004, + :house-number "975", + :street "Cypress Road", + :city "Neshkoro", + :state-abbrev "WI", + :zip "54960"} + {:lat 47.0958654, + :lon -99.3796684, + :house-number "5252-5298", + :street "22nd Street Southeast", + :city "Woodworth", + :state-abbrev "ND", + :zip "58496"} + {:lat 32.4864678, + :lon -100.6358039, + :house-number "579-1053", + :street "County Road 169", + :city "Roscoe", + :state-abbrev "TX", + :zip "79545"} + {:lat 46.0631211, + :lon -93.41270970000001, + :house-number "3213", + :street "Falcon Street", + :city "Isle", + :state-abbrev "MN", + :zip "56342"} + {:lat 44.6275708, + :lon -73.5379217, + :house-number "35", + :street "Stone Bridge Way", + :city "Plattsburgh", + :state-abbrev "NY", + :zip "12901"} + {:lat 40.4012142, + :lon -80.763559, + :house-number "1010-1311", + :street "Township Road 223", + :city "Richmond", + :state-abbrev "OH", + :zip "43944"} + {:lat 37.701113, + :lon -77.42609999999999, + :house-number "9479", + :street "Sliding Hill Road", + :city "Ashland", + :state-abbrev "VA", + :zip "23005"} + {:lat 27.0896397, + :lon -80.5149292, + :house-number "9601", + :street "T M Road", + :city "Indiantown", + :state-abbrev "FL", + :zip "34956"} + {:lat 30.2316212, + :lon -96.2623417, + :house-number "10402", + :street "FM 2193", + :city "Brenham", + :state-abbrev "TX", + :zip "77833"} + {:lat 39.9809, + :lon -91.35653490000001, + :house-number "4134", + :street "North 36th Street", + :city "Quincy", + :state-abbrev "IL", + :zip "62305"} + {:lat 27.780107, + :lon -97.91449, + :house-number "1903", + :street "2nd Street", + :city "Agua Dulce", + :state-abbrev "TX", + :zip "78330"} + {:lat 36.0824814, + :lon -94.78128149999999, + :house-number "463210", + :street "East 594 Road", + :city "Kansas", + :state-abbrev "OK", + :zip "74347"} + {:lat 48.889109, + :lon -106.46857, + :house-number "247", + :street "Roanwood Road", + :city "Opheim", + :state-abbrev "MT", + :zip "59250"} + {:lat 34.2245448, + :lon -79.9830906, + :house-number "1720", + :street "Country Manor Road", + :city "Timmonsville", + :state-abbrev "SC", + :zip "29161"} + {:lat 44.9538531, + :lon -89.07141879999999, + :house-number "16480", + :street "Hemlock Road", + :city "Birnamwood", + :state-abbrev "WI", + :zip "54414"} + {:lat 33.0803727, + :lon -108.4894079, + :house-number "535", + :street "Turkey Creek Road", + :city "Silver City", + :state-abbrev "NM", + :zip "88061"} + {:lat 45.780511, + :lon -110.115136, + :house-number "398", + :street "North Yellowstone Trail Road", + :city "Big Timber", + :state-abbrev "MT", + :zip "59011"} + {:lat 31.565726, + :lon -97.78056699999999, + :house-number "820", + :street "Cr 239", + :city "Gatesville", + :state-abbrev "TX", + :zip "76528"} + {:lat 37.5001623, + :lon -77.654427, + :house-number "112-208", + :street "Coalfield Road", + :city "Midlothian", + :state-abbrev "VA", + :zip "23114"} + {:lat 33.4192279, + :lon -105.416806, + :house-number "27587", + :street "U.S. 70", + :city "Glencoe", + :state-abbrev "NM", + :zip "88324"} + {:lat 57.0565648, + :lon -135.3704941, + :house-number "1190", + :street "Seward Avenue", + :city "Sitka", + :state-abbrev "AK", + :zip "99835"} + {:lat 37.6143574, + :lon -114.4812157, + :house-number "600", + :street "Clover Creek Road", + :city "Caliente", + :state-abbrev "NV", + :zip "89008"} + {:lat 42.669734, + :lon -82.57851099999999, + :house-number "7882", + :street "Morrow Road", + :city "Marine City", + :state-abbrev "MI", + :zip "48039"} + {:lat 43.1411556, + :lon -94.1562886, + :house-number "1514-1568", + :street "260th Street", + :city "Algona", + :state-abbrev "IA", + :zip "50511"} + {:lat 35.4250248, + :lon -81.5005142, + :house-number "4632", + :street "Fallston Road", + :city "Shelby", + :state-abbrev "NC", + :zip "28150"} + {:lat 31.2472719, + :lon -97.5642558, + :house-number "5690", + :street "Farm to Market 184", + :city "Gatesville", + :state-abbrev "TX", + :zip "76528"} + {:lat 41.54929, + :lon -73.2168571, + :house-number "98-108", + :street "Judson Avenue", + :city "Woodbury", + :state-abbrev "CT", + :zip "06798"} + {:lat 35.225912, + :lon -84.789648, + :house-number "637", + :street "Dry Valley Road Northeast", + :city "Cleveland", + :state-abbrev "TN", + :zip "37312"} + {:lat 48.91047709999999, + :lon -104.1266629, + :house-number "201-363", + :street "McElroy Road", + :city "Westby", + :state-abbrev "MT", + :zip "59275"} + {:lat 38.5357658, + :lon -78.74227309999999, + :house-number "1358", + :street "Warner Lane", + :city "Harrisonburg", + :state-abbrev "VA", + :zip "22802"} + {:lat 31.7556515, + :lon -84.3729352, + :house-number "1605-1611", + :street "Sellers Road", + :city "Dawson", + :state-abbrev "GA", + :zip "39842"} + {:lat 34.6606528, + :lon -78.6013504, + :house-number "852", + :street "Smith and Hair Road", + :city "Elizabethtown", + :state-abbrev "NC", + :zip "28337"} + {:lat 35.4922289, + :lon -86.82591479999999, + :house-number "1797-1927", + :street "New Columbia Highway", + :city "Lewisburg", + :state-abbrev "TN", + :zip "37091"} + {:lat 35.819588, + :lon -80.92777199999999, + :house-number "2097", + :street "Old Wilkesboro Road", + :city "Statesville", + :state-abbrev "NC", + :zip "28625"} + {:lat 42.826871, + :lon -76.36948699999999, + :house-number "5625", + :street "Mack Road", + :city "Skaneateles", + :state-abbrev "NY", + :zip "13152"} + {:lat 34.36168, + :lon -88.8239395, + :house-number "100-198", + :street "Road 1", + :city "Tupelo", + :state-abbrev "MS", + :zip "38804"} + {:lat 37.685311, + :lon -106.6649902, + :house-number "1077-1487", + :street "Colorado 149", + :city "South Fork", + :state-abbrev "CO", + :zip "81154"} + {:lat 32.54768, + :lon -82.0425238, + :house-number "41-51", + :street "Georgia 121", + :city "Twin City", + :state-abbrev "GA", + :zip "30471"} + {:lat 38.7840047, + :lon -97.15234319999999, + :house-number "1283-1293", + :street "1300 Avenue", + :city "Abilene", + :state-abbrev "KS", + :zip "67410"} + {:lat 37.285231, + :lon -93.7755416, + :house-number "363", + :street "State Highway WW", + :city "Miller", + :state-abbrev "MO", + :zip "65707"} + {:lat 34.0449385, + :lon -82.6381945, + :house-number "1310", + :street "Bobby Brown State Park Road", + :city "Elberton", + :state-abbrev "GA", + :zip "30635"} + {:lat 41.29878480000001, + :lon -123.220365, + :house-number "27529-27827", + :street "Sawyers Bar Road", + :city "Etna", + :state-abbrev "CA", + :zip "96027"} + {:lat 41.44222329999999, + :lon -75.8678892, + :house-number "1515", + :street "Keelersburg Road", + :city "Tunkhannock", + :state-abbrev "PA", + :zip "18657"} + {:lat 36.60397409999999, + :lon -80.0159182, + :house-number "945", + :street "Old Well Road", + :city "Spencer", + :state-abbrev "VA", + :zip "24165"} + {:lat 35.5220545, + :lon -98.5358859, + :house-number "3006", + :street "County Street 2500", + :city "Hydro", + :state-abbrev "OK", + :zip "73048"} + {:lat 47.1815654, + :lon -114.93413, + :house-number "16-62", + :street "Thompson Creek Road", + :city "Superior", + :state-abbrev "MT", + :zip "59872"} + {:lat 44.2569619, + :lon -117.0542315, + :house-number "729", + :street "Jonathan Road", + :city "Weiser", + :state-abbrev "ID", + :zip "83672"} + {:lat 29.8632357, + :lon -96.86068159999999, + :house-number "1900-2012", + :street "George Road", + :city "La Grange", + :state-abbrev "TX", + :zip "78945"} + {:lat 43.7119068, + :lon -102.1301612, + :house-number "19651", + :street "South Dakota 44", + :city "Interior", + :state-abbrev "SD", + :zip "57750"} + {:lat 39.277648, + :lon -106.962145, + :house-number "4305", + :street "Snowmass Creek Road", + :city "Snowmass", + :state-abbrev "CO", + :zip "81654"} + {:lat 34.7461054, + :lon -102.4947431, + :house-number "3240", + :street "U.S. 60", + :city "Hereford", + :state-abbrev "TX", + :zip "79045"} + {:lat 36.290432, + :lon -83.817886, + :house-number "1878", + :street "Hickory Valley Road", + :city "Maynardville", + :state-abbrev "TN", + :zip "37807"} + {:lat 44.9100772, + :lon -94.6225424, + :house-number "11258", + :street "570th Avenue", + :city "Cosmos", + :state-abbrev "MN", + :zip "56228"} + {:lat 35.8832669, + :lon -90.98008, + :house-number "8399", + :street "CR 194", + :city "Cash", + :state-abbrev "AR", + :zip "72421"} + {:lat 42.5763337, + :lon -101.1235419, + :house-number "18-30", + :street "302nd Avenue", + :city "Valentine", + :state-abbrev "NE", + :zip "69201"} + {:lat 32.3376667, + :lon -80.528605, + :house-number "1", + :street "Whitams Island", + :city "Saint Helena Island", + :state-abbrev "SC", + :zip "29920"} + {:lat 38.2782884, + :lon -105.6437323, + :house-number "710", + :street "Lake Creek Lane", + :city "Cotopaxi", + :state-abbrev "CO", + :zip "81223"} + {:lat 47.605072, + :lon -92.3040578, + :house-number "6500-6698", + :street "Giants Ridge Road", + :city "Embarrass", + :state-abbrev "MN", + :zip "55732"} + {:lat 32.6444029, + :lon -95.56368700000002, + :house-number "25678", + :street "County Road 457", + :city "Mineola", + :state-abbrev "TX", + :zip "75773"} + {:lat 43.244654, + :lon -83.739317, + :house-number "10787", + :street "Rose Lane", + :city "Birch Run", + :state-abbrev "MI", + :zip "48415"} + {:lat 38.3867667, + :lon -78.0820758, + :house-number "23123-23159", + :street "Roland Road", + :city "Rapidan", + :state-abbrev "VA", + :zip "22733"} + {:lat 39.062332, + :lon -123.243362, + :house-number "6091", + :street "Boonville Road", + :city "Ukiah", + :state-abbrev "CA", + :zip "95482"} + {:lat 42.7312246, + :lon -76.7093886, + :house-number "18", + :street "Sunset Beach Road", + :city "Aurora", + :state-abbrev "NY", + :zip "13026"} + {:lat 42.1696132, + :lon -118.5709799, + :house-number "44289", + :street "Whitehorse Ranch Lane", + :city "Fields", + :state-abbrev "OR", + :zip "97710"} + {:lat 34.61839, + :lon -92.8443247, + :house-number "24141", + :street "Old Hot Springs Highway", + :city "Hot Springs Village", + :state-abbrev "AR", + :zip "71909"} + {:lat 41.2517668, + :lon -104.2248914, + :house-number "6255", + :street "County Road 213", + :city "Pine Bluffs", + :state-abbrev "WY", + :zip "82082"} + {:lat 33.485464, + :lon -82.0220871, + :house-number "501", + :street "Delano Street", + :city "Augusta", + :state-abbrev "GA", + :zip "30904"} + {:lat 39.9526071, + :lon -90.9820034, + :house-number "2701-2799", + :street "North 1353rd Lane", + :city "Clayton", + :state-abbrev "IL", + :zip "62324"} + {:lat 43.0633923, + :lon -105.565439, + :house-number "28", + :street "Highland Loop Road", + :city "Douglas", + :state-abbrev "WY", + :zip "82633"} + {:lat 47.1533778, + :lon -99.0071076, + :house-number "7001-7089", + :street "18th Street Southeast", + :city "Pingree", + :state-abbrev "ND", + :zip "58476"} + {:lat 30.557063, + :lon -96.30854199999999, + :house-number "13546", + :street "Alacia Court", + :city "College Station", + :state-abbrev "TX", + :zip "77845"} + {:lat 40.0936749, + :lon -86.92878619999999, + :house-number "1435", + :street "West 400 North", + :city "Crawfordsville", + :state-abbrev "IN", + :zip "47933"} + {:lat 40.429191, + :lon -80.038403, + :house-number "666", + :street "Hestor Drive", + :city "Pittsburgh", + :state-abbrev "PA", + :zip "15220"} + {:lat 48.306407, + :lon -120.0517531, + :house-number "1", + :street "Benson Creek Drive", + :city "Twisp", + :state-abbrev "WA", + :zip "98856"} + {:lat 33.6578638, + :lon -96.4607095, + :house-number "435", + :street "Craft Road", + :city "Bells", + :state-abbrev "TX", + :zip "75414"} + {:lat 42.2964941, + :lon -83.148913, + :house-number "12821", + :street "Dix", + :city "Dearborn", + :state-abbrev "MI", + :zip "48120"} + {:lat 35.24744099999999, + :lon -83.625271, + :house-number "254", + :street "Otter Creek Road", + :city "Topton", + :state-abbrev "NC", + :zip "28781"} + {:lat 45.5650285, + :lon -121.0089962, + :house-number "4757-5201", + :street "Emerson Loop Road", + :city "The Dalles", + :state-abbrev "OR", + :zip "97058"} + {:lat 42.1992991, + :lon -97.03739809999999, + :house-number "85346-85398", + :street "575 Avenue", + :city "Wayne", + :state-abbrev "NE", + :zip "68787"} + {:lat 46.15602, + :lon -89.907809, + :house-number "14077", + :street "East Circle Lily Road", + :city "Manitowish Waters", + :state-abbrev "WI", + :zip "54545"} + {:lat 44.98760499999999, + :lon -93.990574, + :house-number "11500", + :street "Ferman Avenue Southwest", + :city "Waverly", + :state-abbrev "MN", + :zip "55390"} + {:lat 38.563198, + :lon -94.270044, + :house-number "28909", + :street "South Kircher Road", + :city "Harrisonville", + :state-abbrev "MO", + :zip "64701"} + {:lat 32.5621624, + :lon -99.7002526, + :house-number "822", + :street "Comanche Trail", + :city "Abilene", + :state-abbrev "TX", + :zip "79601"} + {:lat 41.2800528, + :lon -73.8347881, + :house-number "1510-1580", + :street "Whitehill Road", + :city "Yorktown Heights", + :state-abbrev "NY", + :zip "10598"} + {:lat 40.593251, + :lon -80.84441, + :house-number "628", + :street "Ohio 164", + :city "Salineville", + :state-abbrev "OH", + :zip "43945"} + {:lat 33.0242556, + :lon -81.9565897, + :house-number "501-899", + :street "Idlewood Road", + :city "Waynesboro", + :state-abbrev "GA", + :zip "30830"} + {:lat 34.3102816, + :lon -80.251449, + :house-number "681", + :street "Newsome Road", + :city "Bishopville", + :state-abbrev "SC", + :zip "29010"} + {:lat 39.332676, + :lon -78.240403, + :house-number "2616", + :street "Siler Road", + :city "Winchester", + :state-abbrev "VA", + :zip "22603"} + {:lat 45.96328279999999, + :lon -118.3866197, + :house-number "84325", + :street "Oregon 11", + :city "Milton-Freewater", + :state-abbrev "OR", + :zip "97862"} + {:lat 45.0264787, + :lon -69.599564, + :house-number "24", + :street "Cross Road", + :city "Wellington", + :state-abbrev "ME", + :zip "04942"} + {:lat 41.3111215, + :lon -85.8015207, + :house-number "2071-2199", + :street "East Riverside Drive", + :city "Warsaw", + :state-abbrev "IN", + :zip "46582"} + {:lat 37.4179811, + :lon -85.7573845, + :house-number "12280", + :street "North Jackson Highway", + :city "Magnolia", + :state-abbrev "KY", + :zip "42757"} + {:lat 42.484824, + :lon -72.8480639, + :house-number "1876", + :street "Spruce Corner Road", + :city "Ashfield", + :state-abbrev "MA", + :zip "01330"} + {:lat 35.2600134, + :lon -106.7031468, + :house-number "125-127", + :street "2nd Street Southeast", + :city "Rio Rancho", + :state-abbrev "NM", + :zip "87124"} + {:lat 37.155673, + :lon -122.024959, + :house-number "1000", + :street "Wilderfield Road", + :city "Los Gatos", + :state-abbrev "CA", + :zip "95033"} + {:lat 47.9367019, + :lon -91.4237666, + :house-number "3191", + :street "Fernberg Road", + :city "Ely", + :state-abbrev "MN", + :zip "55731"} + {:lat 30.114453, + :lon -97.9510375, + :house-number "8700", + :street "Farm to Market 967", + :city "Buda", + :state-abbrev "TX", + :zip "78610"} + {:lat 44.1517741, + :lon -98.0955537, + :house-number "22310", + :street "406th Avenue", + :city "Forestburg", + :state-abbrev "SD", + :zip "57314"} + {:lat 28.8448101, + :lon -99.0027399, + :house-number "5056-5638", + :street "Keystone Road", + :city "Pearsall", + :state-abbrev "TX", + :zip "78061"} + {:lat 32.918188, + :lon -84.89107200000001, + :house-number "493", + :street "Old Chipley Road", + :city "Pine Mountain", + :state-abbrev "GA", + :zip "31822"} + {:lat 35.872716, + :lon -78.63470640000001, + :house-number "409", + :street "Westbrook Drive", + :city "Raleigh", + :state-abbrev "NC", + :zip "27615"} + {:lat 35.9351531, + :lon -79.0572364, + :house-number "875-893", + :street "Estes Drive Extension", + :city "Chapel Hill", + :state-abbrev "NC", + :zip "27516"} + {:lat 34.8583854, + :lon -91.6012164, + :house-number "2993", + :street "Arkansas 249", + :city "Hazen", + :state-abbrev "AR", + :zip "72064"} + {:lat 41.1521072, + :lon -73.7005391, + :house-number "99", + :street "Pioneer Trail", + :city "Armonk", + :state-abbrev "NY", + :zip "10504"} + {:lat 66.5295218, + :lon -152.6634842, + :house-number "21", + :street "Tanana-Allakaket Winter Trail", + :city "Allakaket", + :state-abbrev "AK", + :zip "99720"} + {:lat 40.1315377, + :lon -89.09725139999999, + :house-number "2499", + :street "Bungtown Road", + :city "Kenney", + :state-abbrev "IL", + :zip "61749"} + {:lat 37.3424648, + :lon -86.01359289999999, + :house-number "770", + :street "Cave Hill Road", + :city "Munfordville", + :state-abbrev "KY", + :zip "42765"} + {:lat 39.2092304, + :lon -82.4539298, + :house-number "35887", + :street "State Route 324", + :city "Hamden", + :state-abbrev "OH", + :zip "45634"} + {:lat 36.456136, + :lon -76.87827, + :house-number "70", + :street "Tinkham Road", + :city "Eure", + :state-abbrev "NC", + :zip "27935"} + {:lat 48.3779906, + :lon -109.2142716, + :house-number "16475", + :street "Cleveland Road", + :city "Chinook", + :state-abbrev "MT", + :zip "59523"} + {:lat 37.6707712, + :lon -91.1372466, + :house-number "1465", + :street "Ponderosa", + :city "Bixby", + :state-abbrev "MO", + :zip "65439"} + {:lat 40.8572074, + :lon -98.3295741, + :house-number "1099", + :street "East Wildwood Drive", + :city "Grand Island", + :state-abbrev "NE", + :zip "68801"} + {:lat 41.9850861, + :lon -96.4640027, + :house-number "2160", + :street "U.S. 77", + :city "Lyons", + :state-abbrev "NE", + :zip "68038"} + {:lat 44.5762138, + :lon -98.681271, + :house-number "37500-37598", + :street "194th Street", + :city "Wessington", + :state-abbrev "SD", + :zip "57381"} + {:lat 32.2830071, + :lon -101.4354302, + :house-number "104", + :street "Broken Bow Road", + :city "Big Spring", + :state-abbrev "TX", + :zip "79720"} + {:lat 39.9008824, + :lon -102.6886584, + :house-number "22000-22998", + :street "County Road G", + :city "Yuma", + :state-abbrev "CO", + :zip "80759"} + {:lat 38.6578676, + :lon -75.1426649, + :house-number "23402", + :street "Camp Arrowhead Road", + :city "Lewes", + :state-abbrev "DE", + :zip "19958"} + {:lat 38.32789959999999, + :lon -113.0122461, + :house-number "186", + :street "4500 South", + :city "Milford", + :state-abbrev "UT", + :zip "84751"} + {:lat 47.726737, + :lon -117.19418, + :house-number "15708", + :street "East Lincoln Road", + :city "Spokane", + :state-abbrev "WA", + :zip "99217"} + {:lat 38.821582, + :lon -84.659021, + :house-number "14933", + :street "Walton-Verona Road", + :city "Verona", + :state-abbrev "KY", + :zip "41092"}) diff --git a/sample_dataset/metabase/sample_dataset/generate.clj b/sample_dataset/metabase/sample_dataset/generate.clj index 9ddb952d31449b463f1bc5a7f32e31a69bba3e44..ad8b6a8253107cc72537294007ba8e66ea500aa6 100644 --- a/sample_dataset/metabase/sample_dataset/generate.clj +++ b/sample_dataset/metabase/sample_dataset/generate.clj @@ -1,13 +1,14 @@ (ns metabase.sample-dataset.generate "Logic for generating the sample dataset. Run this with `lein generate-sample-dataset`." - (:require [clojure.java + (:require [clojure + [edn :as edn] + [string :as s]] + [clojure.java [io :as io] [jdbc :as jdbc]] [clojure.math.numeric-tower :as math] - [clojure.string :as s] [faker - [address :as address] [company :as company] [internet :as internet] [lorem :as lorem] @@ -21,23 +22,21 @@ (def ^:private ^:const sample-dataset-filename (str (System/getProperty "user.dir") "/resources/sample-dataset.db")) -(defn- normal-distribution-rand [mean median] - (dist/sample (dist/normal mean median))) +(def ^:private num-rows-to-create + {:people 2500 + :products 200}) -(defn- normal-distribution-rand-int [mean median] - (math/round (normal-distribution-rand mean median))) +(def ^:private num-reviews-distribution + "Normal distribution sampled to determine number of reviews each product should have. Actual average number of + reviews will be slightly higher because negative values returned by the sample will be floored at 0 (e.g. a product + cannot have less than 0 reviews)." + (dist/normal 5 4)) -;;; ## PEOPLE - -(defn- random-latitude [] - (-> (rand) - (* 180) - (- 90))) +(def ^:private num-orders-distibution + "Normal distribution sampled to determine number of orders each person should have." + (dist/normal 5 10)) -(defn- random-longitude [] - (-> (rand) - (* 360) - (- 180))) +;;; ## PEOPLE (defn ^Date random-date-between [^Date min, ^Date max] (let [min-ms (.getTime min) @@ -47,19 +46,34 @@ (.setTime d (+ (long (rand range)) min-ms)) d)) +(def ^:private addresses (atom nil)) + +(defn- load-addresses! [] + (println "Loading addresses...") + (reset! addresses (edn/read-string (slurp "sample_dataset/metabase/sample_dataset/addresses.edn"))) + :ok) + +(defn- next-address [] + (when-not (seq @addresses) + (load-addresses!)) + (let [address (first @addresses)] + (swap! addresses rest) + address)) + (defn- random-person [] (let [first (name/first-name) - last (name/last-name)] + last (name/last-name) + addr (next-address)] {:name (format "%s %s" first last) :email (internet/free-email (format "%s.%s" first last)) :password (str (java.util.UUID/randomUUID)) :birth_date (random-date-between (u/relative-date :year -60) (u/relative-date :year -18)) - :address (address/street-address) - :city (address/city) - :zip (apply str (take 5 (address/zip-code))) - :state (address/us-state-abbr) - :latitude (random-latitude) - :longitude (random-longitude) + :address (str (:house-number addr) " " (:street addr)) + :city (:city addr) + :zip (:zip addr) + :state (:state-abbrev addr) + :latitude (:lat addr) + :longitude (:lon addr) :source (rand-nth ["Google" "Twitter" "Facebook" "Organic" "Affiliate"]) :created_at (random-date-between (u/relative-date :year -2) (u/relative-date :year 1))})) @@ -88,9 +102,12 @@ (dist/normal mean2 variance)])))) (def ^:private ^:const product-names - {:adjective '[Small, Ergonomic, Rustic, Intelligent, Gorgeous, Incredible, Fantastic, Practical, Sleek, Awesome, Enormous, Mediocre, Synergistic, Heavy-Duty, Lightweight, Aerodynamic, Durable] - :material '[Steel, Wooden, Concrete, Plastic, Cotton, Granite, Rubber, Leather, Silk, Wool, Linen, Marble, Iron, Bronze, Copper, Aluminum, Paper] - :product '[Chair, Car, Computer, Gloves, Pants, Shirt, Table, Shoes, Hat, Plate, Knife, Bottle, Coat, Lamp, Keyboard, Bag, Bench, Clock, Watch, Wallet, Toucan]}) + {:adjective '[Small, Ergonomic, Rustic, Intelligent, Gorgeous, Incredible, Fantastic, Practical, Sleek, Awesome, + Enormous, Mediocre, Synergistic, Heavy-Duty, Lightweight, Aerodynamic, Durable] + :material '[Steel, Wooden, Concrete, Plastic, Cotton, Granite, Rubber, Leather, Silk, Wool, Linen, Marble, Iron, + Bronze, Copper, Aluminum, Paper] + :product '[Chair, Car, Computer, Gloves, Pants, Shirt, Table, Shoes, Hat, Plate, Knife, Bottle, Coat, Lamp, + Keyboard, Bag, Bench, Clock, Watch, Wallet, Toucan]}) (defn- random-product-name [] (format "%s %s %s" @@ -129,7 +146,7 @@ ;;; ## ORDERS -(def ^:private ^:const state->tax-rate +(def ^:private state->tax-rate {"AK" 0.0 "AL" 0.04 "AR" 0.065 @@ -251,13 +268,18 @@ :body (first (lorem/paragraphs)) :created_at (random-date-between (:created_at product) (u/relative-date :year 2))}) +(defn- add-ids [objs] + (map-indexed + (fn [id obj] + (assoc obj :id (inc id))) + objs)) + (defn- create-randoms [n f] - (vec (map-indexed (fn [id obj] - (assoc obj :id (inc id))) - (repeatedly n f)))) + (-> (take n (distinct (repeatedly f))) + add-ids)) (defn- product-add-reviews [product] - (let [num-reviews (max 0 (normal-distribution-rand-int 5 4)) + (let [num-reviews (max 0 (dist/sample num-reviews-distribution)) ; with 200 products should give us ~1000 reviews reviews (vec (for [review (repeatedly num-reviews #(random-review product))] (assoc review :product_id (:id product)))) rating (if (seq reviews) (/ (reduce + (map :rating reviews)) @@ -271,15 +293,14 @@ {:pre [(sequential? products) (map? person)] :post [(map? %)]} - (let [num-orders (max 0 (normal-distribution-rand-int 5 10))] + (let [num-orders (max 0 (dist/sample num-orders-distibution))] ; with 2500 people should give us ~15k orders (if (zero? num-orders) person (assoc person :orders (vec (repeatedly num-orders #(random-order person (rand-nth products)))))))) (defn- add-autocorrelation - "Add autocorrelation with lag `lag` to field `k` by adding the value from `lag` - steps back (and dividing by 2 to retain roughly the same value range). - https://en.wikipedia.org/wiki/Autocorrelation" + "Add autocorrelation with lag `lag` to field `k` by adding the value from `lag` steps back (and dividing by 2 to + retain roughly the same value range). https://en.wikipedia.org/wiki/Autocorrelation" ([k xs] (add-autocorrelation 1 k xs)) ([lag k xs] (map (fn [prev next] @@ -288,8 +309,8 @@ (drop lag xs)))) (defn- add-increasing-variance - "Gradually increase variance of field `k` by scaling it an (on average) - increasingly larger random noise. + "Gradually increase variance of field `k` by scaling it an (on average) increasingly larger random noise. + https://en.wikipedia.org/wiki/Variance" [k xs] (let [n (count xs)] @@ -299,16 +320,15 @@ xs))) (defn- add-seasonality - "Add seasonal component to field `k`. Seasonal variation (a multiplicative - factor) is described with map `seasonality-map` indexed into by `season-fn` - (eg. month of year of field created_at)." + "Add seasonal component to field `k`. Seasonal variation (a multiplicative factor) is described with map + `seasonality-map` indexed into by `season-fn` (eg. month of year of field created_at)." [season-fn k seasonality-map xs] (for [x xs] (update x k * (seasonality-map (season-fn x))))) (defn- add-outliers - "Add `n` outliers (times `scale` value spikes) to field `k`. `n` can be either - percentage or count, determined by `mode`." + "Add `n` outliers (times `scale` value spikes) to field `k`. `n` can be either percentage or count, determined by + `mode`." ([mode n k xs] (add-outliers mode n 10 k xs)) ([mode n scale k xs] (if (= mode :share) @@ -328,39 +348,40 @@ (update x k * scale) x)))))) -(defn create-random-data [& {:keys [people products] - :or {people 2500 products 200}}] +(defn create-random-data [] {:post [(map? %) - (= (count (:people %)) people) - (= (count (:products %)) products) + (= (count (:people %)) (:people num-rows-to-create)) + (= (count (:products %)) (:products num-rows-to-create)) (every? keyword? (keys %)) (every? sequential? (vals %))]} - (printf "Generating random data: %d people, %d products...\n" people products) - (let [products (mapv product-add-reviews (create-randoms products random-product)) - people (mapv (partial person-add-orders products) (create-randoms people random-person))] - {:people (mapv #(dissoc % :orders) people) - :products (mapv #(dissoc % :reviews) products) - :reviews (vec (mapcat :reviews products)) - :orders (->> people - (mapcat :orders) - (add-autocorrelation :quantity) - (add-outliers :share 0.01 :quantity) - (add-outliers :count 5 :discount) - (add-increasing-variance :total) - (add-seasonality #(.getMonth ^java.util.Date (:created_at %)) - :quantity {0 0.6 - 1 0.5 - 2 0.3 - 3 0.9 - 4 1.3 - 5 1.9 - 6 1.5 - 7 2.1 - 8 1.5 - 9 1.7 - 10 0.9 - 11 0.6}) - vec)})) + (let [{:keys [products people]} num-rows-to-create] + (printf "Generating random data: %d people, %d products...\n" people products) + (let [products (for [product (create-randoms products random-product)] + (product-add-reviews product)) + people (vec (for [person (create-randoms people random-person)] + (person-add-orders products person)))] + {:people (map #(dissoc % :orders) people) + :products (map #(dissoc % :reviews) products) + :reviews (mapcat :reviews products) + :orders (->> people + (mapcat :orders) + (add-autocorrelation :quantity) + (add-outliers :share 0.01 :quantity) + (add-outliers :count 5 :discount) + (add-increasing-variance :total) + (add-seasonality #(.getMonth ^java.util.Date (:created_at %)) + :quantity {0 0.6 + 1 0.5 + 2 0.3 + 3 0.9 + 4 1.3 + 5 1.9 + 6 1.5 + 7 2.1 + 8 1.5 + 9 1.7 + 10 0.9 + 11 0.6}))}))) ;;; # LOADING THE DATA @@ -475,7 +496,9 @@ (io/delete-file (str filename ".mv.db") :silently) (io/delete-file (str filename ".trace.db") :silently) (println "Creating db...") - (let [db (dbspec/h2 {:db (format "file:%s;UNDO_LOG=0;CACHE_SIZE=131072;QUERY_CACHE_SIZE=128;COMPRESS=TRUE;MULTI_THREADED=TRUE;MVCC=TRUE;DEFRAG_ALWAYS=TRUE;MAX_COMPACT_TIME=5000;ANALYZE_AUTO=100" + (let [db (dbspec/h2 {:db (format (str "file:%s;UNDO_LOG=0;CACHE_SIZE=131072;QUERY_CACHE_SIZE=128;COMPRESS=TRUE;" + "MULTI_THREADED=TRUE;MVCC=TRUE;DEFRAG_ALWAYS=TRUE;MAX_COMPACT_TIME=5000;" + "ANALYZE_AUTO=100") filename)})] (doseq [[table-name field->type] (seq tables)] (jdbc/execute! db [(create-table-sql table-name field->type)])) @@ -495,6 +518,7 @@ (assert (keyword? table)) (assert (sequential? rows)) (let [table-name (s/upper-case (name table))] + (println (format "Inserting %d rows into %s..." (count rows) table-name)) (jdbc/insert-multi! db table-name (for [row rows] (into {} (for [[k v] (seq row)] {(s/upper-case (name k)) v})))))) diff --git a/src/metabase/api/automagic_dashboards.clj b/src/metabase/api/automagic_dashboards.clj new file mode 100644 index 0000000000000000000000000000000000000000..d46dce5a12d75d3ca3642d22d89ba9bdd59e5f66 --- /dev/null +++ b/src/metabase/api/automagic_dashboards.clj @@ -0,0 +1,256 @@ +(ns metabase.api.automagic-dashboards + (:require [buddy.core.codecs :as codecs] + [cheshire.core :as json] + [compojure.core :refer [GET POST]] + [metabase.api.common :as api] + [metabase.automagic-dashboards + [core :as magic] + [comparison :as magic.comparison] + [rules :as rules]] + [metabase.models + [card :refer [Card]] + [dashboard :refer [Dashboard] :as dashboard] + [database :refer [Database]] + [field :refer [Field]] + [metric :refer [Metric]] + [query :refer [Query] :as query] + [segment :refer [Segment]] + [table :refer [Table]]] + [metabase.util.schema :as su] + [puppetlabs.i18n.core :refer [tru]] + [ring.util.codec :as codec] + [schema.core :as s] + [toucan + [db :as db] + [hydrate :refer [hydrate]]])) + +(def ^:private Show + (su/with-api-error-message (s/maybe (s/enum "all")) + (tru "invalid show value"))) + +(def ^:private Prefix + (su/with-api-error-message + (s/pred (fn [prefix] + (some #(not-empty (rules/get-rules [% prefix])) ["table" "metric" "field"]))) + (tru "invalid value for prefix"))) + +(def ^:private Rule + (su/with-api-error-message + (s/pred (fn [rule] + (some (fn [toplevel] + (some (comp rules/get-rule + (fn [prefix] + [toplevel prefix rule]) + :rule) + (rules/get-rules [toplevel]))) + ["table" "metric" "field"]))) + (tru "invalid value for rule name"))) + +(def ^:private ^{:arglists '([s])} decode-base64-json + (comp #(json/decode % keyword) codecs/bytes->str codec/base64-decode)) + +(def ^:private Base64EncodedJSON + (su/with-api-error-message + (s/pred decode-base64-json) + (tru "value couldn''t be parsed as base64 encoded JSON"))) + +(api/defendpoint GET "/database/:id/candidates" + "Return a list of candidates for automagic dashboards orderd by interestingness." + [id] + (-> (Database id) + api/check-404 + magic/candidate-tables)) + + +;; ----------------------------------------- API Endpoints for viewing a transient dashboard ---------------- + +(api/defendpoint GET "/table/:id" + "Return an automagic dashboard for table with id `ìd`." + [id show] + {show Show} + (-> id Table api/check-404 (magic/automagic-analysis {:show (keyword show)}))) + +(api/defendpoint GET "/table/:id/rule/:prefix/:rule" + "Return an automagic dashboard for table with id `ìd` using rule `rule`." + [id prefix rule show] + {show Show + prefix Prefix + rule Rule} + (-> id + Table + api/check-404 + (magic/automagic-analysis + {:rule ["table" prefix rule] + :show (keyword show)}))) + +(api/defendpoint GET "/segment/:id" + "Return an automagic dashboard analyzing segment with id `id`." + [id show] + {show Show} + (-> id Segment api/check-404 (magic/automagic-analysis {:show (keyword show)}))) + +(api/defendpoint GET "/segment/:id/rule/:prefix/:rule" + "Return an automagic dashboard analyzing segment with id `id`. using rule `rule`." + [id prefix rule show] + {show Show + prefix Prefix + rule Rule} + (-> id + Segment + api/check-404 + (magic/automagic-analysis + {:rule ["table" prefix rule] + :show (keyword show)}))) + +(api/defendpoint GET "/question/:id/cell/:cell-query" + "Return an automagic dashboard analyzing cell in question with id `id` defined by + query `cell-querry`." + [id cell-query show] + {show Show + cell-query Base64EncodedJSON} + (-> id + Card + api/check-404 + (magic/automagic-analysis {:show (keyword show) + :cell-query (decode-base64-json cell-query)}))) + +(api/defendpoint GET "/question/:id/cell/:cell-query/rule/:prefix/:rule" + "Return an automagic dashboard analyzing cell in question with id `id` defined by + query `cell-querry` using rule `rule`." + [id cell-query prefix rule show] + {show Show + prefix Prefix + rule Rule + cell-query Base64EncodedJSON} + (-> id + Card + api/check-404 + (magic/automagic-analysis {:show (keyword show) + :rule ["table" prefix rule] + :cell-query (decode-base64-json cell-query)}))) + +(api/defendpoint GET "/metric/:id" + "Return an automagic dashboard analyzing metric with id `id`." + [id show] + {show Show} + (-> id Metric api/check-404 (magic/automagic-analysis {:show (keyword show)}))) + +(api/defendpoint GET "/field/:id" + "Return an automagic dashboard analyzing field with id `id`." + [id show] + {show Show} + (-> id Field api/check-404 (magic/automagic-analysis {:show (keyword show)}))) + +(api/defendpoint GET "/question/:id" + "Return an automagic dashboard analyzing question with id `id`." + [id show] + {show Show} + (-> id Card api/check-404 (magic/automagic-analysis {:show (keyword show)}))) + +(api/defendpoint GET "/question/:id/rule/:prefix/:rule" + "Return an automagic dashboard analyzing question with id `id` using rule `rule`." + [id prefix rule show] + {show Show + prefix Prefix + rule Rule} + (-> id Card api/check-404 (magic/automagic-analysis {:show (keyword show) + :rule ["table" prefix rule]}))) + +(api/defendpoint GET "/adhoc/:query" + "Return an automagic dashboard analyzing ad hoc query." + [query show] + {show Show + query Base64EncodedJSON} + (-> query + decode-base64-json + query/adhoc-query + (magic/automagic-analysis {:show (keyword show)}))) + +(api/defendpoint GET "/adhoc/:query/rule/:prefix/:rule" + "Return an automagic dashboard analyzing ad hoc query." + [query prefix rule show] + {show Show + query Base64EncodedJSON + prefix Prefix + rule Rule} + (-> query + decode-base64-json + query/adhoc-query + (magic/automagic-analysis {:show (keyword show) + :rule ["table" prefix rule]}))) + +(api/defendpoint GET "/adhoc/:query/cell/:cell-query" + "Return an automagic dashboard analyzing ad hoc query." + [query cell-query show] + {show Show + query Base64EncodedJSON + cell-query Base64EncodedJSON} + (let [query (decode-base64-json query) + cell-query (decode-base64-json cell-query)] + (-> query + query/adhoc-query + (magic/automagic-analysis {:show (keyword show) + :cell-query cell-query})))) + +(api/defendpoint GET "/adhoc/:query/cell/:cell-query/rule/:prefix/:rule" + "Return an automagic dashboard analyzing cell in question with id `id` defined by + query `cell-querry` using rule `rule`." + [query cell-query prefix rule show] + {show Show + prefix Prefix + rule Rule + query Base64EncodedJSON + cell-query Base64EncodedJSON} + (let [query (decode-base64-json query) + cell-query (decode-base64-json cell-query)] + (-> query + query/adhoc-query + (magic/automagic-analysis {:show (keyword show) + :cell-query cell-query + :rule ["table" prefix rule]})))) + +(def ^:private valid-comparison-pair? + #{["segment" "segment"] + ["segment" "table"] + ["segment" "adhoc"] + ["table" "segment"] + ["table" "adhoc"] + ["adhoc" "table"] + ["adhoc" "segment"] + ["adhoc" "adhoc"]}) + +(defmulti + ^{:private true + :doc "Turn `x` into segment-like." + :arglists '([x])} + ->segment (comp keyword :type)) + +(defmethod ->segment :table + [{:keys [id]}] + (-> id Table api/check-404)) + +(defmethod ->segment :segment + [{:keys [id]}] + (-> id Segment api/check-404)) + +(defmethod ->segment :adhoc + [{:keys [query name]}] + (-> query + query/adhoc-query + (assoc :name name))) + +(api/defendpoint POST "/compare" + "Return an automagic comparison dashboard based on given dashboard." + [:as {{:keys [dashboard left right]} :body}] + (api/check-404 (valid-comparison-pair? (map :type [left right]))) + (magic.comparison/comparison-dashboard (if (number? dashboard) + (-> (Dashboard dashboard) + api/check-404 + (hydrate [:ordered_cards + [:card :in_public_dashboard] + :series])) + dashboard) + (->segment left) + (->segment right))) + +(api/define-routes) diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index 181ab8214394d21ce490a12785c82fc48369cae6..0f3d2689389053d5251f95e026cd05b8bb18c7d8 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -36,6 +36,7 @@ [metabase.query-processor.middleware [cache :as cache] [results-metadata :as results-metadata]] + [metabase.related :as related] [metabase.util.schema :as su] [ring.util.codec :as codec] [schema.core :as s] @@ -594,7 +595,7 @@ "Run the query for Card with PARAMETERS and CONSTRAINTS, and return results in the usual format." {:style/indent 1} [card-id & {:keys [parameters constraints context dashboard-id] - :or {constraints dataset-api/default-query-constraints + :or {constraints qp/default-query-constraints context :question}}] {:pre [(u/maybe? sequential? parameters)]} (let [card (api/read-check (hydrate (Card card-id) :in_public_dashboard)) @@ -669,4 +670,14 @@ (api/check-embedding-enabled) (db/select [Card :name :id], :enable_embedding true, :archived false)) +(api/defendpoint GET "/:id/related" + "Return related entities." + [id] + (-> id Card api/read-check related/related)) + +(api/defendpoint POST "/related" + "Return related entities for an ad-hoc query." + [:as {query :body}] + (related/related (query/adhoc-query query))) + (api/define-routes) diff --git a/src/metabase/api/common.clj b/src/metabase/api/common.clj index ce5692bef31e813ab2f0321c39538120191bcf87..9162a54c4a36d647ea92e8ff2ce27f1ffa439914 100644 --- a/src/metabase/api/common.clj +++ b/src/metabase/api/common.clj @@ -13,15 +13,12 @@ [util :as u]] [metabase.api.common.internal :refer :all] [metabase.models.interface :as mi] - [ring.util - [io :as rui] - [response :as rr]] + [puppetlabs.i18n.core :refer [trs tru]] [ring.core.protocols :as protocols] [ring.util.response :as response] [schema.core :as s] [toucan.db :as db]) - (:import [java.io BufferedWriter OutputStream OutputStreamWriter] - [java.nio.charset Charset StandardCharsets])) + (:import java.io.OutputStream)) (declare check-403 check-404) @@ -101,7 +98,7 @@ (defn throw-invalid-param-exception "Throw an `ExceptionInfo` that contains information about an invalid API params in the expected format." [field-name message] - (throw (ex-info (format "Invalid field: %s" field-name) + (throw (ex-info (tru "Invalid field: {0}" field-name) {:status-code 400 :errors {(keyword field-name) message}}))) @@ -139,7 +136,7 @@ (checkp-with f symb value (str "test failed: " f))) ([f symb value message] {:pre [(symbol? symb)]} - (checkp (f value) symb (format "Invalid value '%s' for '%s': %s" (str value) symb message)) + (checkp (f value) symb (tru "Invalid value ''{0}'' for ''{1}'': {2}" (str value) symb message)) value)) (defn checkp-contains? @@ -151,7 +148,8 @@ [400 (str \"Invalid value '\" f \"' for 'f': must be one of: #{:fav :all :mine}\")])" [valid-values-set symb value] {:pre [(set? valid-values-set) (symbol? symb)]} - (checkp-with (partial contains? valid-values-set) symb value (str "must be one of: " valid-values-set))) + (checkp-with (partial contains? valid-values-set) symb value + (tru "must be one of: {0}" valid-values-set))) ;;; ---------------------------------------------- api-let, api->, etc. ---------------------------------------------- @@ -186,7 +184,7 @@ ;; #### GENERIC 400 RESPONSE HELPERS (def ^:private generic-400 - [400 "Invalid Request."]) + [400 (tru "Invalid Request.")]) (defn check-400 "Throw a `400` if ARG is `false` or `nil`, otherwise return as-is." @@ -201,7 +199,7 @@ ;; #### GENERIC 404 RESPONSE HELPERS (def ^:private generic-404 - [404 "Not found."]) + [404 (tru "Not found.")]) (defn check-404 "Throw a `404` if ARG is `false` or `nil`, otherwise return as-is." @@ -217,7 +215,7 @@ ;; #### GENERIC 403 RESPONSE HELPERS ;; If you can't be bothered to write a custom error message (def ^:private generic-403 - [403 "You don't have permissions to do that."]) + [403 (tru "You don''t have permissions to do that.")]) (defn check-403 "Throw a `403` if ARG is `false` or `nil`, otherwise return as-is." @@ -232,7 +230,7 @@ ;; #### GENERIC 500 RESPONSE HELPERS ;; For when you don't feel like writing something useful (def ^:private generic-500 - [500 "Internal server error."]) + [500 (tru "Internal server error.")]) (defn check-500 "Throw a `500` if ARG is `false` or `nil`, otherwise return as-is." @@ -277,7 +275,7 @@ [arg->schema body] (u/optional #(and (map? %) (every? symbol? (keys %))) more) validate-param-calls (validate-params arg->schema)] (when-not docstr - (log/warn (format "Warning: endpoint %s/%s does not have a docstring." (ns-name *ns*) fn-name))) + (log/warn (trs "Warning: endpoint {0}/{1} does not have a docstring." (ns-name *ns*) fn-name))) `(def ~(vary-meta fn-name assoc ;; eval the vals in arg->schema to make sure the actual schemas are resolved so we can document ;; their API error messages @@ -352,7 +350,7 @@ (extend-protocol protocols/StreamableResponseBody clojure.core.async.impl.channels.ManyToManyChannel (write-body-to-stream [output-queue _ ^OutputStream output-stream] - (log/debug (u/format-color 'green "starting streaming request")) + (log/debug (u/format-color 'green (trs "starting streaming request"))) (with-open [out (io/writer output-stream)] (loop [chunk (async/<!! output-queue)] (cond @@ -362,7 +360,7 @@ (.write out (str chunk)) (.flush out) (catch org.eclipse.jetty.io.EofException e - (log/info e (u/format-color 'yellow "connection closed, canceling request")) + (log/info e (u/format-color 'yellow (trs "connection closed, canceling request"))) (async/close! output-queue) (throw e))) (recur (async/<!! output-queue))) @@ -419,7 +417,7 @@ ;; a newline padding character as it's harmless and will allow us to check if the client is connected. If ;; sending this character fails because the connection is closed, the chan will then close. Newlines are ;; no-ops when reading JSON which this depends upon. - (log/debug (u/format-color 'blue "Response not ready, writing one byte & sleeping...")) + (log/debug (u/format-color 'blue (trs "Response not ready, writing one byte & sleeping..."))) (if (async/>!! output-chan \newline) ;; Success put the channel, wait and see if we get the response next time (recur) @@ -449,13 +447,13 @@ "Check that the `public-sharing-enabled` Setting is `true`, or throw a `400`." [] (check (public-settings/enable-public-sharing) - [400 "Public sharing is not enabled."])) + [400 (tru "Public sharing is not enabled.")])) (defn check-embedding-enabled "Is embedding of Cards or Objects (secured access via `/api/embed` endpoints with a signed JWT enabled?" [] (check (public-settings/enable-embedding) - [400 "Embedding is not enabled."])) + [400 (tru "Embedding is not enabled.")])) (defn check-not-archived "Check that the OBJECT exists and is not `:archived`, or throw a `404`. Returns OBJECT as-is if check passes." @@ -463,4 +461,4 @@ (u/prog1 object (check-404 object) (check (not (:archived object)) - [404 {:message "The object has been archived.", :error_code "archived"}]))) + [404 {:message (tru "The object has been archived."), :error_code "archived"}]))) diff --git a/src/metabase/api/common/internal.clj b/src/metabase/api/common/internal.clj index a09cb97b4ad24aca10cff0218c51abbe12ec7b39..5862498c56752c5eab5ae86e69f83a780d87db5b 100644 --- a/src/metabase/api/common/internal.clj +++ b/src/metabase/api/common/internal.clj @@ -7,6 +7,7 @@ [medley.core :as m] [metabase.util :as u] [metabase.util.schema :as su] + [puppetlabs.i18n.core :refer [trs tru]] [schema.core :as s]) (:import java.sql.SQLException)) @@ -52,9 +53,8 @@ (if-not schema "" (or (su/api-error-message schema) - (log/warn "We don't have a nice error message for schema:" - schema - "Consider wrapping it in `su/with-api-error-message`.")))) + (log/warn (str (trs "We don't have a nice error message for schema: {0}." schema) + (trs "Consider wrapping it in `su/with-api-error-message`.")))))) (defn- param-name "Return the appropriate name for this PARAM-SYMB based on its SCHEMA. Usually this is just the name of the @@ -271,7 +271,7 @@ [field-name value schema] (try (s/validate schema value) (catch Throwable e - (throw (ex-info (format "Invalid field: %s" field-name) + (throw (ex-info (tru "Invalid field: {0}" field-name) {:status-code 400 :errors {(keyword field-name) (or (su/api-error-message schema) (:message (ex-data e)) @@ -305,7 +305,7 @@ [response] ;; Not sure why this is but the JSON serialization middleware barfs if response is just a plain boolean (when (m/boolean? response) - (throw (Exception. "Attempted to return a boolean as an API response. This is not allowed!"))) + (throw (Exception. (str (tru "Attempted to return a boolean as an API response. This is not allowed!"))))) (if (and (map? response) (contains? response :status) (contains? response :body)) diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj index 0b7a49f856f99dec95a807c0c7183ff386b6c431..f2e40e5777daea312087d17bc7add0d8e7d02bb5 100644 --- a/src/metabase/api/dashboard.clj +++ b/src/metabase/api/dashboard.clj @@ -4,6 +4,7 @@ [compojure.core :refer [DELETE GET POST PUT]] [metabase [events :as events] + [query-processor :as qp] [util :as u]] [metabase.api [common :as api] @@ -17,6 +18,7 @@ [query :as query :refer [Query]] [revision :as revision]] [metabase.query-processor.util :as qp-util] + [metabase.related :as related] [metabase.util.schema :as su] [schema.core :as s] [toucan @@ -123,7 +125,7 @@ [{:keys [dataset_query]}] (u/ignore-exceptions [(qp-util/query-hash dataset_query) - (qp-util/query-hash (assoc dataset_query :constraints dataset/default-query-constraints))])) + (qp-util/query-hash (assoc dataset_query :constraints qp/default-query-constraints))])) (defn- dashcard->query-hashes "Return a sequence of all the query hashes for this DASHCARD, including the top-level Card and any Series." @@ -365,5 +367,18 @@ (api/check-embedding-enabled) (db/select [Dashboard :name :id], :enable_embedding true, :archived false)) +(api/defendpoint GET "/:id/related" + "Return related entities." + [id] + (-> id Dashboard api/read-check related/related)) + +;;; --------------------------------------------------- Transient dashboards --------------------------------------------------- + +(api/defendpoint POST "/save" + "Save a denormalized description of dashboard." + [:as {dashboard :body}] + (api/check-superuser) + (->> (dashboard/save-transient-dashboard! dashboard) + (events/publish-event! :dashboard-create))) (api/define-routes) diff --git a/src/metabase/api/database.clj b/src/metabase/api/database.clj index f8f5b70b5e3e0064220d2f257a863b7edfcab865..cc974a20ad6c3518e0894e9cc94a9075b53be792 100644 --- a/src/metabase/api/database.clj +++ b/src/metabase/api/database.clj @@ -198,7 +198,7 @@ (defn- db-metadata [id] (-> (api/read-check Database id) - (hydrate [:tables [:fields :target :has_field_values] :segments :metrics]) + (hydrate [:tables [:fields [:target :has_field_values] :has_field_values] :segments :metrics]) (update :tables (fn [tables] (for [table tables :when (mi/can-read? table)] diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj index be2c68d17e734f5ed42d2cbe6935e1cd6ff20a5d..4ead2c8ee2bf38c86fe0f882473fac1fb27fe5d3 100644 --- a/src/metabase/api/dataset.clj +++ b/src/metabase/api/dataset.clj @@ -16,27 +16,13 @@ [database :as database :refer [Database]] [query :as query]] [metabase.query-processor.util :as qputil] + [metabase.util :as util] [metabase.util [export :as ex] [schema :as su]] + [puppetlabs.i18n.core :refer [trs tru]] [schema.core :as s])) -;;; --------------------------------------------------- Constants ---------------------------------------------------- - -(def ^:private ^:const max-results-bare-rows - "Maximum number of rows to return specifically on :rows type queries via the API." - 2000) - -(def ^:private ^:const max-results - "General maximum number of rows to return from an API query." - 10000) - -(def ^:const default-query-constraints - "Default map of constraints that we apply on dataset queries executed by the api." - {:max-results max-results - :max-results-bare-rows max-results-bare-rows}) - - ;;; -------------------------------------------- Running a Query Normally -------------------------------------------- (defn- query->source-card-id @@ -46,7 +32,7 @@ well." [outer-query] (when-let [source-card-id (qputil/query->source-card-id outer-query)] - (log/info (str "Source query for this query is Card " source-card-id)) + (log/info (trs "Source query for this query is Card {0}" source-card-id)) (api/read-check Card source-card-id) source-card-id)) @@ -61,8 +47,8 @@ (let [source-card-id (query->source-card-id query)] (api/cancellable-json-response (fn [] - (qp/process-query-and-save-execution! (assoc query :constraints default-query-constraints) - {:executed-by api/*current-user-id*, :context :ad-hoc, :card-id source-card-id, :nested? (boolean source-card-id)}))))) + (qp/process-query-and-save-with-max! query {:executed-by api/*current-user-id*, :context :ad-hoc, + :card-id source-card-id, :nested? (boolean source-card-id)}))))) ;;; ----------------------------------- Downloading Query Results in Other Formats ----------------------------------- @@ -78,14 +64,19 @@ (export-format->context :json) ;-> :json-download" [export-format] (or (get-in ex/export-formats [export-format :context]) - (throw (Exception. (str "Invalid export format: " export-format))))) + (throw (Exception. (str (tru "Invalid export format: {0}" export-format)))))) (defn- datetime-str->date "Dates are iso formatted, i.e. 2014-09-18T00:00:00.000-07:00. We can just drop the T and everything after it since - we don't want to change the timezone or alter the date part." + we don't want to change the timezone or alter the date part. SQLite dates are not iso formatted and separate the + date from the time using a space, this function handles that as well" [^String date-str] - (when date-str - (subs date-str 0 (.indexOf date-str "T")))) + (if-let [time-index (and (string? date-str) + ;; clojure.string/index-of returns nil if the string is not found + (or (str/index-of date-str "T") + (str/index-of date-str " ")))] + (subs date-str 0 time-index) + date-str)) (defn- swap-date-columns [date-col-indexes] (fn [row] @@ -151,7 +142,7 @@ ;; try calculating the average for the query as it was given to us, otherwise with the default constraints if ;; there's no data there. If we still can't find relevant info, just default to 0 {:average (or (query/average-execution-time-ms (qputil/query-hash query)) - (query/average-execution-time-ms (qputil/query-hash (assoc query :constraints default-query-constraints))) + (query/average-execution-time-ms (qputil/query-hash (assoc query :constraints qp/default-query-constraints))) 0)}) (api/define-routes) diff --git a/src/metabase/api/email.clj b/src/metabase/api/email.clj index 16ec9948ea618737182c9ce53e56c7788dd27d5a..d6da9a13a865bf74db111f6e5286dfaac63b0405 100644 --- a/src/metabase/api/email.clj +++ b/src/metabase/api/email.clj @@ -5,7 +5,7 @@ [set :as set] [string :as string]] [clojure.tools.logging :as log] - [compojure.core :refer [POST PUT]] + [compojure.core :refer [DELETE POST PUT]] [metabase [config :as config] [email :as email]] @@ -90,6 +90,13 @@ {:status 500 :body (humanize-error-messages response)}))) +(api/defendpoint DELETE "/" + "Clear all email related settings. You must be a superuser to ddo this" + [] + (api/check-superuser) + (setting/set-many! (zipmap (keys mb-to-smtp-settings) (repeat nil))) + api/generic-204-no-content) + (api/defendpoint POST "/test" "Send a test email. You must be a superuser to do this." [] diff --git a/src/metabase/api/embed.clj b/src/metabase/api/embed.clj index 31cb2449aa87dd34bfd8ce0c573474dec1f503ef..46ac0d35eabe40d2546cc473e700765955d0f710 100644 --- a/src/metabase/api/embed.clj +++ b/src/metabase/api/embed.clj @@ -145,15 +145,19 @@ [card] (update card :parameters concat (template-tag-parameters card))) -(defn- apply-parameter-values +(s/defn ^:private apply-parameter-values :- (s/maybe [{:slug su/NonBlankString + :type su/NonBlankString + :target s/Any + :value s/Any}]) "Adds `value` to parameters with `slug` matching a key in `parameter-values` and removes parameters without a `value`." [parameters parameter-values] - (for [param parameters - :let [value (get parameter-values (keyword (:slug param)))] - :when (some? value)] - (assoc (select-keys param [:type :target]) - :value value))) + (when (seq parameters) + (for [param parameters + :let [value (get parameter-values (keyword (:slug param)))] + :when (some? value)] + (assoc (select-keys param [:type :target :slug]) + :value value)))) (defn- resolve-card-parameters "Returns parameters for a card (HUH?)" ; TODO - better docstring @@ -343,4 +347,74 @@ :query-params query-params))) +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | FieldValues, Search, Remappings | +;;; +----------------------------------------------------------------------------------------------------------------+ + +;;; -------------------------------------------------- Field Values -------------------------------------------------- + +(api/defendpoint GET "/card/:token/field/:field-id/values" + "Fetch FieldValues for a Field that is referenced by an embedded Card." + [token field-id] + (let [unsigned-token (eu/unsign token) + card-id (eu/get-in-unsigned-token-or-throw unsigned-token [:resource :question])] + (check-embedding-enabled-for-card card-id) + (public-api/card-and-field-id->values card-id field-id))) + +(api/defendpoint GET "/dashboard/:token/field/:field-id/values" + "Fetch FieldValues for a Field that is used as a param in an embedded Dashboard." + [token field-id] + (let [unsigned-token (eu/unsign token) + dashboard-id (eu/get-in-unsigned-token-or-throw unsigned-token [:resource :dashboard])] + (check-embedding-enabled-for-dashboard dashboard-id) + (public-api/dashboard-and-field-id->values dashboard-id field-id))) + + +;;; --------------------------------------------------- Searching ---------------------------------------------------- + +(api/defendpoint GET "/card/:token/field/:field-id/search/:search-field-id" + "Search for values of a Field that is referenced by an embedded Card." + [token field-id search-field-id value limit] + {value su/NonBlankString + limit (s/maybe su/IntStringGreaterThanZero)} + (let [unsigned-token (eu/unsign token) + card-id (eu/get-in-unsigned-token-or-throw unsigned-token [:resource :question])] + (check-embedding-enabled-for-card card-id) + (public-api/search-card-fields card-id field-id search-field-id value (when limit (Integer/parseInt limit))))) + +(api/defendpoint GET "/dashboard/:token/field/:field-id/search/:search-field-id" + "Search for values of a Field that is referenced by a Card in an embedded Dashboard." + [token field-id search-field-id value limit] + {value su/NonBlankString + limit (s/maybe su/IntStringGreaterThanZero)} + (let [unsigned-token (eu/unsign token) + dashboard-id (eu/get-in-unsigned-token-or-throw unsigned-token [:resource :dashboard])] + (check-embedding-enabled-for-dashboard dashboard-id) + (public-api/search-dashboard-fields dashboard-id field-id search-field-id value (when limit + (Integer/parseInt limit))))) + + +;;; --------------------------------------------------- Remappings --------------------------------------------------- + +(api/defendpoint GET "/card/:token/field/:field-id/remapping/:remapped-id" + "Fetch remapped Field values. This is the same as `GET /api/field/:id/remapping/:remapped-id`, but for use with + embedded Cards." + [token field-id remapped-id value] + {value su/NonBlankString} + (let [unsigned-token (eu/unsign token) + card-id (eu/get-in-unsigned-token-or-throw unsigned-token [:resource :question])] + (check-embedding-enabled-for-card card-id) + (public-api/card-field-remapped-values card-id field-id remapped-id value))) + +(api/defendpoint GET "/dashboard/:token/field/:field-id/remapping/:remapped-id" + "Fetch remapped Field values. This is the same as `GET /api/field/:id/remapping/:remapped-id`, but for use with + embedded Dashboards." + [token field-id remapped-id value] + {value su/NonBlankString} + (let [unsigned-token (eu/unsign token) + dashboard-id (eu/get-in-unsigned-token-or-throw unsigned-token [:resource :dashboard])] + (check-embedding-enabled-for-dashboard dashboard-id) + (public-api/dashboard-field-remapped-values dashboard-id field-id remapped-id value))) + + (api/define-routes) diff --git a/src/metabase/api/field.clj b/src/metabase/api/field.clj index 5b700aef73f3ff24781acc19accf6f6affd4584f..a1ea3eec15b4ba298387c1acaa1c1e597624608d 100644 --- a/src/metabase/api/field.clj +++ b/src/metabase/api/field.clj @@ -8,6 +8,7 @@ [field-values :as field-values :refer [FieldValues]] [table :refer [Table]]] [metabase.query-processor :as qp] + [metabase.related :as related] [metabase.util :as u] [metabase.util.schema :as su] [schema.core :as s] @@ -32,7 +33,7 @@ "Get `Field` with ID." [id] (-> (api/read-check Field id) - (hydrate [:table :db] :has_field_values))) + (hydrate [:table :db] :has_field_values :dimensions :name_field))) (defn- clear-dimension-on-fk-change! [{{dimension-id :id dimension-type :type} :dimensions :as field}] (when (and dimension-id (= :external dimension-type)) @@ -74,7 +75,7 @@ points_of_interest (s/maybe su/NonBlankString) special_type (s/maybe FieldType) visibility_type (s/maybe FieldVisibilityType) - has_field_values (s/maybe (s/enum "search" "list" "none"))} + has_field_values (s/maybe (apply s/enum (map name field/has-field-values-options)))} (let [field (hydrate (api/write-check Field id) :dimensions) new-special-type (keyword (get body :special_type (:special_type field))) removed-fk? (removed-fk-special-type? (:special_type field) new-special-type) @@ -150,17 +151,23 @@ (def ^:private empty-field-values {:values []}) +(defn field->values + "Fetch FieldValues, if they exist, for a `field` and return them in an appropriate format for public/embedded + use-cases." + [field] + (api/check-404 field) + (if-let [field-values (and (field-values/field-should-have-field-values? field) + (field-values/create-field-values-if-needed! field))] + (-> field-values + (assoc :values (field-values/field-values->pairs field-values)) + (dissoc :human_readable_values :created_at :updated_at :id)) + {:values [], :field_id (:id field)})) + (api/defendpoint GET "/:id/values" "If `Field`'s special type derives from `type/Category`, or its base type is `type/Boolean`, return all distinct values of the field, and a map of human-readable values defined by the user." [id] - (let [field (api/read-check Field id)] - (if-let [field-values (and (field-values/field-should-have-field-values? field) - (field-values/create-field-values-if-needed! field))] - (-> field-values - (assoc :values (field-values/field-values->pairs field-values)) - (dissoc :human_readable_values)) - {:values []}))) + (field->values (api/read-check Field id))) ;; match things like GET /field-literal%2Ccreated_at%2Ctype%2FDatetime/values ;; (this is how things like [field-literal,created_at,type/Datetime] look when URL-encoded) @@ -205,8 +212,8 @@ {value-pairs [[(s/one s/Num "value") (s/optional su/NonBlankString "human readable value")]]} (let [field (api/write-check Field id)] (api/check (field-values/field-should-have-field-values? field) - [400 (str "You can only update the human readable values of a mapped values of a Field whose 'special_type' " - "is 'category'/'city'/'state'/'country' or whose 'base_type' is 'type/Boolean'.")]) + [400 (str "You can only update the human readable values of a mapped values of a Field whose value of " + "`has_field_values` is `list` or whose 'base_type' is 'type/Boolean'.")]) (if-let [field-value-id (db/select-one-id FieldValues, :field_id id)] (update-field-values! field-value-id value-pairs) (create-field-values! field value-pairs))) @@ -253,8 +260,24 @@ (db/select-one Field :id fk-target-field-id) field)) - -(s/defn ^:private search-values +(defn- search-values-query + "Generate the MBQL query used to power FieldValues search in `search-values` below. The actual query generated differs + slightly based on whether the two Fields are the same Field." + [field search-field value limit] + {:database (db-id field) + :type :query + :query {:source-table (table-id field) + :filter [:starts-with [:field-id (u/get-id search-field)] value {:case-sensitive false}] + ;; if both fields are the same then make sure not to refer to it twice in the `:breakout` clause. + ;; Otherwise this will break certain drivers like BigQuery that don't support duplicate + ;; identifiers/aliases + :breakout (if (= (u/get-id field) (u/get-id search-field)) + [[:field-id (u/get-id field)]] + [[:field-id (u/get-id field)] + [:field-id (u/get-id search-field)]]) + :limit limit}}) + +(s/defn search-values "Search for values of `search-field` that start with `value` (up to `limit`, if specified), and return like [<value-of-field> <matching-value-of-search-field>]. @@ -268,17 +291,16 @@ (48 \"Maryam Douglas\"))" [field search-field value & [limit]] (let [field (follow-fks field) - results (qp/process-query - {:database (db-id field) - :type :query - :query {:source-table (table-id field) - :filter [:starts-with [:field-id (u/get-id search-field)] value {:case-sensitive false}] - :breakout [[:field-id (u/get-id field)]] - :fields [[:field-id (u/get-id field)] - [:field-id (u/get-id search-field)]] - :limit limit}})] - ;; return rows if they exist - (get-in results [:data :rows]))) + results (qp/process-query (search-values-query field search-field value limit)) + rows (get-in results [:data :rows])] + ;; if the two Fields are different, we'll get results like [[v1 v2] [v1 v2]]. That is the expected format and we can + ;; return them as-is + (if-not (= (u/get-id field) (u/get-id search-field)) + rows + ;; However if the Fields are both the same results will be in the format [[v1] [v1]] so we need to double the + ;; value to get the format the frontend expects + (for [[result] rows] + [result result])))) (api/defendpoint GET "/:id/search/:search-id" "Search for values of a Field that match values of another Field when breaking out by the " @@ -314,15 +336,25 @@ ;; return first row if it exists (first (get-in results [:data :rows])))) +(defn parse-query-param-value-for-field + "Parse a `value` passed as a URL query param in a way appropriate for the `field` it belongs to. E.g. for text Fields + the value doesn't need to be parsed; for numeric Fields we should parse it as a number." + [field, ^String value] + (if (isa? (:base_type field) :type/Number) + (.parse (NumberFormat/getInstance) value) + value)) + (api/defendpoint GET "/:id/remapping/:remapped-id" "Fetch remapped Field values." [id remapped-id, ^String value] (let [field (api/read-check Field id) remapped-field (api/read-check Field remapped-id) - value (if (isa? (:base_type field) :type/Number) - (.parse (NumberFormat/getInstance) value) - value)] + value (parse-query-param-value-for-field field value)] (remapped-value field remapped-field value))) +(api/defendpoint GET "/:id/related" + "Return related entities." + [id] + (-> id Field api/read-check related/related)) (api/define-routes) diff --git a/src/metabase/api/geojson.clj b/src/metabase/api/geojson.clj index c6cfc6e1de3abc1e29714757fcb1dd6f5d3b94f8..96209270ed718351154b52f77ae78b0757bfa51c 100644 --- a/src/metabase/api/geojson.clj +++ b/src/metabase/api/geojson.clj @@ -6,15 +6,23 @@ [metabase.models.setting :as setting :refer [defsetting]] [metabase.util :as u] [metabase.util.schema :as su] - [schema.core :as s])) + [puppetlabs.i18n.core :refer [tru]] + [ring.util.response :as rr] + [schema.core :as s]) + (:import org.apache.commons.io.input.ReaderInputStream)) + +(def ^:private ^:const ^Integer geojson-fetch-timeout-ms + "Number of milliseconds we have to fetch (and parse, if applicable) a GeoJSON file before we consider the request to + have timed out." + (int (* 60 1000))) (defn- valid-json? "Does this URL-OR-RESOURCE point to valid JSON? URL-OR-RESOURCE should be something that can be passed to `slurp`, like an HTTP URL or a `java.net.URL` (which is what `io/resource` returns below)." [url-or-resource] - (u/with-timeout 5000 - (json/parse-string (slurp url-or-resource))) + (u/with-timeout geojson-fetch-timeout-ms + (dorun (json/parse-stream (io/reader url-or-resource)))) true) (defn- valid-json-resource? @@ -38,7 +46,7 @@ (memoize (fn [url-or-resource-path] (or (valid-json-url? url-or-resource-path) (valid-json-resource? url-or-resource-path) - (throw (Exception. (str "Invalid JSON URL or resource: " url-or-resource-path))))))) + (throw (Exception. (str (tru "Invalid JSON URL or resource: {0}" url-or-resource-path)))))))) (def ^:private CustomGeoJSON {s/Keyword {:name s/Str @@ -63,8 +71,7 @@ :builtin true}}) (defsetting custom-geojson - "JSON containing information about custom GeoJSON files for use in map visualizations instead of the default US - State or World GeoJSON." + (tru "JSON containing information about custom GeoJSON files for use in map visualizations instead of the default US State or World GeoJSON.") :type :json :default {} :getter (fn [] (merge (setting/get-json :custom-geojson) builtin-geojson)) @@ -80,12 +87,11 @@ [key] {key su/NonBlankString} (let [url (or (get-in (custom-geojson) [(keyword key) :url]) - (throw (ex-info (str "Invalid custom GeoJSON key: " key) + (throw (ex-info (tru "Invalid custom GeoJSON key: {0}" key) {:status-code 400})))] - {:status 200 - :headers {"Content-Type" "application/json"} - :body (u/with-timeout 5000 - (slurp url))})) + ;; TODO - it would be nice if we could also avoid returning our usual cache-busting headers with the response here + (-> (rr/response (ReaderInputStream. (io/reader url))) + (rr/content-type "application/json")))) (define-routes) diff --git a/src/metabase/api/getting_started.clj b/src/metabase/api/getting_started.clj index 40c0c2b6ec3a8840102d7660f9957fe6b75313d1..8bb06c466565c5f177d5fc8449571b794cef91cf 100644 --- a/src/metabase/api/getting_started.clj +++ b/src/metabase/api/getting_started.clj @@ -5,16 +5,17 @@ [metabase.models [interface :as mi] [setting :refer [defsetting]]] + [puppetlabs.i18n.core :refer [tru]] [toucan.db :as db])) (defsetting getting-started-things-to-know - "'Some things to know' text field for the Getting Started guide.") + (tru "''Some things to know'' text field for the Getting Started guide.")) (defsetting getting-started-contact-name - "Name of somebody users can contact for help in the Getting Started guide.") + (tru "Name of somebody users can contact for help in the Getting Started guide.")) (defsetting getting-started-contact-email - "Email of somebody users can contact for help in the Getting Started guide.") + (tru "Email of somebody users can contact for help in the Getting Started guide.")) (api/defendpoint GET "/" diff --git a/src/metabase/api/metric.clj b/src/metabase/api/metric.clj index b33dcf4a6fad9d553fe798648952efd1b64a8c8c..125d75c760ffc6edbdfb189dd182101f01a168f6 100644 --- a/src/metabase/api/metric.clj +++ b/src/metabase/api/metric.clj @@ -8,6 +8,7 @@ [metric :as metric :refer [Metric]] [revision :as revision] [table :refer [Table]]] + [metabase.related :as related] [metabase.util.schema :as su] [toucan [db :as db] @@ -112,5 +113,9 @@ :user-id api/*current-user-id* :revision-id revision_id)) +(api/defendpoint GET "/:id/related" + "Return related entities." + [id] + (-> id Metric api/read-check related/related)) (api/define-routes) diff --git a/src/metabase/api/public.clj b/src/metabase/api/public.clj index 5f8018a8d5f18da49933c42331cf0aa38a9a1639..dfd1a80a04790d2e21dbf046beadd79f805016a4 100644 --- a/src/metabase/api/public.clj +++ b/src/metabase/api/public.clj @@ -1,25 +1,34 @@ (ns metabase.api.public "Metabase API endpoints for viewing publicly-accessible Cards and Dashboards." (:require [cheshire.core :as json] + [clojure.walk :as walk] [compojure.core :refer [GET]] + [medley.core :as m] [metabase + [db :as mdb] [query-processor :as qp] [util :as u]] [metabase.api [card :as card-api] [common :as api] [dataset :as dataset-api] - [dashboard :as dashboard-api]] + [dashboard :as dashboard-api] + [field :as field-api]] [metabase.models - [card :refer [Card]] + [card :refer [Card] :as card] [dashboard :refer [Dashboard]] [dashboard-card :refer [DashboardCard]] [dashboard-card-series :refer [DashboardCardSeries]] + [dimension :refer [Dimension]] + [field :refer [Field]] [field-values :refer [FieldValues]] [params :as params]] + [metabase.query-processor :as qp] + metabase.query-processor.interface ; because we refer to Field [metabase.util [embed :as embed] [schema :as su]] + [puppetlabs.i18n.core :refer [trs tru]] [schema.core :as s] [toucan [db :as db] @@ -31,18 +40,21 @@ ;;; -------------------------------------------------- Public Cards -------------------------------------------------- -(defn- remove-card-non-public-fields +(defn- remove-card-non-public-columns "Remove everyting from public CARD that shouldn't be visible to the general public." [card] - (u/select-nested-keys card [:id :name :description :display :visualization_settings [:dataset_query :type [:native :template_tags]]])) + (card/map->CardInstance + (u/select-nested-keys card [:id :name :description :display :visualization_settings + [:dataset_query :type [:native :template_tags]]]))) (defn public-card - "Return a public Card matching key-value CONDITIONS, removing all fields that should not be visible to the general + "Return a public Card matching key-value CONDITIONS, removing all columns that should not be visible to the general public. Throws a 404 if the Card doesn't exist." [& conditions] - (-> (api/check-404 (apply db/select-one [Card :id :dataset_query :description :display :name :visualization_settings], :archived false, conditions)) - remove-card-non-public-fields - params/add-card-param-values)) + (-> (api/check-404 (apply db/select-one [Card :id :dataset_query :description :display :name :visualization_settings] + :archived false, conditions)) + remove-card-non-public-columns + (hydrate :param_values :param_fields))) (defn- card-with-uuid [uuid] (public-card :public_uuid uuid)) @@ -53,16 +65,15 @@ (api/check-public-sharing-enabled) (card-with-uuid uuid)) - (defn run-query-for-card-with-id "Run the query belonging to Card with CARD-ID with PARAMETERS and other query options (e.g. `:constraints`)." + {:style/indent 2} [card-id parameters & options] - (u/prog1 (-> (let [parameters (if (string? parameters) (json/parse-string parameters keyword) parameters)] - ;; run this query with full superuser perms - (binding [api/*current-user-permissions-set* (atom #{"/"}) - qp/*allow-queries-with-no-executor-id* true] - (apply card-api/run-query-for-card card-id, :parameters parameters, :context :public-question, options))) - (u/select-nested-keys [[:data :columns :cols :rows :rows_truncated] [:json_query :parameters] :error :status])) + (u/prog1 (-> ;; run this query with full superuser perms + (binding [api/*current-user-permissions-set* (atom #{"/"}) + qp/*allow-queries-with-no-executor-id* true] + (apply card-api/run-query-for-card card-id, :parameters parameters, :context :public-question, options)) + (u/select-nested-keys [[:data :columns :cols :rows :rows_truncated] [:json_query :parameters] :error :status])) ;; if the query failed instead of returning anything about the query just return a generic error message (when (= (:status <>) :failed) (throw (ex-info "An error occurred while running the query." {:status-code 400}))))) @@ -71,7 +82,10 @@ "Run query for a *public* Card with UUID. If public sharing is not enabled, this throws an exception." [uuid parameters & options] (api/check-public-sharing-enabled) - (apply run-query-for-card-with-id (api/check-404 (db/select-one-id Card :public_uuid uuid, :archived false)) parameters options)) + (apply run-query-for-card-with-id + (api/check-404 (db/select-one-id Card :public_uuid uuid, :archived false)) + parameters + options)) (api/defendpoint GET "/card/:uuid/query" @@ -79,7 +93,7 @@ credentials. Public sharing must be enabled." [uuid parameters] {parameters (s/maybe su/JSONString)} - (run-query-for-card-with-public-uuid uuid parameters)) + (run-query-for-card-with-public-uuid uuid (json/parse-string parameters keyword))) (api/defendpoint GET "/card/:uuid/query/:export-format" "Fetch a publicly-accessible Card and return query results in the specified format. Does not require auth @@ -88,26 +102,27 @@ {parameters (s/maybe su/JSONString) export-format dataset-api/ExportFormat} (dataset-api/as-format export-format - (run-query-for-card-with-public-uuid uuid parameters, :constraints nil))) + (run-query-for-card-with-public-uuid uuid (json/parse-string parameters keyword), :constraints nil))) + + ;;; ----------------------------------------------- Public Dashboards ------------------------------------------------ (defn public-dashboard - "Return a public Dashboard matching key-value CONDITIONS, removing all fields that should not be visible to the + "Return a public Dashboard matching key-value CONDITIONS, removing all columns that should not be visible to the general public. Throws a 404 if the Dashboard doesn't exist." [& conditions] (-> (api/check-404 (apply db/select-one [Dashboard :name :description :id :parameters], :archived false, conditions)) - (hydrate [:ordered_cards :card :series]) - params/add-field-values-for-parameters + (hydrate [:ordered_cards :card :series] :param_values :param_fields) dashboard-api/add-query-average-durations (update :ordered_cards (fn [dashcards] (for [dashcard dashcards] (-> (select-keys dashcard [:id :card :card_id :dashboard_id :series :col :row :sizeX :sizeY :parameter_mappings :visualization_settings]) - (update :card remove-card-non-public-fields) + (update :card remove-card-non-public-columns) (update :series (fn [series] (for [series series] - (remove-card-non-public-fields series)))))))))) + (remove-card-non-public-columns series)))))))))) (defn- dashboard-with-uuid [uuid] (public-dashboard :public_uuid uuid)) @@ -117,20 +132,103 @@ (api/check-public-sharing-enabled) (dashboard-with-uuid uuid)) +(defn- dashboard->dashcard-param-mappings + "Get a sequence of all the `:parameter_mappings` for all the DashCards in this `dashboard-or-id`." + [dashboard-or-id] + (for [params (db/select-field :parameter_mappings DashboardCard + :dashboard_id (u/get-id dashboard-or-id)) + param params + :when (:parameter_id param)] + param)) + +(defn- matching-dashboard-param-with-target + "Find an entry in `dashboard-params` that matches `target`, if one exists. Since `dashboard-params` do not themselves + have targets they are matched via the `dashcard-param-mappings` for the Dashboard. See `resolve-params` below for + more details." + [dashboard-params dashcard-param-mappings target] + (some (fn [{id :parameter_id, :as param-mapping}] + (when (= target (:target param-mapping)) + ;; ...and once we find that, try to find a Dashboard `:parameters` + ;; entry with the same ID... + (m/find-first #(= (:id %) id) + dashboard-params))) + dashcard-param-mappings)) + +(s/defn ^:private resolve-params :- (s/maybe [{s/Keyword s/Any}]) + "Resolve the parmeters passed in to the API (`query-params`) and make sure they're actual valid parameters the + Dashboard with `dashboard-id`. This is done to prevent people from adding in parameters that aren't actually present + on the Dashboard. When successful, this will return a merged sequence based on the original `dashboard-params`, but + including the `:value` from the appropriate query-param. + + The way we pass in parameters is complicated and silly: for public Dashboards, they're passed in as JSON-encoded + parameters that look something like (when decoded): + + [{:type :category, :target [:variable [:template-tag :num]], :value \"50\"}] + + For embedded Dashboards they're simply passed in as query parameters, e.g. + + [{:num 50}] + + Thus resolving the params has to take either format into account. To further complicate matters, a Dashboard's + `:parameters` column contains values that look something like: + + [{:name \"Num\", :slug \"num\", :id \"537e37b4\", :type \"category\"} + + This is sufficient to resolve slug-style params passed in to embedded Dashboards, but URL-encoded params for public + Dashboards do not have anything that can directly match them to a Dashboard `:parameters` entry. However, they + already have enough information for the query processor to handle resolving them itself; thus we simply need to make + sure these params are actually allowed to be used on the Dashboard. To do this, we can match them against the + `:parameter_mappings` for the Dashboard's DashboardCards, which look like: + + [{:card_id 1, :target [:variable [:template-tag :num]], :parameter_id \"537e37b4\"}] + + Thus for public Dashboards JSON-encoded style we can look for a matching Dashcard parameter mapping, based on + `:target`, and then find the matching Dashboard parameter, based on `:id`. + + *Cries* + + TODO -- Tom has mentioned this, and he is very correct -- our lives would be much easier if we just used slug-style + for everything, rather than the weird JSON-encoded format we use for public Dashboards. We should fix this!" + [dashboard-id :- su/IntGreaterThanZero, query-params :- (s/maybe [{s/Keyword s/Any}])] + (when (seq query-params) + (let [dashboard-params (db/select-one-field :parameters Dashboard, :id dashboard-id) + slug->dashboard-param (u/key-by :slug dashboard-params) + dashcard-param-mappings (dashboard->dashcard-param-mappings dashboard-id)] + (for [{slug :slug, target :target, :as query-param} query-params + :let [dashboard-param + (or + ;; try to match by slug... + (slug->dashboard-param slug) + ;; ...if that fails, try to find a DashboardCard param mapping with the same target... + (matching-dashboard-param-with-target dashboard-params dashcard-param-mappings target) + ;; ...but if we *still* couldn't find a match, throw an Exception, because we don't want people + ;; trying to inject new params + (throw (Exception. (str (tru "Invalid param: {0}" slug)))))]] + (merge query-param dashboard-param))))) + +(defn- check-card-is-in-dashboard + "Check that the Card with `card-id` is in Dashboard with `dashboard-id`, either in a DashboardCard at the top level or + as a series, or throw an Exception. If not such relationship exists this will throw a 404 Exception." + [card-id dashboard-id] + (api/check-404 + (or (db/exists? DashboardCard + :dashboard_id dashboard-id + :card_id card-id) + (when-let [dashcard-ids (db/select-ids DashboardCard :dashboard_id dashboard-id)] + (db/exists? DashboardCardSeries + :card_id card-id + :dashboardcard_id [:in dashcard-ids]))))) (defn public-dashcard-results "Return the results of running a query with PARAMETERS for Card with CARD-ID belonging to Dashboard with DASHBOARD-ID. Throws a 404 if the Card isn't part of the Dashboard." [dashboard-id card-id parameters & {:keys [context] :or {context :public-dashboard}}] - (api/check-404 (or (db/exists? DashboardCard - :dashboard_id dashboard-id - :card_id card-id) - (when-let [dashcard-ids (db/select-ids DashboardCard :dashboard_id dashboard-id)] - (db/exists? DashboardCardSeries - :card_id card-id - :dashboardcard_id [:in dashcard-ids])))) - (run-query-for-card-with-id card-id parameters, :context context, :dashboard-id dashboard-id)) + (check-card-is-in-dashboard card-id dashboard-id) + (run-query-for-card-with-id card-id (resolve-params dashboard-id (if (string? parameters) + (json/parse-string parameters keyword) + parameters)) + :context context, :dashboard-id dashboard-id)) (api/defendpoint GET "/dashboard/:uuid/card/:card-id" "Fetch the results for a Card in a publicly-accessible Dashboard. Does not require auth credentials. Public @@ -138,7 +236,8 @@ [uuid card-id parameters] {parameters (s/maybe su/JSONString)} (api/check-public-sharing-enabled) - (public-dashcard-results (api/check-404 (db/select-one-id Dashboard :public_uuid uuid, :archived false)) card-id parameters)) + (public-dashcard-results + (api/check-404 (db/select-one-id Dashboard :public_uuid uuid, :archived false)) card-id parameters)) (api/defendpoint GET "/oembed" @@ -159,4 +258,180 @@ :html (embed/iframe url width height)})) +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | FieldValues, Search, Remappings | +;;; +----------------------------------------------------------------------------------------------------------------+ + +;;; -------------------------------------------------- Field Values -------------------------------------------------- + +;; TODO - this is a stupid, inefficient way of doing things. Figure out a better way to do it. :( +(defn- query->referenced-field-ids + "Get the IDs of all Fields referenced by an MBQL `query` (not including any parameters)." + [query] + (let [field-ids (atom [])] + (walk/postwalk + (fn [x] + (if (instance? metabase.query_processor.interface.Field x) + (swap! field-ids conj (:field-id x)) + x)) + (qp/expand query)) + @field-ids)) + +(defn- card->referenced-field-ids + "Return a set of all Field IDs referenced by `card`, in both the MBQL query itself and in its parameters ('template + tags')." + [card] + (set (concat (query->referenced-field-ids (:dataset_query card)) + (params/card->template-tag-field-ids card)))) + +(defn- check-field-is-referenced-by-card + "Check to make sure the query for Card with `card-id` references Field with `field-id`. Otherwise, or if the Card + cannot be found, throw an Exception." + [field-id card-id] + (let [card (api/check-404 (db/select-one [Card :dataset_query] :id card-id)) + referenced-field-ids (card->referenced-field-ids card)] + (api/check-404 (contains? referenced-field-ids field-id)))) + +(defn- check-search-field-is-allowed + "Check whether a search Field is allowed to be used in conjunction with another Field. A search Field is allowed if + *any* of the following conditions is true: + + * `search-field-id` and `field-id` are both the same Field + * `search-field-id` is equal to the other Field's Dimension's `human-readable-field-id` + * field is a `:type/PK` Field and search field is a `:type/Name` Field belonging to the same Table. + + If none of these conditions are met, you are not allowed to use the search field in combination with the other + field, and an 400 exception will be thrown." + [field-id search-field-id] + {:pre [(integer? field-id) (integer? search-field-id)]} + (api/check-400 + (or (= field-id search-field-id) + (db/exists? Dimension :field_id field-id, :human_readable_field_id search-field-id) + ;; just do a couple small queries to figure this out, we could write a fancy query to join Field against itself + ;; and do this in one but the extra code complexity isn't worth it IMO + (when-let [table-id (db/select-one-field :table_id Field :id field-id, :special_type (mdb/isa :type/PK))] + (db/exists? Field :id search-field-id, :table_id table-id, :special_type (mdb/isa :type/Name)))))) + + +(defn- check-field-is-referenced-by-dashboard + "Check that `field-id` belongs to a Field that is used as a parameter in a Dashboard with `dashboard-id`, or throw a + 404 Exception." + [field-id dashboard-id] + (let [param-field-ids (params/dashboard->param-field-ids (api/check-404 (Dashboard dashboard-id)))] + (api/check-404 (contains? param-field-ids field-id)))) + +(defn card-and-field-id->values + "Return the FieldValues for a Field with `field-id` that is referenced by Card with `card-id`." + [card-id field-id] + (check-field-is-referenced-by-card field-id card-id) + (field-api/field->values (Field field-id))) + +(api/defendpoint GET "/card/:uuid/field/:field-id/values" + "Fetch FieldValues for a Field that is referenced by a public Card." + [uuid field-id] + (api/check-public-sharing-enabled) + (let [card-id (db/select-one-id Card :public_uuid uuid, :archived false)] + (card-and-field-id->values card-id field-id))) + +(defn dashboard-and-field-id->values + "Return the FieldValues for a Field with `field-id` that is referenced by Card with `card-id` which itself is present + in Dashboard with `dashboard-id`." + [dashboard-id field-id] + (check-field-is-referenced-by-dashboard field-id dashboard-id) + (field-api/field->values (Field field-id))) + +(api/defendpoint GET "/dashboard/:uuid/field/:field-id/values" + "Fetch FieldValues for a Field that is referenced by a Card in a public Dashboard." + [uuid field-id] + (api/check-public-sharing-enabled) + (let [dashboard-id (api/check-404 (db/select-one-id Dashboard :public_uuid uuid, :archived false))] + (dashboard-and-field-id->values dashboard-id field-id))) + + +;;; --------------------------------------------------- Searching ---------------------------------------------------- + +(defn search-card-fields + "Wrapper for `metabase.api.field/search-values` for use with public/embedded Cards. See that functions + documentation for a more detailed explanation of exactly what this does." + [card-id field-id search-id value limit] + (check-field-is-referenced-by-card field-id card-id) + (check-search-field-is-allowed field-id search-id) + (field-api/search-values (Field field-id) (Field search-id) value limit)) + +(defn search-dashboard-fields + "Wrapper for `metabase.api.field/search-values` for use with public/embedded Dashboards. See that functions + documentation for a more detailed explanation of exactly what this does." + [dashboard-id field-id search-id value limit] + (check-field-is-referenced-by-dashboard field-id dashboard-id) + (check-search-field-is-allowed field-id search-id) + (field-api/search-values (Field field-id) (Field search-id) value limit)) + +(api/defendpoint GET "/card/:uuid/field/:field-id/search/:search-field-id" + "Search for values of a Field that is referenced by a public Card." + [uuid field-id search-field-id value limit] + {value su/NonBlankString + limit (s/maybe su/IntStringGreaterThanZero)} + (api/check-public-sharing-enabled) + (let [card-id (db/select-one-id Card :public_uuid uuid, :archived false)] + (search-card-fields card-id field-id search-field-id value (when limit (Integer/parseInt limit))))) + +(api/defendpoint GET "/dashboard/:uuid/field/:field-id/search/:search-field-id" + "Search for values of a Field that is referenced by a Card in a public Dashboard." + [uuid field-id search-field-id value limit] + {value su/NonBlankString + limit (s/maybe su/IntStringGreaterThanZero)} + (api/check-public-sharing-enabled) + (let [dashboard-id (api/check-404 (db/select-one-id Dashboard :public_uuid uuid, :archived false))] + (search-dashboard-fields dashboard-id field-id search-field-id value (when limit (Integer/parseInt limit))))) + + +;;; --------------------------------------------------- Remappings --------------------------------------------------- + +(defn- field-remapped-values [field-id remapped-field-id, ^String value-str] + (let [field (api/check-404 (Field field-id)) + remapped-field (api/check-404 (Field remapped-field-id))] + (check-search-field-is-allowed field-id remapped-field-id) + (field-api/remapped-value field remapped-field (field-api/parse-query-param-value-for-field field value-str)))) + +(defn card-field-remapped-values + "Return the reampped Field values for a Field referenced by a *Card*. This explanation is almost useless, so see the + one in `metabase.api.field/remapped-value` if you would actually like to understand what is going on here." + [card-id field-id remapped-field-id, ^String value-str] + (check-field-is-referenced-by-card field-id card-id) + (field-remapped-values field-id remapped-field-id value-str)) + +(defn dashboard-field-remapped-values + "Return the reampped Field values for a Field referenced by a *Dashboard*. This explanation is almost useless, so see + the one in `metabase.api.field/remapped-value` if you would actually like to understand what is going on here." + [dashboard-id field-id remapped-field-id, ^String value-str] + (check-field-is-referenced-by-dashboard field-id dashboard-id) + (field-remapped-values field-id remapped-field-id value-str)) + +(api/defendpoint GET "/card/:uuid/field/:field-id/remapping/:remapped-id" + "Fetch remapped Field values. This is the same as `GET /api/field/:id/remapping/:remapped-id`, but for use with public + Cards." + [uuid field-id remapped-id value] + {value su/NonBlankString} + (api/check-public-sharing-enabled) + (let [card-id (api/check-404 (db/select-one-id Card :public_uuid uuid, :archived false))] + (card-field-remapped-values card-id field-id remapped-id value))) + +(api/defendpoint GET "/dashboard/:uuid/field/:field-id/remapping/:remapped-id" + "Fetch remapped Field values. This is the same as `GET /api/field/:id/remapping/:remapped-id`, but for use with public + Dashboards." + [uuid field-id remapped-id value] + {value su/NonBlankString} + (api/check-public-sharing-enabled) + (let [dashboard-id (db/select-one-id Dashboard :public_uuid uuid, :archived false)] + (dashboard-field-remapped-values dashboard-id field-id remapped-id value))) + + +;;; ----------------------------------------- Route Definitions & Complaints ----------------------------------------- + +;; TODO - why don't we just make these routes have a bit of middleware that includes the +;; `api/check-public-sharing-enabled` check in each of them? That way we don't need to remember to include the line in +;; every single endpoint definition here? Wouldn't that be 100x better?! +;; +;; TODO - also a smart person would probably just parse the UUIDs automatically in middleware as appropriate for +;;`/dashboard` vs `/card` (api/define-routes) diff --git a/src/metabase/api/pulse.clj b/src/metabase/api/pulse.clj index f6106dcdad95141c591c1388597025dc47d73a50..324216bd28d74f6426bbfdb8adb02a983781c9b8 100644 --- a/src/metabase/api/pulse.clj +++ b/src/metabase/api/pulse.clj @@ -133,7 +133,8 @@ :pulse_card_html card-html :pulse_card_name (:name card) :pulse_card_url (urls/card-url (:id card)) - :row_count (:row_count result)})) + :row_count (:row_count result) + :col_count (count (:cols (:data result)))})) (api/defendpoint GET "/preview_card_png/:id" "Get PNG rendering of a `Card` with ID." diff --git a/src/metabase/api/routes.clj b/src/metabase/api/routes.clj index cd3b6d0ddd90955ecbe1994ce32bbfdab4fdc948..9ca6732deee0806b08964766a234ee3b976c3f53 100644 --- a/src/metabase/api/routes.clj +++ b/src/metabase/api/routes.clj @@ -6,6 +6,7 @@ [activity :as activity] [alert :as alert] [async :as async] + [automagic-dashboards :as magic] [card :as card] [collection :as collection] [dashboard :as dashboard] @@ -56,38 +57,39 @@ middleware/enforce-authentication) (defroutes ^{:doc "Ring routes for API endpoints."} routes - (context "/activity" [] (+auth activity/routes)) - (context "/alert" [] (+auth alert/routes)) - (context "/async" [] (+auth async/routes)) - (context "/card" [] (+auth card/routes)) - (context "/collection" [] (+auth collection/routes)) - (context "/dashboard" [] (+auth dashboard/routes)) - (context "/database" [] (+auth database/routes)) - (context "/dataset" [] (+auth dataset/routes)) - (context "/email" [] (+auth email/routes)) - (context "/embed" [] (+message-only-exceptions embed/routes)) - (context "/field" [] (+auth field/routes)) - (context "/x-ray" [] (+auth x-ray/routes)) - (context "/getting_started" [] (+auth getting-started/routes)) - (context "/geojson" [] (+auth geojson/routes)) - (context "/label" [] (+auth label/routes)) - (context "/ldap" [] (+auth ldap/routes)) - (context "/metric" [] (+auth metric/routes)) - (context "/notify" [] (+apikey notify/routes)) - (context "/permissions" [] (+auth permissions/routes)) - (context "/preview_embed" [] (+auth preview-embed/routes)) - (context "/public" [] (+generic-exceptions public/routes)) - (context "/pulse" [] (+auth pulse/routes)) - (context "/revision" [] (+auth revision/routes)) - (context "/segment" [] (+auth segment/routes)) - (context "/session" [] session/routes) - (context "/setting" [] (+auth setting/routes)) - (context "/setup" [] setup/routes) - (context "/slack" [] (+auth slack/routes)) - (context "/table" [] (+auth table/routes)) - (context "/tiles" [] (+auth tiles/routes)) - (context "/user" [] (+auth user/routes)) - (context "/util" [] util/routes) + (context "/activity" [] (+auth activity/routes)) + (context "/alert" [] (+auth alert/routes)) + (context "/async" [] (+auth async/routes)) + (context "/automagic-dashboards" [] (+auth magic/routes)) + (context "/card" [] (+auth card/routes)) + (context "/collection" [] (+auth collection/routes)) + (context "/dashboard" [] (+auth dashboard/routes)) + (context "/database" [] (+auth database/routes)) + (context "/dataset" [] (+auth dataset/routes)) + (context "/email" [] (+auth email/routes)) + (context "/embed" [] (+message-only-exceptions embed/routes)) + (context "/field" [] (+auth field/routes)) + (context "/x-ray" [] (+auth x-ray/routes)) + (context "/getting_started" [] (+auth getting-started/routes)) + (context "/geojson" [] (+auth geojson/routes)) + (context "/label" [] (+auth label/routes)) + (context "/ldap" [] (+auth ldap/routes)) + (context "/metric" [] (+auth metric/routes)) + (context "/notify" [] (+apikey notify/routes)) + (context "/permissions" [] (+auth permissions/routes)) + (context "/preview_embed" [] (+auth preview-embed/routes)) + (context "/public" [] (+generic-exceptions public/routes)) + (context "/pulse" [] (+auth pulse/routes)) + (context "/revision" [] (+auth revision/routes)) + (context "/segment" [] (+auth segment/routes)) + (context "/session" [] session/routes) + (context "/setting" [] (+auth setting/routes)) + (context "/setup" [] setup/routes) + (context "/slack" [] (+auth slack/routes)) + (context "/table" [] (+auth table/routes)) + (context "/tiles" [] (+auth tiles/routes)) + (context "/user" [] (+auth user/routes)) + (context "/util" [] util/routes) (route/not-found (fn [{:keys [request-method uri]}] {:status 404 :body (str (.toUpperCase (name request-method)) " " uri " does not exist.")}))) diff --git a/src/metabase/api/segment.clj b/src/metabase/api/segment.clj index 835ccedeff36ddaa4dc63786b2af03bc3bcc278c..f4cb352edd7a1dd14f1b1c7dd08c93a466e3a6b4 100644 --- a/src/metabase/api/segment.clj +++ b/src/metabase/api/segment.clj @@ -7,6 +7,7 @@ [revision :as revision] [segment :as segment :refer [Segment]] [table :refer [Table]]] + [metabase.related :as related] [metabase.util.schema :as su] [toucan [db :as db] @@ -82,5 +83,9 @@ :user-id api/*current-user-id* :revision-id revision_id)) +(api/defendpoint GET "/:id/related" + "Return related entities." + [id] + (-> id Segment api/read-check related/related)) (api/define-routes) diff --git a/src/metabase/api/session.clj b/src/metabase/api/session.clj index 3d313fca3ecf4ef6950665137c36f9f9b31cd247..bee43518044d31c323e328347be755e2029c1146 100644 --- a/src/metabase/api/session.clj +++ b/src/metabase/api/session.clj @@ -19,6 +19,7 @@ [metabase.util [password :as pass] [schema :as su]] + [puppetlabs.i18n.core :refer [trs tru]] [schema.core :as s] [throttle.core :as throttle] [toucan.db :as db])) @@ -41,6 +42,9 @@ ;; IP Address doesn't have an actual UI field so just show error by username :ip-address (throttle/make-throttler :username, :attempts-threshold 50)}) +(def ^:private password-fail-message (tru "Password did not match stored password.")) +(def ^:private password-fail-snippet (tru "did not match stored password")) + (defn- ldap-login "If LDAP is enabled and a matching user exists return a new Session for them, or `nil` if they couldn't be authenticated." @@ -50,12 +54,15 @@ (when-let [user-info (ldap/find-user username)] (when-not (ldap/verify-password user-info password) ;; Since LDAP knows about the user, fail here to prevent the local strategy to be tried with a possibly outdated password - (throw (ex-info "Password did not match stored password." {:status-code 400 - :errors {:password "did not match stored password"}}))) + (throw (ex-info password-fail-message + {:status-code 400 + :errors {:password password-fail-snippet}}))) ;; password is ok, return new session {:id (create-session! (ldap/fetch-or-create-user! user-info password))}) (catch com.unboundid.util.LDAPSDKException e - (log/error (u/format-color 'red "Problem connecting to LDAP server, will fallback to local authentication") (.getMessage e)))))) + (log/error + (u/format-color 'red + (trs "Problem connecting to LDAP server, will fallback to local authentication {0}" (.getMessage e)))))))) (defn- email-login "Find a matching `User` if one exists and return a new Session for them, or `nil` if they couldn't be authenticated." @@ -76,8 +83,9 @@ (email-login username password) ; Then try local authentication ;; If nothing succeeded complain about it ;; Don't leak whether the account doesn't exist or the password was incorrect - (throw (ex-info "Password did not match stored password." {:status-code 400 - :errors {:password "did not match stored password"}})))) + (throw (ex-info password-fail-message + {:status-code 400 + :errors {:password password-fail-snippet}})))) (api/defendpoint DELETE "/" @@ -146,7 +154,7 @@ ;; after a successful password update go ahead and offer the client a new session that they can use {:success true :session_id (create-session! user)}) - (api/throw-invalid-param-exception :password "Invalid reset token"))) + (api/throw-invalid-param-exception :password (tru "Invalid reset token")))) (api/defendpoint GET "/password_reset_token_valid" @@ -169,18 +177,18 @@ ;; add more 3rd-party SSO options (defsetting google-auth-client-id - "Client ID for Google Auth SSO. If this is set, Google Auth is considered to be enabled.") + (tru "Client ID for Google Auth SSO. If this is set, Google Auth is considered to be enabled.")) (defsetting google-auth-auto-create-accounts-domain - "When set, allow users to sign up on their own if their Google account email address is from this domain.") + (tru "When set, allow users to sign up on their own if their Google account email address is from this domain.")) (defn- google-auth-token-info [^String token] (let [{:keys [status body]} (http/post (str "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=" token))] (when-not (= status 200) - (throw (ex-info "Invalid Google Auth token." {:status-code 400}))) + (throw (ex-info (tru "Invalid Google Auth token.") {:status-code 400}))) (u/prog1 (json/parse-string body keyword) (when-not (= (:email_verified <>) "true") - (throw (ex-info "Email is not verified." {:status-code 400})))))) + (throw (ex-info (tru "Email is not verified.") {:status-code 400})))))) ;; TODO - are these general enough to move to `metabase.util`? (defn- email->domain ^String [email] @@ -198,7 +206,7 @@ (when-not (autocreate-user-allowed-for-email? email) ;; Use some wacky status code (428 - Precondition Required) so we will know when to so the error screen specific ;; to this situation - (throw (ex-info "You'll need an administrator to create a Metabase account before you can use Google to log in." + (throw (ex-info (tru "You''ll need an administrator to create a Metabase account before you can use Google to log in.") {:status-code 428})))) (defn- google-auth-create-new-user! [first-name last-name email] @@ -220,7 +228,7 @@ (throttle/check (login-throttlers :ip-address) remote-address) ;; Verify the token is valid with Google (let [{:keys [given_name family_name email]} (google-auth-token-info token)] - (log/info "Successfully authenticated Google Auth token for:" given_name family_name) + (log/info (trs "Successfully authenticated Google Auth token for: {0} {1}" given_name family_name)) (google-auth-fetch-or-create-user! given_name family_name email))) diff --git a/src/metabase/api/table.clj b/src/metabase/api/table.clj index 9ee39dbc73104a80997b1887fe451c4adbebf230..c8aec9886d32e79858f5573454ec4fc43f96bc1b 100644 --- a/src/metabase/api/table.clj +++ b/src/metabase/api/table.clj @@ -15,18 +15,15 @@ [field-values :refer [FieldValues] :as fv] [interface :as mi] [table :as table :refer [Table]]] + [metabase.related :as related] [metabase.sync.field-values :as sync-field-values] [metabase.util.schema :as su] [schema.core :as s] + [puppetlabs.i18n.core :refer [trs tru]] [toucan [db :as db] [hydrate :refer [hydrate]]])) -;; TODO - I don't think this is used for anything any more -(def ^:private ^:deprecated TableEntityType - "Schema for a valid table entity type." - (apply s/enum (map name table/entity-types))) - (def ^:private TableVisibilityType "Schema for a valid table visibility type." (apply s/enum (map name table/visibility-types))) @@ -54,7 +51,7 @@ [id :as {{:keys [display_name entity_type visibility_type description caveats points_of_interest show_in_getting_started], :as body} :body}] {display_name (s/maybe su/NonBlankString) - entity_type (s/maybe TableEntityType) + entity_type (s/maybe su/EntityTypeKeywordOrString) visibility_type (s/maybe TableVisibilityType) description (s/maybe su/NonBlankString) caveats (s/maybe su/NonBlankString) @@ -75,12 +72,16 @@ was-visible? (nil? original-visibility-type) became-visible? (and now-visible? (not was-visible?))] (when became-visible? - (log/info (u/format-color 'green "Table '%s' is now visible. Resyncing." (:name updated-table))) + (log/info (u/format-color 'green (trs "Table ''{0}'' is now visible. Resyncing." (:name updated-table)))) (sync/sync-table! updated-table)) updated-table))) +(def ^:private auto-bin-str (tru "Auto bin")) +(def ^:private dont-bin-str (tru "Don''t bin")) +(def ^:private day-str (tru "Day")) + (def ^:private dimension-options - (let [default-entry ["Auto bin" ["default"]]] + (let [default-entry [auto-bin-str ["default"]]] (zipmap (range) (concat (map (fn [[name param]] @@ -88,31 +89,31 @@ :mbql ["datetime-field" nil param] :type "type/DateTime"}) ;; note the order of these options corresponds to the order they will be shown to the user in the UI - [["Minute" "minute"] - ["Hour" "hour"] - ["Day" "day"] - ["Week" "week"] - ["Month" "month"] - ["Quarter" "quarter"] - ["Year" "year"] - ["Minute of Hour" "minute-of-hour"] - ["Hour of Day" "hour-of-day"] - ["Day of Week" "day-of-week"] - ["Day of Month" "day-of-month"] - ["Day of Year" "day-of-year"] - ["Week of Year" "week-of-year"] - ["Month of Year" "month-of-year"] - ["Quarter of Year" "quarter-of-year"]]) + [[(tru "Minute") "minute"] + [(tru "Hour") "hour"] + [day-str "day"] + [(tru "Week") "week"] + [(tru "Month") "month"] + [(tru "Quarter") "quarter"] + [(tru "Year") "year"] + [(tru "Minute of Hour") "minute-of-hour"] + [(tru "Hour of Day") "hour-of-day"] + [(tru "Day of Week") "day-of-week"] + [(tru "Day of Month") "day-of-month"] + [(tru "Day of Year") "day-of-year"] + [(tru "Week of Year") "week-of-year"] + [(tru "Month of Year") "month-of-year"] + [(tru "Quarter of Year") "quarter-of-year"]]) (conj (mapv (fn [[name params]] {:name name :mbql (apply vector "binning-strategy" nil params) :type "type/Number"}) [default-entry - ["10 bins" ["num-bins" 10]] - ["50 bins" ["num-bins" 50]] - ["100 bins" ["num-bins" 100]]]) - {:name "Don't bin" + [(tru "10 bins") ["num-bins" 10]] + [(tru "50 bins") ["num-bins" 50]] + [(tru "100 bins") ["num-bins" 100]]]) + {:name dont-bin-str :mbql nil :type "type/Number"}) (conj @@ -121,11 +122,11 @@ :mbql (apply vector "binning-strategy" nil params) :type "type/Coordinate"}) [default-entry - ["Bin every 1 degree" ["bin-width" 1.0]] - ["Bin every 10 degrees" ["bin-width" 10.0]] - ["Bin every 20 degrees" ["bin-width" 20.0]] - ["Bin every 50 degrees" ["bin-width" 50.0]]]) - {:name "Don't bin" + [(tru "Bin every 0.1 degrees") ["bin-width" 0.1]] + [(tru "Bin every 1 degree") ["bin-width" 1.0]] + [(tru "Bin every 10 degrees") ["bin-width" 10.0]] + [(tru "Bin every 20 degrees") ["bin-width" 20.0]]]) + {:name dont-bin-str :mbql nil :type "type/Coordinate"}))))) @@ -155,13 +156,13 @@ (pred v))) dimension-options-for-response))) (def ^:private date-default-index - (dimension-index-for-type "type/DateTime" #(= "Day" (:name %)))) + (dimension-index-for-type "type/DateTime" #(= day-str (:name %)))) (def ^:private numeric-default-index - (dimension-index-for-type "type/Number" #(.contains ^String (:name %) "Auto bin"))) + (dimension-index-for-type "type/Number" #(.contains ^String (:name %) auto-bin-str))) (def ^:private coordinate-default-index - (dimension-index-for-type "type/Coordinate" #(.contains ^String (:name %) "Auto bin"))) + (dimension-index-for-type "type/Coordinate" #(.contains ^String (:name %) auto-bin-str))) (defn- supports-numeric-binning? [driver] (and driver (contains? (driver/features driver) :binning))) @@ -212,7 +213,7 @@ field))))) (api/defendpoint GET "/:id/query_metadata" - "Get metadata about a `Table` useful for running queries. + "Get metadata about a `Table` us eful for running queries. Returns DB, fields, field FKs, and field values. By passing `include_sensitive_fields=true`, information *about* sensitive `Fields` will be returned; in no case will @@ -222,7 +223,7 @@ (let [table (api/read-check Table id) driver (driver/database-id->driver (:db_id table))] (-> table - (hydrate :db [:fields :target :dimensions :has_field_values] :segments :metrics) + (hydrate :db [:fields [:target :has_field_values] :dimensions :has_field_values] :segments :metrics) (m/dissoc-in [:db :details]) (assoc-dimension-options driver) format-fields-for-response @@ -244,8 +245,8 @@ (assoc :table_id (str "card__" card-id) :id [:field-literal (:name col) (or (:base_type col) :type/*)] - ;; don't return :special_type if it's a PK or FK because it confuses the frontend since it can't actually be - ;; used that way IRL + ;; don't return :special_type if it's a PK or FK because it confuses the frontend since it can't + ;; actually be used that way IRL :special_type (when-let [special-type (keyword (:special_type col))] (when-not (or (isa? special-type :type/PK) (isa? special-type :type/FK)) @@ -263,12 +264,15 @@ :display_name (:name card) :schema (get-in card [:collection :name] "Everything else") :description (:description card)} - include-fields? (assoc :fields (card-result-metadata->virtual-fields (u/get-id card) database_id (:result_metadata card)))))) + include-fields? (assoc :fields (card-result-metadata->virtual-fields (u/get-id card) + database_id + (:result_metadata card)))))) (api/defendpoint GET "/card__:id/query_metadata" "Return metadata for the 'virtual' table for a Card." [id] - (let [{:keys [database_id] :as card } (db/select-one [Card :id :dataset_query :result_metadata :name :description :collection_id :database_id] + (let [{:keys [database_id] :as card } (db/select-one [Card :id :dataset_query :result_metadata :name :description + :collection_id :database_id] :id id)] (-> card api/read-check @@ -315,5 +319,9 @@ (db/simple-delete! FieldValues :id [:in field-ids])) {:status :success}) +(api/defendpoint GET "/:id/related" + "Return related entities." + [id] + (-> id Table api/read-check related/related)) (api/define-routes) diff --git a/src/metabase/api/tiles.clj b/src/metabase/api/tiles.clj index 0e1537c123ba1c0a63a7b4c51aebbab21fbba852..6a242975a38332ddd7bafc1c803330652a9d9ffa 100644 --- a/src/metabase/api/tiles.clj +++ b/src/metabase/api/tiles.clj @@ -7,7 +7,8 @@ [query-processor :as qp] [util :as u]] [metabase.api.common :as api] - [metabase.util.schema :as su]) + [metabase.util.schema :as su] + [puppetlabs.i18n.core :refer [tru]]) (:import java.awt.Color java.awt.image.BufferedImage java.io.ByteArrayOutputStream @@ -97,7 +98,7 @@ (let [output-stream (ByteArrayOutputStream.)] (try (when-not (ImageIO/write tile "png" output-stream) ; returns `true` if successful -- see JavaDoc - (throw (Exception. "No approprate image writer found!"))) + (throw (Exception. (str (tru "No appropriate image writer found!"))))) (.flush output-stream) (.toByteArray output-stream) (catch Throwable e diff --git a/src/metabase/api/x_ray.clj b/src/metabase/api/x_ray.clj index 06d6820a349393782eea473e9e96126dd5874782..d344f0c4afed34f144fcf27d9d90f9e81ec0986e 100644 --- a/src/metabase/api/x_ray.clj +++ b/src/metabase/api/x_ray.clj @@ -90,21 +90,13 @@ (x-ray (max-cost max_query_cost max_computation_cost) (api/read-check Segment id))) -(defn- adhoc-query - [{:keys [database], :as query}] - (when-not (= database database/virtual-id) - (api/read-check Database database)) - (->> {:dataset_query query} - (merge (card/query->database-and-table-ids query)) - query/map->QueryInstance)) - (api/defendpoint POST "/query" "X-ray a query." [max_query_cost max_computation_cost :as {query :body}] {max_query_cost MaxQueryCost max_computation_cost MaxComputationCost} (->> query - adhoc-query + query/adhoc-query (x-ray (max-cost max_query_cost max_computation_cost)))) (api/defendpoint GET "/compare/tables/:table1-id/:table2-id" @@ -204,7 +196,7 @@ max_computation_cost MaxComputationCost} (compare (max-cost max_query_cost max_computation_cost) (api/read-check Card id) - (adhoc-query query))) + (query/adhoc-query query))) (api/defendpoint POST "/compare/table/:id/query" "Get comparison x-ray of table and ad-hoc query." @@ -213,7 +205,7 @@ max_computation_cost MaxComputationCost} (compare (max-cost max_query_cost max_computation_cost) (api/read-check Table id) - (adhoc-query query))) + (query/adhoc-query query))) (api/defendpoint POST "/compare/segment/:id/query" "Get comparison x-ray of segment and ad-hoc query." @@ -222,6 +214,6 @@ max_computation_cost MaxComputationCost} (compare (max-cost max_query_cost max_computation_cost) (api/read-check Segment id) - (adhoc-query query))) + (query/adhoc-query query))) (api/define-routes) diff --git a/src/metabase/automagic_dashboards/comparison.clj b/src/metabase/automagic_dashboards/comparison.clj new file mode 100644 index 0000000000000000000000000000000000000000..dba1f352eb9f4278d5471e4a39b8b60a89da036e --- /dev/null +++ b/src/metabase/automagic_dashboards/comparison.clj @@ -0,0 +1,119 @@ +(ns metabase.automagic-dashboards.comparison + (:require [clojure.string :as str] + [metabase.api.common :as api] + [metabase.automagic-dashboards + [populate :as populate]])) + +;; (defn- dashboard->cards +;; [dashboard] +;; (->> dashboard +;; :ordered_cards +;; (map (fn [{:keys [sizeY card col row series]}] +;; (assoc card +;; :series series +;; :height sizeY +;; :position (+ (* row populate/grid-width) col)))) +;; (sort-by :position))) + +;; (defn- clone-card +;; [card] +;; (-> card +;; (select-keys [:dataset_query :description :display :name :result_metadata +;; :visualization_settings]) +;; (assoc :creator_id api/*current-user-id* +;; :collection_id (:id (populate/automagic-collection)) +;; :id (gensym)))) + +;; (defn- overlay-comparison? +;; [card] +;; (and (-> card :display name (#{"bar" "line"})) +;; (-> card :series empty?))) + +;; (defn- place-row +;; [dashboard row left right] +;; (let [height (:height left) +;; card-left (clone-card left) +;; card-right (clone-card right)] +;; (if (overlay-comparison? left) +;; (update dashboard :ordered_cards conj {:col 0 +;; :row row +;; :sizeX populate/grid-width +;; :sizeY height +;; :card card-left +;; :card_id (:id card-left) +;; :series [card-right] +;; :visualization_settings {} +;; :id (gensym)}) +;; (let [width (/ populate/grid-width 2) +;; series-left (map clone-card (:series left)) +;; series-right (map clone-card (:series right))] +;; (-> dashboard +;; (update :ordered_cards conj {:col 0 +;; :row row +;; :sizeX width +;; :sizeY height +;; :card card-left +;; :card_id (:id card-left) +;; :series series-left +;; :visualization_settings {} +;; :id (gensym)}) +;; (update :ordered_cards conj {:col width +;; :row row +;; :sizeX width +;; :sizeY height +;; :card card-right +;; :card_id (:id card-right) +;; :series series-right +;; :visualization_settings {} +;; :id (gensym)})))))) + +;; (def ^:private ^Long ^:const title-height 2) + +;; (defn- add-col-title +;; [dashboard title col] +;; (populate/add-text-card dashboard {:text (format "# %s" title) +;; :width (/ populate/grid-width 2) +;; :height title-height} +;; [0 col])) + +;; (defn- unroll-multiseries +;; [card] +;; (if (and (-> card :display name (= "line")) +;; (-> card :dataset_query :query :aggregation count (> 1))) +;; (for [aggregation (-> card :dataset_query :query :aggregation)] +;; (assoc-in card [:dataset_query :query :aggregation] [aggregation])) +;; [card])) + +;; (defn- inject-segment +;; "Inject filter clause into card." +;; [query-filter card] +;; (-> card +;; (update-in [:dataset_query :query :filter] merge-filters query-filter) +;; (update :series (partial map (partial inject-segment query-filter))))) + +(defn comparison-dashboard + "Create a comparison dashboard based on dashboard `dashboard` comparing subsets of + the dataset defined by segments `left` and `right`." + [dashboard left right] + ;; (->> dashboard + ;; dashboard->cards + ;; (mapcat unroll-multiseries) + ;; (map (juxt (partial inject-segment left) + ;; (partial inject-segment right))) + ;; (reduce (fn [[dashboard row] [left right]] + ;; [(place-row dashboard row left right) + ;; (+ row (:height left))]) + ;; [(-> {:name (format "Comparison of %s and %s" + ;; (full-name left) + ;; (full-name right)) + ;; :description (format "Automatically generated comparison dashboard comparing %s and %s" + ;; (full-name left) + ;; (full-name right)) + ;; :creator_id api/*current-user-id* + ;; :parameters []} + ;; (add-col-title (-> left full-name str/capitalize) 0) + ;; (add-col-title (-> right full-name str/capitalize) + ;; (/ populate/grid-width 2))) + ;; title-height]) + ;; first) + ) diff --git a/src/metabase/automagic_dashboards/core.clj b/src/metabase/automagic_dashboards/core.clj new file mode 100644 index 0000000000000000000000000000000000000000..861cc111e8157d2fa983c9b3fe7e2b9f8b649da6 --- /dev/null +++ b/src/metabase/automagic_dashboards/core.clj @@ -0,0 +1,801 @@ +(ns metabase.automagic-dashboards.core + "Automatically generate questions and dashboards based on predefined + heuristics." + (:require [buddy.core.codecs :as codecs] + [cheshire.core :as json] + [clj-time + [core :as t] + [format :as t.format]] + [clojure.math.combinatorics :as combo] + [clojure.string :as str] + [clojure.tools.logging :as log] + [clojure.walk :as walk] + [kixi.stats + [core :as stats] + [math :as math]] + [medley.core :as m] + [metabase.automagic-dashboards + [populate :as populate] + [rules :as rules]] + [metabase.models + [card :as card :refer [Card]] + [field :refer [Field] :as field] + [interface :as mi] + [metric :refer [Metric]] + [query :refer [Query]] + [segment :refer [Segment]] + [table :refer [Table]]] + [metabase.query-processor.middleware.expand-macros :refer [merge-filter-clauses]] + [metabase.query-processor.util :as qp.util] + [metabase.related :as related] + [metabase.sync.analyze.classify :as classify] + [metabase.util :as u] + [puppetlabs.i18n.core :as i18n :refer [tru]] + [ring.util.codec :as codec] + [toucan.db :as db])) + +(def ^:private public-endpoint "/auto/dashboard/") + +(defmulti + ^{:private true + :arglists '([entity])} + ->root type) + +(defmethod ->root (type Table) + [table] + {:entity table + :full-name (if (isa? (:entity_type table) :entity/GoogleAnalyticsTable) + (:display_name table) + (str (:display_name table) (tru " table"))) + :source table + :database (:db_id table) + :url (format "%stable/%s" public-endpoint (u/get-id table)) + :rules-prefix "table"}) + +(defmethod ->root (type Segment) + [segment] + (let [table (-> segment :table_id Table) ] + {:entity segment + :full-name (str (:name segment) (tru " segment")) + :source table + :database (:db_id table) + :query-filter (-> segment :definition :filter) + :url (format "%ssegment/%s" public-endpoint (u/get-id segment)) + :rules-prefix "table"})) + +(defmethod ->root (type Metric) + [metric] + (let [table (-> metric :table_id Table)] + {:entity metric + :full-name (str (:name metric) (tru " metric")) + :source table + :database (:db_id table) + :url (format "%smetric/%s" public-endpoint (u/get-id metric)) + :rules-prefix "metric"})) + +(defmethod ->root (type Field) + [field] + (let [table (field/table field)] + {:entity field + :full-name (str (:display_name field) (tru " field")) + :source table + :database (:db_id table) + :url (format "%sfield/%s" public-endpoint (u/get-id field)) + :rules-prefix "field"})) + +(defmulti + ^{:doc "Get a reference for a given model to be injected into a template + (either MBQL, native query, or string)." + :arglists '([template-type model]) + :private true} + ->reference (fn [template-type model] + [template-type (type model)])) + +(defn- optimal-datetime-resolution + [field] + (if-let [[earliest latest] (some->> field + :fingerprint + :type + :type/DateTime + ((juxt :earliest :latest)) + (map t.format/parse))] + (condp > (t/in-hours (t/interval earliest latest)) + 3 :minute + (* 24 7) :hour + (* 24 30 6) :day + (* 24 30 12 10) :month + :year) + :day)) + +(defmethod ->reference [:mbql (type Field)] + [_ {:keys [fk_target_field_id id link aggregation base_type fingerprint name base_type] :as field}] + (let [reference (cond + link [:fk-> link id] + fk_target_field_id [:fk-> id fk_target_field_id] + id [:field-id id] + :else [:field-literal name base_type])] + (cond + (isa? base_type :type/DateTime) + [:datetime-field reference (or aggregation + (optimal-datetime-resolution field))] + + (and aggregation + ; We don't handle binning on non-analyzed fields gracefully + (-> fingerprint :type :type/Number :min)) + [:binning-strategy reference aggregation] + + :else + reference))) + +(defmethod ->reference [:string (type Field)] + [_ {:keys [display_name full-name]}] + (or full-name display_name)) + +(defmethod ->reference [:string (type Table)] + [_ {:keys [display_name full-name]}] + (or full-name display_name)) + +(defmethod ->reference [:string (type Metric)] + [_ {:keys [name full-name]}] + (or full-name name)) + +(defmethod ->reference [:mbql (type Metric)] + [_ {:keys [id]}] + ["METRIC" id]) + +(defmethod ->reference [:native (type Field)] + [_ field] + (field/qualified-name field)) + +(defmethod ->reference [:native (type Table)] + [_ {:keys [name]}] + name) + +(defmethod ->reference :default + [_ form] + (or (cond-> form + (map? form) ((some-fn :full-name :name) form)) + form)) + +(defn- field-isa? + [{:keys [base_type special_type]} t] + (or (isa? (keyword special_type) t) + (isa? (keyword base_type) t))) + +(defn- key-col? + "Workaround for our leaky type system which conflates types with properties." + [{:keys [base_type special_type name]}] + (and (isa? base_type :type/Number) + (or (#{:type/PK :type/FK} special_type) + (let [name (str/lower-case name)] + (or (= name "id") + (str/starts-with? name "id_") + (str/ends-with? name "_id")))))) + +(def ^:private field-filters + {:fieldspec (fn [fieldspec] + (if (and (string? fieldspec) + (rules/ga-dimension? fieldspec)) + (comp #{fieldspec} :name) + (fn [{:keys [special_type target] :as field}] + (cond + ;; This case is mostly relevant for native queries + (#{:type/PK :type/FK} fieldspec) + (isa? special_type fieldspec) + + target + (recur target) + + :else + (and (not (key-col? field)) + (field-isa? field fieldspec)))))) + :named (fn [name-pattern] + (comp (->> name-pattern + str/lower-case + re-pattern + (partial re-find)) + str/lower-case + :name)) + :max-cardinality (fn [cardinality] + (fn [field] + (some-> field + (get-in [:fingerprint :global :distinct-count]) + (<= cardinality))))}) + +(defn- filter-fields + "Find all fields belonging to table `table` for which all predicates in + `preds` are true." + [preds fields] + (filter (->> preds + (keep (fn [[k v]] + (when-let [pred (field-filters k)] + (some-> v pred)))) + (apply every-pred)) + fields)) + +(defn- filter-tables + [tablespec tables] + (filter #(-> % :entity_type (isa? tablespec)) tables)) + +(defn- fill-templates + [template-type context bindings form] + (walk/postwalk + (fn [form] + (if (string? form) + (str/replace form #"\[\[(\w+)\]\]" + (fn [[_ identifier]] + (->reference template-type (or (-> identifier + ((merge {"this" (-> context :root :entity)} + bindings))) + (-> identifier + rules/->entity + (filter-tables (:tables context)) + first) + identifier)))) + form)) + form)) + +(defn- field-candidates + [context {:keys [field_type links_to named max_cardinality] :as constraints}] + (if links_to + (filter (comp (->> (filter-tables links_to (:tables context)) + (keep :link) + set) + u/get-id) + (field-candidates context (dissoc constraints :links_to))) + (let [[tablespec fieldspec] field_type] + (if fieldspec + (mapcat (fn [table] + (some->> table + :fields + (filter-fields {:fieldspec fieldspec + :named named + :max-cardinality max_cardinality}) + (map #(assoc % :link (:link table))))) + (filter-tables tablespec (:tables context))) + (filter-fields {:fieldspec tablespec + :named named + :max-cardinality max_cardinality} + (-> context :source :fields)))))) + +(defn- make-binding + [context [identifier definition]] + (->> definition + (field-candidates context) + (map #(->> (merge % definition) + vector ; we wrap these in a vector to make merging easier (see `bind-dimensions`) + (assoc definition :matches) + (hash-map (name identifier)))))) + +(def ^:private ^{:arglists '([definitions])} most-specific-definition + "Return the most specific defintion among `definitions`. + Specificity is determined based on: + 1) how many ancestors `field_type` has (if field_type has a table prefix, + ancestors for both table and field are counted); + 2) if there is a tie, how many additional filters (`named`, `max_cardinality`, + `links_to`, ...) are used; + 3) if there is still a tie, `score`." + (comp last (partial sort-by (comp (fn [[_ definition]] + [(transduce (map (comp count ancestors)) + + + (:field_type definition)) + (count definition) + (:score definition)]) + first)))) + +(defn- bind-dimensions + "Bind fields to dimensions and resolve overloading. + Each field will be bound to only one dimension. If multiple dimension definitions + match a single field, the field is bound to the most specific definition used + (see `most-specific-defintion` for details)." + [context dimensions] + (->> dimensions + (mapcat (comp (partial make-binding context) first)) + (group-by (comp (some-fn :id :name) first :matches val first)) + (map (comp most-specific-definition val)) + (apply merge-with (fn [a b] + (case (compare (:score a) (:score b)) + 1 a + 0 (update a :matches concat (:matches b)) + -1 b)) + {}))) + +(defn- build-order-by + [dimensions metrics order-by] + (let [dimensions (set dimensions)] + (for [[identifier ordering] (map first order-by)] + [(if (dimensions identifier) + [:dimension identifier] + [:aggregation (u/index-of #{identifier} metrics)]) + (if (= ordering "ascending") + :ascending + :descending)]))) + +(defn- build-query + ([context bindings filters metrics dimensions limit order_by] + (walk/postwalk + (fn [subform] + (if (rules/dimension-form? subform) + (let [[_ identifier opts] subform] + (->reference :mbql (-> identifier bindings (merge opts)))) + subform)) + {:type :query + :database (-> context :root :database) + :query (cond-> {:source_table (if (->> context :source (instance? (type Table))) + (-> context :source u/get-id) + (->> context :source u/get-id (str "card__")))} + (not-empty filters) + (assoc :filter (transduce (map :filter) + merge-filter-clauses + filters)) + + (not-empty dimensions) + (assoc :breakout dimensions) + + (not-empty metrics) + (assoc :aggregation (map :metric metrics)) + + limit + (assoc :limit limit) + + (not-empty order_by) + (assoc :order_by order_by))})) + ([context bindings query] + {:type :native + :native {:query (fill-templates :native context bindings query)} + :database (-> context :root :database)})) + +(defn- has-matches? + [dimensions definition] + (->> definition + rules/collect-dimensions + (every? (partial get dimensions)))) + +(defn- resolve-overloading + "Find the overloaded definition with the highest `score` for which all + referenced dimensions have at least one matching field." + [{:keys [dimensions]} definitions] + (apply merge-with (fn [a b] + (case (map (partial has-matches? dimensions) [a b]) + [true false] a + [false true] b + (max-key :score a b))) + definitions)) + +(defn- instantate-visualization + [[k v] dimensions metrics] + (let [dimension->name (comp vector :name dimensions) + metric->name (comp vector first :metric metrics)] + [k (-> v + (u/update-when :map.latitude_column dimension->name) + (u/update-when :map.longitude_column dimension->name) + (u/update-when :graph.metrics metric->name) + (u/update-when :graph.dimensions dimension->name))])) + +(defn- instantiate-metadata + [x context bindings] + (-> (fill-templates :string context bindings x) + (u/update-when :visualization #(instantate-visualization % bindings + (:metrics context))))) + +(defn- card-candidates + "Generate all potential cards given a card definition and bindings for + dimensions, metrics, and filters." + [context {:keys [metrics filters dimensions score limit order_by query] :as card}] + (let [order_by (build-order-by dimensions metrics order_by) + metrics (map (partial get (:metrics context)) metrics) + filters (cond-> (map (partial get (:filters context)) filters) + (:query-filter context) + (conj {:filter (:query-filter context)})) + score (if query + score + (* (or (->> dimensions + (map (partial get (:dimensions context))) + (concat filters metrics) + (transduce (keep :score) stats/mean)) + rules/max-score) + (/ score rules/max-score))) + dimensions (map (comp (partial into [:dimension]) first) dimensions) + used-dimensions (rules/collect-dimensions [dimensions metrics filters query])] + (->> used-dimensions + (map (some-fn #(get-in (:dimensions context) [% :matches]) + (comp #(filter-tables % (:tables context)) rules/->entity))) + (apply combo/cartesian-product) + (map (fn [instantiations] + (let [bindings (zipmap used-dimensions instantiations) + query (if query + (build-query context bindings query) + (build-query context bindings + filters + metrics + dimensions + limit + order_by))] + (-> card + (assoc :metrics metrics) + (instantiate-metadata context (->> metrics + (map :name) + (zipmap (:metrics card)) + (merge bindings))) + (assoc :score score + :dataset_query query)))))))) + +(def ^:private ^{:arglists '([rule])} rule-specificity + (comp (partial transduce (map (comp count ancestors)) +) :applies_to)) + +(defn- matching-rules + "Return matching rules orderd by specificity. + Most specific is defined as entity type specification the longest ancestor + chain." + [rules {:keys [source entity]}] + (let [table-type (or (:entity_type source) :entity/GenericTable)] + (->> rules + (filter (fn [{:keys [applies_to]}] + (let [[entity-type field-type] applies_to] + (and (isa? table-type entity-type) + (or (nil? field-type) + (field-isa? entity field-type)))))) + (sort-by rule-specificity >)))) + +(defn- linked-tables + "Return all tables accessable from a given table with the paths to get there. + If there are multiple FKs pointing to the same table, multiple entries will + be returned." + [table] + (for [{:keys [id target]} (field/with-targets + (db/select Field + :table_id (u/get-id table) + :fk_target_field_id [:not= nil])) + :when (some-> target mi/can-read?)] + (-> target field/table (assoc :link id)))) + +(defmulti + ^{:private true + :arglists '([context entity])} + inject-root (fn [_ entity] (type entity))) + +(defmethod inject-root (type Field) + [context field] + (update context :dimensions + (fn [dimensions] + (->> dimensions + (keep (fn [[identifier definition]] + (when-let [matches (->> definition + :matches + (remove (comp #{(u/get-id field)} u/get-id)) + not-empty)] + [identifier (assoc definition :matches matches)]))) + (concat [["this" {:matches [field] + :score rules/max-score}]]) + (into {}))))) + +(defmethod inject-root (type Metric) + [context metric] + (update context :metrics assoc "this" {:metric (->reference :mbql metric) + :name (:name metric) + :score rules/max-score})) + +(defmethod inject-root :default + [context _] + context) + +(defn- make-context + [root rule] + {:pre [(:source root)]} + (let [source (:source root) + tables (concat [source] (when (instance? (type Table) source) + (linked-tables source))) + table->fields (if (instance? (type Table) source) + (comp (->> (db/select Field + :table_id [:in (map u/get-id tables)] + :visibility_type "normal") + field/with-targets + (group-by :table_id)) + u/get-id) + (->> source + :result_metadata + (map (fn [field] + (-> field + (update :base_type keyword) + (update :special_type keyword) + field/map->FieldInstance + (classify/run-classifiers {})))) + constantly))] + (as-> {:source (assoc source :fields (table->fields source)) + :root root + :tables (map #(assoc % :fields (table->fields %)) tables) + :query-filter (merge-filter-clauses (:query-filter root) + (:cell-query root))} context + (assoc context :dimensions (bind-dimensions context (:dimensions rule))) + (assoc context :metrics (resolve-overloading context (:metrics rule))) + (assoc context :filters (resolve-overloading context (:filters rule))) + (inject-root context (:entity root))))) + +(defn- make-cards + [context {:keys [cards]}] + (some->> cards + (map first) + (map-indexed (fn [position [identifier card]] + (some->> (assoc card :position position) + (card-candidates context) + not-empty + (hash-map (name identifier))))) + (apply merge-with (partial max-key (comp :score first)) {}) + vals + (apply concat))) + +(defn- make-dashboard + ([root rule] + (make-dashboard root rule {:tables [(:source root)]})) + ([root rule context] + (let [this {"this" (-> root + :entity + (assoc :full-name (:full-name root)))}] + (-> rule + (select-keys [:title :description :transient_title :groups]) + (update :title (partial fill-templates :string context this)) + (update :description (partial fill-templates :string context this)) + (update :transient_title (partial fill-templates :string context this)) + (u/update-when :short_title (partial fill-templates :string context this)) + (update :groups (partial fill-templates :string context {})) + (assoc :refinements (:cell-query root)))))) + +(defn- apply-rule + [root rule] + (let [context (make-context root rule) + dashboard (make-dashboard root rule context) + filters (->> rule + :dashboard_filters + (mapcat (comp :matches (:dimensions context)))) + cards (make-cards context rule)] + (when cards + [(assoc dashboard + :filters filters + :cards cards + :context context + :fieldset (->> context + :tables + (mapcat :fields) + (map (fn [field] + [((some-fn :id :name) field) field])) + (into {}))) + rule]))) + +(def ^:private ^:const ^Long max-related 6) +(def ^:private ^:const ^Long max-cards 15) + +(defn- ->related-entity + [entity] + (let [root (->root entity) + rule (->> root + (matching-rules (rules/get-rules [(:rules-prefix root)])) + first) + dashboard (make-dashboard root rule)] + {:url (:url root) + :title (:full-name root) + :description (:description dashboard)})) + +(defn- others + ([root] (others max-related root)) + ([n root] + (let [recommendations (-> root :entity related/related)] + (->> (reduce (fn [acc selector] + (concat acc (-> selector recommendations rules/ensure-seq))) + [] + [:table :segments :metrics :linking-to :linked-from :tables + :fields]) + (take n) + (map ->related-entity))))) + +(defn- indepth + [root rule] + (->> (rules/get-rules [(:rules-prefix root) (:rule rule)]) + (keep (fn [indepth] + (when-let [[dashboard _] (apply-rule root indepth)] + {:title ((some-fn :short-title :title) dashboard) + :description (:description dashboard) + :url (format "%s/rule/%s/%s" (:url root) (:rule rule) + (:rule indepth))}))) + (take max-related))) + +(defn- related + [root rule] + (let [indepth (indepth root rule)] + {:indepth indepth + :tables (take (- max-related (count indepth)) (others root))})) + +(defn- automagic-dashboard + "Create dashboards for table `root` using the best matching heuristics." + [{:keys [rule show rules-prefix query-filter cell-query full-name] :as root}] + (when-let [[dashboard rule] (if rule + (apply-rule root (rules/get-rule rule)) + (->> root + (matching-rules (rules/get-rules [rules-prefix])) + (keep (partial apply-rule root)) + ;; `matching-rules` returns an `ArraySeq` (via `sort-by`) so + ;; `first` realises one element at a time (no chunking). + first))] + (log/info (format "Applying heuristic %s to %s." (:rule rule) full-name)) + (log/info (format "Dimensions bindings:\n%s" + (->> dashboard + :context + :dimensions + (m/map-vals #(update % :matches (partial map :name))) + u/pprint-to-str))) + (log/info (format "Using definitions:\nMetrics:\n%s\nFilters:\n%s" + (-> dashboard :context :metrics u/pprint-to-str) + (-> dashboard :context :filters u/pprint-to-str))) + (-> (cond-> dashboard + (or query-filter cell-query) + (assoc :title (str (tru "A closer look at ") full-name))) + (populate/create-dashboard (or show max-cards)) + (assoc :related (-> (related root rule) + (assoc :more (if (and (-> dashboard + :cards + count + (> max-cards)) + (not= show :all)) + [{:title (tru "Show more about this") + :description nil + :table (:source root) + :url (format "%s#show=all" + (:url root))}] + []))))))) + +(def ^:private ^{:arglists '([card])} table-like? + (comp empty? #(qp.util/get-in-normalized % [:dataset_query :query :aggregation]))) + +(defmulti + ^{:doc "Create a transient dashboard analyzing given entity." + :arglists '([entity opts])} + automagic-analysis (fn [entity _] + (type entity))) + +(defmethod automagic-analysis (type Table) + [table opts] + (automagic-dashboard (merge opts (->root table)))) + +(defmethod automagic-analysis (type Segment) + [segment opts] + (automagic-dashboard (merge opts (->root segment)))) + +(defmethod automagic-analysis (type Metric) + [metric opts] + (automagic-dashboard (merge opts (->root metric)))) + +(def ^:private ^{:arglists '([x])} encode-base64-json + (comp codec/base64-encode codecs/str->bytes json/encode)) + +(def ^:private ^{:arglists '([card-or-question])} nested-query? + (comp (every-pred string? #(str/starts-with? % "card__")) + #(qp.util/get-in-normalized % [:dataset_query :query :source_table]))) + +(def ^:private ^{:arglists '([card-or-question])} native-query? + (comp #{:native} qp.util/normalize-token #(qp.util/get-in-normalized % [:dataset_query :type]))) + +(def ^:private ^{:arglists '([card-or-question])} source-question + (comp Card #(Integer/parseInt %) second #(str/split % #"__") + #(qp.util/get-in-normalized % [:dataset_query :query :source_table]))) + +(defmethod automagic-analysis (type Card) + [card {:keys [cell-query] :as opts}] + (if (or (table-like? card) + cell-query) + (let [source (cond + (nested-query? card) (-> card + source-question + (assoc :entity_type :entity/GenericTable)) + (native-query? card) (-> card (assoc :entity_type :entity/GenericTable)) + :else (-> card :table_id Table))] + (automagic-dashboard + (merge {:entity source + :full-name (str (:name card) (tru " question")) + :source source + :query-filter (qp.util/get-in-normalized card [:dataset_query :query :filter]) + :database (:database_id card) + :url (if cell-query + (format "%squestion/%s/cell/%s" public-endpoint + (u/get-id card) + (encode-base64-json cell-query)) + (format "%squestion/%s" public-endpoint (u/get-id card))) + :rules-prefix "table"} + opts))) + nil)) + +(defmethod automagic-analysis (type Query) + [query {:keys [cell-query] :as opts}] + (if (or (table-like? query) + (:cell-query opts)) + (let [source (cond + (nested-query? query) (-> query + source-question + (assoc :entity_type :entity/GenericTable)) + (native-query? query) (-> query (assoc :entity_type :entity/GenericTable)) + :else (-> query :table-id Table))] + (automagic-dashboard + (merge {:entity source + :full-name (cond + (nested-query? query) + (str (:name source) (tru " question")) + + (isa? (:entity_type source) :entity/GoogleAnalyitcsTable) + (:display_name source) + + :else + (str (:display_name source) (tru " table"))) + :source source + :database (:database-id query) + :url (if cell-query + (format "%sadhoc/%s/cell/%s" public-endpoint + (encode-base64-json (:dataset_query query)) + (encode-base64-json cell-query)) + (format "%sadhoc/%s" public-endpoint + (encode-base64-json query))) + :rules-prefix "table"} + (update opts :cell-query merge-filter-clauses + (qp.util/get-in-normalized query [:dataset_query :query :filter]))))) + nil)) + +(defmethod automagic-analysis (type Field) + [field opts] + (automagic-dashboard (merge opts (->root field)))) + +(defn- enhanced-table-stats + [table] + (let [field-types (->> (db/select [Field :special_type] :table_id (u/get-id table)) + (map :special_type))] + (assoc table :stats {:num-fields (count field-types) + :list-like? (= (count (remove #{:type/PK} field-types)) 1) + :link-table? (every? #{:type/FK :type/PK} field-types)}))) + +(def ^:private ^:const ^Long max-candidate-tables + "Maximal number of tables shown per schema." + 10) + +(defn candidate-tables + "Return a list of tables in database with ID `database-id` for which it makes sense + to generate an automagic dashboard. Results are grouped by schema and ranked + acording to interestingness (both schemas and tables within each schema). Each + schema contains up to `max-candidate-tables` tables. + + Tables are ranked based on how specific rule has been used, and the number of + fields. + Schemes are ranked based on the number of distinct entity types and the + interestingness of tables they contain (see above)." + ([database] (candidate-tables database nil)) + ([database schema] + (let [rules (rules/get-rules ["table"])] + (->> (apply db/select Table + (cond-> [:db_id (u/get-id database) + :visibility_type nil] + schema (concat [:schema schema]))) + (filter mi/can-read?) + (map enhanced-table-stats) + (remove (comp (some-fn :link-table? :list-like?) :stats)) + (map (fn [table] + (let [root (->root table) + rule (->> root + (matching-rules rules) + first) + dashboard (make-dashboard root rule)] + {:url (format "%stable/%s" public-endpoint (u/get-id table)) + :title (:full-name root) + :score (+ (math/sq (rule-specificity rule)) + (math/log (-> table :stats :num-fields))) + :description (:description dashboard) + :table table + :rule (:rule rule)}))) + (group-by (comp :schema :table)) + (map (fn [[schema tables]] + (let [tables (->> tables + (sort-by :score >) + (take max-candidate-tables))] + {:tables tables + :schema schema + :score (+ (math/sq (transduce (m/distinct-by :rule) + stats/count + tables)) + (math/sqrt (transduce (map (comp math/sq :score)) + stats/mean + tables)))}))) + (sort-by :score >))))) diff --git a/src/metabase/automagic_dashboards/filters.clj b/src/metabase/automagic_dashboards/filters.clj new file mode 100644 index 0000000000000000000000000000000000000000..fd4182983c52b774e7652419fc1fa3511c74d57c --- /dev/null +++ b/src/metabase/automagic_dashboards/filters.clj @@ -0,0 +1,325 @@ +(ns metabase.automagic-dashboards.filters + (:require [clojure.string :as str] + [clj-time.format :as t.format] + [metabase.models + [field :refer [Field] :as field] + [table :refer [Table]]] + [metabase.query-processor.util :as qp.util] + [metabase.util :as u] + [metabase.util.schema :as su] + [schema.core :as s] + [toucan.db :as db])) + +(def ^:private FieldReference + [(s/one (s/constrained su/KeywordOrString + (comp #{:field-id :fk-> :field-literal} qp.util/normalize-token)) + "head") + s/Any]) + +(def ^:private ^{:arglists '([form])} field-reference? + "Is given form an MBQL field reference?" + (complement (s/checker FieldReference))) + +(defmulti + ^{:doc "Extract field ID from a given field reference form." + :arglists '([op & args])} + field-reference->id (comp qp.util/normalize-token first)) + +(defmethod field-reference->id :field-id + [[_ id]] + id) + +(defmethod field-reference->id :fk-> + [[_ _ id]] + id) + +(defmethod field-reference->id :field-literal + [[_ name _]] + name) + +(defn collect-field-references + "Collect all field references (`[:field-id]` or `[:fk->]` forms) from a given form." + [form] + (->> form + (tree-seq (some-fn sequential? map?) identity) + (filter field-reference?))) + +(def ^:private ^{:arglists '([field])} periodic-datetime? + (comp #{:minute-of-hour :hour-of-day :day-of-week :day-of-month :day-of-year :week-of-year + :month-of-year :quarter-of-year} + :unit)) + +(defn- datetime? + [field] + (and (not (periodic-datetime? field)) + (or (isa? (:base_type field) :type/DateTime) + (field/unix-timestamp? field)))) + +(defn- candidates-for-filtering + [fieldset cards] + (->> cards + (mapcat collect-field-references) + (map field-reference->id) + distinct + (map fieldset) + (filter (fn [{:keys [special_type] :as field}] + (or (datetime? field) + (isa? special_type :type/Category)))))) + +(defn- build-fk-map + [fks field] + (if (:id field) + (->> fks + (filter (comp #{(:table_id field)} :table_id :target)) + (group-by :table_id) + (keep (fn [[_ [fk & fks]]] + ;; Bail out if there is more than one FK from the same table + (when (empty? fks) + [(:table_id fk) [:fk-> (u/get-id fk) (u/get-id field)]]))) + (into {(:table_id field) [:field-id (u/get-id field)]})) + (constantly [:field-literal (:name field) (:base_type field)]))) + +(defn- filter-for-card + [card field] + (some->> ((:fk-map field) (:table_id card)) + (vector :dimension))) + +(defn- add-filter + [dashcard filter-id field] + (let [mappings (->> (conj (:series dashcard) (:card dashcard)) + (keep (fn [card] + (when-let [target (filter-for-card card field)] + {:parameter_id filter-id + :target target + :card_id (:id card)}))) + not-empty)] + (cond + (nil? (:card dashcard)) dashcard + mappings (update dashcard :parameter_mappings concat mappings)))) + +(defn- filter-type + "Return filter type for a given field." + [{:keys [base_type special_type] :as field}] + (cond + (datetime? field) "date/all-options" + (isa? special_type :type/State) "location/state" + (isa? special_type :type/Country) "location/country" + (isa? special_type :type/Category) "category")) + +(defn- score + [{:keys [base_type special_type fingerprint] :as field}] + (cond-> 0 + (some-> fingerprint :global :distinct-count (< 10)) inc + (some-> fingerprint :global :distinct-count (> 20)) dec + ((descendants :type/Category) special_type) inc + (field/unix-timestamp? field) inc + (isa? base_type :type/DateTime) inc + ((descendants :type/DateTime) special_type) inc + (isa? special_type :type/CreationTimestamp) inc + (#{:type/State :type/Country} special_type) inc)) + +(def ^:private ^{:arglists '([dimensions])} remove-unqualified + (partial remove (fn [{:keys [fingerprint]}] + (some-> fingerprint :global :distinct-count (< 2))))) + +(defn add-filters + "Add up to `max-filters` filters to dashboard `dashboard`. Takes an optional + argument `dimensions` which is a list of fields for which to create filters, else + it tries to infer by which fields it would be useful to filter." + ([dashboard max-filters] + (->> dashboard + :orderd_cards + (candidates-for-filtering (:fieldset dashboard)) + (add-filters dashboard max-filters))) + ([dashboard dimensions max-filters] + (let [fks (->> (db/select Field + :fk_target_field_id [:not= nil] + :table_id [:in (keep (comp :table_id :card) (:ordered_cards dashboard))]) + field/with-targets)] + (->> dimensions + remove-unqualified + (sort-by score >) + (take max-filters) + (map #(assoc % :fk-map (build-fk-map fks %))) + (reduce + (fn [dashboard candidate] + (let [filter-id (-> candidate hash str) + dashcards (:ordered_cards dashboard) + dashcards-new (map #(add-filter % filter-id candidate) dashcards)] + ;; Only add filters that apply to all cards. + (if (= (count dashcards) (count dashcards-new)) + (-> dashboard + (assoc :ordered_cards dashcards-new) + (update :parameters conj {:id filter-id + :type (filter-type candidate) + :name (:display_name candidate) + :slug (:name candidate)})) + dashboard))) + dashboard))))) + + +(def ^:private date-formatter (t.format/formatter "MMMM d, YYYY")) +(def ^:private datetime-formatter (t.format/formatter "EEEE, MMMM d, YYYY h:mm a")) + +(defn- humanize-datetime + [dt] + (t.format/unparse (if (str/index-of dt "T") + datetime-formatter + date-formatter) + (t.format/parse dt))) + +(defn- field-reference->field + [fieldset field-reference] + (cond-> (-> field-reference collect-field-references first field-reference->id fieldset) + (-> field-reference first qp.util/normalize-token (= :datetime-field)) + (assoc :unit (-> field-reference last qp.util/normalize-token)))) + +(defmulti + ^{:private true + :arglists '([fieldset [op & args]])} + humanize-filter-value (fn [_ [op & args]] + (qp.util/normalize-token op))) + +(defn- either + [v & vs] + (if (empty? vs) + v + (loop [acc (format "either %s" v) + [v & vs] vs] + (cond + (nil? v) acc + (empty? vs) (format "%s or %s" acc v) + :else (recur (format "%s, %s" acc v) vs))))) + +(defmethod humanize-filter-value := + [fieldset [_ field-reference value & values]] + (let [field (field-reference->field fieldset field-reference)] + [{:field field-reference + :value (if (datetime? field) + (format "is on %s" (humanize-datetime value)) + (format "is %s" (apply either value values)))}])) + +(defmethod humanize-filter-value :!= + [fieldset [_ field-reference value & values]] + (let [field (field-reference->field fieldset field-reference)] + [{:field field-reference + :value (if (datetime? field) + (format "is not on %s" (humanize-datetime value)) + (format "is not %s" (apply either value values)))}])) + +(defmethod humanize-filter-value :> + [fieldset [_ field-reference value]] + (let [field (field-reference->field fieldset field-reference)] + [{:field field-reference + :value (if (datetime? field) + (format "is after %s" (humanize-datetime value)) + (format "is greater than %s" value))}])) + +(defmethod humanize-filter-value :< + [fieldset [_ field-reference value]] + (let [field (field-reference->field fieldset field-reference)] + [{:field field-reference + :value (if (datetime? field) + (format "is before %s" (humanize-datetime value)) + (format "is less than %s" value))}])) + +(defmethod humanize-filter-value :>= + [_ [_ field-reference value]] + [{:field field-reference + :value (format "is greater than or equal to %s" value)}]) + +(defmethod humanize-filter-value :<= + [_ [_ field-reference value]] + [_ {:field field-reference + :value (format "is less than or equal to %s" value)}]) + +(defmethod humanize-filter-value :is-null + [_ [_ field-reference]] + [{:field field-reference + :value "is null"}]) + +(defmethod humanize-filter-value :not-null + [_ [_ field-reference]] + [{:field field-reference + :value "is not null"}]) + +(defmethod humanize-filter-value :between + [_ [_ field-reference min-value max-value]] + [{:field field-reference + :value (format "is between %s and %s" min-value max-value)}]) + +(defmethod humanize-filter-value :inside + [_ [_ lat-reference lon-reference lat-max lon-min lat-min lon-max]] + [{:field lat-reference + :value (format "is between %s and %s" lat-min lat-max)} + {:field lon-reference + :value (format "is between %s and %s" lon-min lon-max)}]) + +(defmethod humanize-filter-value :starts-with + [_ [_ field-reference value]] + [{:field field-reference + :value (format "starts with %s" value)}]) + +(defmethod humanize-filter-value :contains + [_ [_ field-reference value]] + [{:field field-reference + :value (format "contains %s" value)}]) + +(defmethod humanize-filter-value :does-not-contain + [_ [_ field-reference value]] + [{:field field-reference + :value (format "does not contain %s" value)}]) + +(defmethod humanize-filter-value :ends-with + [_ [_ field-reference value]] + [{:field field-reference + :value (format "ends with %s" value)}]) + +(defn- time-interval + [n unit] + (let [unit (name unit)] + (cond + (zero? n) (format "current %s" unit) + (= n -1) (format "previous %s" unit) + (= n 1) (format "next %s" unit) + (pos? n) (format "next %s %ss" n unit) + (neg? n) (format "previous %s %ss" n unit)))) + +(defmethod humanize-filter-value :time-interval + [_ [_ field-reference n unit]] + [{:field field-reference + :value (format "is during the %s" (time-interval n unit))}]) + +(defmethod humanize-filter-value :and + [fieldset [_ & clauses]] + (mapcat (partial humanize-filter-value fieldset) clauses)) + +(def ^:private unit-name (comp {:minute-of-hour "minute of hour" + :hour-of-day "hour of day" + :day-of-week "day of week" + :day-of-month "day of month" + :week-of-year "week of year" + :month-of-year "month of year" + :quarter-of-year "quarter of year"} + qp.util/normalize-token)) + +(defn- field-name + [field field-reference] + (let [full-name (cond->> (:display_name field) + (periodic-datetime? field) + (format "%s of %s" (-> field :unit unit-name str/capitalize)))] + (if (-> field-reference first qp.util/normalize-token (= :fk->)) + [(-> field :table_id Table :display_name) full-name] + [full-name]))) + +(defn applied-filters + "Extract fields and their values from MBQL filter clauses." + [fieldset filter-clause] + (for [{field-reference :field value :value} (some->> filter-clause + not-empty + (humanize-filter-value fieldset))] + (let [field (field-reference->field fieldset field-reference)] + {:field (field-name field field-reference) + :field_id (:id field) + :type (filter-type field) + :value value}))) diff --git a/src/metabase/automagic_dashboards/populate.clj b/src/metabase/automagic_dashboards/populate.clj new file mode 100644 index 0000000000000000000000000000000000000000..82ce00dedfb676b0d93ed72a08debf625510e5db --- /dev/null +++ b/src/metabase/automagic_dashboards/populate.clj @@ -0,0 +1,283 @@ +(ns metabase.automagic-dashboards.populate + "Create and save models that make up automagic dashboards." + (:require [clojure.string :as str] + [clojure.tools.logging :as log] + [metabase.api.common :as api] + [metabase.automagic-dashboards.filters :as magic.filters] + [metabase.models.card :as card] + [metabase.query-processor.util :as qp.util] + [toucan.db :as db])) + +(def ^Long ^:const grid-width + "Total grid width." + 18) +(def ^Long ^:const default-card-width + "Default card width." + 6) +(def ^Long ^:const default-card-height + "Default card height" + 4) + +(defn create-collection! + "Create a new collection." + [title color description] + (when api/*is-superuser?* + (db/insert! 'Collection + :name title + :color color + :description description))) + +(def colors + "Colors used for coloring charts and collections." + ["#509EE3" "#9CC177" "#A989C5" "#EF8C8C" "#f9d45c" "#F1B556" "#A6E7F3" "#7172AD"]) + +(defn- ensure-distinct-colors + [candidates] + (->> candidates + frequencies + (reduce-kv + (fn [acc color count] + (if (= count 1) + (conj acc color) + (concat acc [color (first (drop-while (conj (set acc) color) colors))]))) + []))) + +(defn- colorize + "Pick the chart colors acording to the following rules: + * If there is more than one breakout dimension let the frontend do it as presumably + the second dimension will be used as color key and we can't know the values it + will take at this stage. + * If the visualization is a bar or row chart with `count` as the aggregation + (ie. a frequency chart), use field IDs referenced in `:breakout` as color key. + * Else use `:aggregation` as color key. + + Colors are then determined by using the hashs of color keys to index into the vector + of available colors." + [{:keys [visualization dataset_query]}] + (let [display (first visualization) + breakout (-> dataset_query :query :breakout) + aggregation (-> dataset_query :query :aggregation)] + (when (and (#{"line" "row" "bar" "scatter" "area"} display) + (= (count breakout) 1)) + (let [color-keys (if (and (#{"bar" "row"} display) + (some->> aggregation + flatten + first + qp.util/normalize-token + (= :count))) + (->> breakout + magic.filters/collect-field-references + (map magic.filters/field-reference->id)) + aggregation)] + {:graph.colors (->> color-keys + (map (comp colors #(mod % (count colors)) hash)) + ensure-distinct-colors)})))) + +(defn- visualization-settings + [{:keys [metrics x_label y_label series_labels visualization dimensions] :as card}] + (let [metric-name (some-fn :name (comp str/capitalize name first :metric)) + [display visualization-settings] visualization] + {:display display + :visualization_settings + (-> visualization-settings + (merge (colorize card)) + (cond-> + (some :name metrics) (assoc :graph.series_labels (map metric-name metrics)) + series_labels (assoc :graph.series_labels series_labels) + x_label (assoc :graph.x_axis.title_text x_label) + y_label (assoc :graph.y_axis.title_text y_label)))})) + +(defn- add-card + "Add a card to dashboard `dashboard` at position [`x`, `y`]." + [dashboard {:keys [title description dataset_query width height] + :as card} [x y]] + (let [card (-> {:creator_id api/*current-user-id* + :dataset_query dataset_query + :description description + :name title + :collection_id nil + :id (gensym)} + (merge (visualization-settings card)) + card/populate-query-fields)] + (update dashboard :ordered_cards conj {:col y + :row x + :sizeX width + :sizeY height + :card card + :card_id (:id card) + :visualization_settings {} + :id (gensym)}))) + +(defn add-text-card + "Add a text card to dashboard `dashboard` at position [`x`, `y`]." + [dashboard {:keys [text width height visualization-settings]} [x y]] + (update dashboard :ordered_cards conj + {:creator_id api/*current-user-id* + :visualization_settings (merge + {:text text + :virtual_card {:name nil + :display :text + :dataset_query {} + :visualization_settings {}}} + visualization-settings) + :col y + :row x + :sizeX width + :sizeY height + :card nil + :id (gensym)})) + +(defn- make-grid + [width height] + (vec (repeat height (vec (repeat width false))))) + +(defn- fill-grid + "Mark a rectangular area starting at [`x`, `y`] of size [`width`, `height`] as + occupied." + [grid [x y] {:keys [width height]}] + (reduce (fn [grid xy] + (assoc-in grid xy true)) + grid + (for [x (range x (+ x height)) + y (range y (+ y width))] + [x y]))) + +(defn- accomodates? + "Can we place card on grid starting at [x y] (top left corner)? + Since we are filling the grid top to bottom and the cards are rectangulard, + it suffices to check just the first (top) row." + [grid [x y] {:keys [width height]}] + (and (<= (+ x height) (count grid)) + (<= (+ y width) (-> grid first count)) + (every? false? (subvec (grid x) y (+ y width))))) + +(defn- card-position + "Find position on the grid where to put the card. + We use the dumbest possible algorithm (the grid size is relatively small, so + we should be fine): startting at top left move along the grid from left to + right, row by row and try to place the card at each position until we find an + unoccupied area. Mark the area as occupied." + [grid start-row card] + (reduce (fn [grid xy] + (if (accomodates? grid xy card) + (reduced xy) + grid)) + grid + (for [x (range start-row (count grid)) + y (range (count (first grid)))] + [x y]))) + +(defn- bottom-row + "Find the bottom of the grid. Bottom is the first completely empty row with + another empty row below it." + [grid] + (let [row {:height 0 :width grid-width}] + (loop [bottom 0] + (let [[bottom _] (card-position grid bottom row) + [next-bottom _] (card-position grid (inc bottom) row)] + (if (= (inc bottom) next-bottom) + bottom + (recur next-bottom)))))) + +(def ^:private ^{:arglists '([card])} text-card? + :text) + +(def ^:private ^Long ^:const group-heading-height 2) + +(defn- add-group + [dashboard grid group cards] + (let [start-row (bottom-row grid) + start-row (cond-> start-row + group (+ group-heading-height))] + (reduce (fn [[dashboard grid] card] + (let [xy (card-position grid start-row card)] + [(if (text-card? card) + (add-text-card dashboard card xy) + (add-card dashboard card xy)) + (fill-grid grid xy card)])) + (if group + (let [xy [(- start-row 2) 0] + card {:text (format "# %s" (:title group)) + :width grid-width + :height group-heading-height + :visualization-settings {:dashcard.background false + :text.align_vertical :bottom}}] + [(add-text-card dashboard card xy) + (fill-grid grid xy card)]) + [dashboard grid]) + cards))) + +(defn- shown-cards + "Pick up to `max-cards` with the highest `:score`. + Keep groups together if possible by pulling all the cards within together and + using the same (highest) score for all. + Among cards with the same score those beloning to the largest group are + favourized, but it is still possible that not all cards in a group make it + (consider a group of 4 cards which starts as 7/9; in that case only 2 cards + from the group will be picked)." + [max-cards cards] + (->> cards + (sort-by :score >) + (take max-cards) + (group-by (some-fn :group hash)) + (map (fn [[_ group]] + {:cards (sort-by :position group) + :position (apply min (map :position group))})) + (sort-by :position) + (mapcat :cards))) + +(def ^:private ^:const ^Long max-filters 4) + +(defn create-dashboard + "Create dashboard and populate it with cards." + ([dashboard] (create-dashboard dashboard :all)) + ([{:keys [title transient_title description groups filters cards refinements fieldset]} n] + (let [n (cond + (= n :all) (count cards) + (keyword? n) (Integer/parseInt (name n)) + :else n) + dashboard {:name title + :transient_name (or transient_title title) + :transient_filters (magic.filters/applied-filters fieldset refinements) + :description description + :creator_id api/*current-user-id* + :parameters []} + cards (shown-cards n cards) + [dashboard _] (->> cards + (partition-by :group) + (reduce (fn [[dashboard grid] cards] + (let [group (some-> cards first :group groups)] + (add-group dashboard grid group cards))) + [dashboard + ;; Height doesn't need to be precise, just some + ;; safe upper bound. + (make-grid grid-width (* n grid-width))]))] + (log/info (format "Adding %s cards to dashboard %s:\n%s" + (count cards) + title + (str/join "; " (map :title cards)))) + (cond-> dashboard + (not-empty filters) (magic.filters/add-filters filters max-filters))))) + +(defn merge-dashboards + "Merge dashboards `ds` into dashboard `d`." + [d & ds] + (reduce (fn [target dashboard] + (let [offset (->> dashboard + :ordered_cards + (map #(+ (:row %) (:sizeY %))) + (apply max -2) ; -2 so it neturalizes +2 for spacing if + ; the target dashboard is empty. + (+ 2))] + (-> target + (add-text-card {:width default-card-width + :height group-heading-height + :text (:name dashboard)} + [offset 0]) + (update :ordered_cards concat + (->> dashboard + :ordered_cards + (map #(update :row + offset group-heading-height)))) + (update :parameters concat (:parameters dashboard))))) + d + ds)) diff --git a/src/metabase/automagic_dashboards/rules.clj b/src/metabase/automagic_dashboards/rules.clj new file mode 100644 index 0000000000000000000000000000000000000000..21870cec9214866dca880ace49e77c472c013736 --- /dev/null +++ b/src/metabase/automagic_dashboards/rules.clj @@ -0,0 +1,348 @@ +(ns metabase.automagic-dashboards.rules + "Validation, transformation to cannonical form, and loading of heuristics." + (:require [clojure.java.io :as io] + [clojure.string :as str] + [clojure.tools.logging :as log] + [metabase.automagic-dashboards.populate :as populate] + [metabase.types] + [metabase.util :as u] + [metabase.util.schema :as su] + [schema + [coerce :as sc] + [core :as s]] + [yaml.core :as yaml]) + (:import java.nio.file.Path java.nio.file.FileSystems java.nio.file.FileSystem + java.nio.file.Files )) + +(def ^Long ^:const max-score + "Maximal (and default) value for heuristics scores." + 100) + +(def ^:private Score (s/constrained s/Int #(<= 0 % max-score) + (str "0 <= score <= " max-score))) + +(def ^:private MBQL [s/Any]) + +(def ^:private Identifier s/Str) + +(def ^:private Metric {Identifier {(s/required-key :metric) MBQL + (s/required-key :score) Score + (s/optional-key :name) s/Str}}) + +(def ^:private Filter {Identifier {(s/required-key :filter) MBQL + (s/required-key :score) Score}}) + +(defn ga-dimension? + "Does string `t` denote a Google Analytics dimension?" + [t] + (str/starts-with? t "ga:")) + +(defn ->type + "Turn `x` into proper type name." + [x] + (cond + (keyword? x) x + (ga-dimension? x) x + :else (keyword "type" x))) + +(defn ->entity + "Turn `x` into proper entity name." + [x] + (cond + (keyword? x) x + (ga-dimension? x) x + :else (keyword "entity" x))) + +(defn- field-type? + [t] + (isa? t :type/*)) + +(defn- table-type? + [t] + (isa? t :entity/*)) + +(def ^:private TableType (s/constrained s/Keyword table-type?)) +(def ^:private FieldType (s/cond-pre (s/constrained s/Str ga-dimension?) + (s/constrained s/Keyword field-type?))) + +(def ^:private AppliesTo (s/either [FieldType] + [TableType] + [(s/one TableType "table") FieldType])) + +(def ^:private Dimension {Identifier {(s/required-key :field_type) AppliesTo + (s/required-key :score) Score + (s/optional-key :links_to) TableType + (s/optional-key :named) s/Str + (s/optional-key :max_cardinality) s/Int}}) + +(def ^:private OrderByPair {Identifier (s/enum "descending" "ascending")}) + +(def ^:private Visualization [(s/one s/Str "visualization") su/Map]) + +(def ^:private Width (s/constrained s/Int #(<= 1 % populate/grid-width) + (format "1 <= width <= %s" + populate/grid-width))) +(def ^:private Height (s/constrained s/Int pos?)) + +(def ^:private CardDimension {Identifier {(s/optional-key :aggregation) s/Str}}) + +(def ^:private Card + {Identifier {(s/required-key :title) s/Str + (s/required-key :score) Score + (s/optional-key :visualization) Visualization + (s/optional-key :text) s/Str + (s/optional-key :dimensions) [CardDimension] + (s/optional-key :filters) [s/Str] + (s/optional-key :metrics) [s/Str] + (s/optional-key :limit) su/IntGreaterThanZero + (s/optional-key :order_by) [OrderByPair] + (s/optional-key :description) s/Str + (s/optional-key :query) s/Str + (s/optional-key :width) Width + (s/optional-key :height) Height + (s/optional-key :group) s/Str + (s/optional-key :y_label) s/Str + (s/optional-key :x_label) s/Str + (s/optional-key :series_labels) [s/Str]}}) + +(def ^:private Groups + {Identifier {(s/required-key :title) s/Str + (s/optional-key :description) s/Str}}) + +(def ^{:arglists '([definition])} identifier + "Return `key` in `{key {}}`." + (comp key first)) + +(def ^:private ^{:arglists '([definitions])} identifiers + (partial into #{"this"} (map identifier))) + +(defn- all-references + [k cards] + (mapcat (comp k val first) cards)) + +(def ^:private DimensionForm + [(s/one (s/constrained (s/cond-pre s/Str s/Keyword) + (comp #{"dimension"} str/lower-case name)) + "dimension") + (s/one s/Str "identifier") + su/Map]) + +(def ^{:arglists '([form])} dimension-form? + "Does form denote a dimension referece?" + (complement (s/checker DimensionForm))) + +(defn collect-dimensions + "Return all dimension references in form." + [form] + (->> form + (tree-seq (some-fn map? sequential?) identity) + (mapcat (fn [subform] + (cond + (dimension-form? subform) [(second subform)] + (string? subform) (->> subform + (re-seq #"\[\[(\w+)\]\]") + (map second)) + :else nil))) + distinct)) + +(defn- valid-metrics-references? + [{:keys [metrics cards]}] + (every? (identifiers metrics) (all-references :metrics cards))) + +(defn- valid-filters-references? + [{:keys [filters cards]}] + (every? (identifiers filters) (all-references :filters cards))) + +(defn- valid-group-references? + [{:keys [cards groups]}] + (every? groups (keep (comp :group val first) cards))) + +(defn- valid-order-by-references? + [{:keys [dimensions metrics cards]}] + (every? (comp (into (identifiers dimensions) + (identifiers metrics)) + identifier) + (all-references :order_by cards))) + +(defn- valid-dimension-references? + [{:keys [dimensions] :as rule}] + (every? (some-fn (identifiers dimensions) (comp table-type? ->entity)) + (collect-dimensions rule))) + +(defn- valid-dashboard-filters-references? + [{:keys [dimensions dashboard_filters]}] + (every? (identifiers dimensions) dashboard_filters)) + +(defn- valid-breakout-dimension-references? + [{:keys [cards dimensions]}] + (->> cards + (all-references :dimensions) + (map identifier) + (every? (identifiers dimensions)))) + +(defn- constrained-all + [schema & constraints] + (reduce (partial apply s/constrained) + schema + (partition 2 constraints))) + +(def ^:private Rules + (constrained-all + {(s/required-key :title) s/Str + (s/required-key :dimensions) [Dimension] + (s/required-key :cards) [Card] + (s/required-key :rule) s/Str + (s/optional-key :applies_to) AppliesTo + (s/optional-key :transient_title) s/Str + (s/optional-key :short_title) s/Str + (s/optional-key :description) s/Str + (s/optional-key :metrics) [Metric] + (s/optional-key :filters) [Filter] + (s/optional-key :groups) Groups + (s/optional-key :indepth) [s/Any] + (s/optional-key :dashboard_filters) [s/Str]} + valid-metrics-references? "Valid metrics references" + valid-filters-references? "Valid filters references" + valid-group-references? "Valid group references" + valid-order-by-references? "Valid order_by references" + valid-dashboard-filters-references? "Valid dashboard filters references" + valid-dimension-references? "Valid dimension references" + valid-breakout-dimension-references? "Valid card dimension references")) + +(defn- with-defaults + [defaults] + (fn [x] + (let [[identifier definition] (first x)] + {identifier (merge defaults definition)}))) + +(defn- shorthand-definition + "Expand definition of the form {identifier value} with regards to key `k` into + {identifier {k value}}." + [k] + (fn [x] + (let [[identifier definition] (first x)] + (if (map? definition) + x + {identifier {k definition}})))) + +(defn ensure-seq + "Wrap `x` into a vector if it is not already a sequence." + [x] + (if (or (sequential? x) (nil? x)) + x + [x])) + +(def ^:private rules-validator + (sc/coercer! + Rules + {[s/Str] ensure-seq + [OrderByPair] ensure-seq + OrderByPair (fn [x] + (if (string? x) + {x "ascending"} + x)) + Visualization (fn [x] + (if (string? x) + [x {}] + (first x))) + Metric (comp (with-defaults {:score max-score}) + (shorthand-definition :metric)) + Dimension (comp (with-defaults {:score max-score}) + (shorthand-definition :field_type)) + Filter (comp (with-defaults {:score max-score}) + (shorthand-definition :filter)) + Card (with-defaults {:score max-score + :width populate/default-card-width + :height populate/default-card-height}) + [CardDimension] ensure-seq + CardDimension (fn [x] + (if (string? x) + {x {}} + x)) + TableType ->entity + FieldType ->type + Identifier (fn [x] + (if (keyword? x) + (name x) + x)) + Groups (partial apply merge) + AppliesTo (fn [x] + (let [[table-type field-type] (str/split x #"\.")] + (if field-type + [(->entity table-type) (->type field-type)] + [(if (-> table-type ->entity table-type?) + (->entity table-type) + (->type table-type))])))})) + +(def ^:private rules-dir "automagic_dashboards/") + +(def ^:private ^{:arglists '([f])} file->entity-type + (comp (partial re-find #".+(?=\.yaml)") str (memfn ^Path getFileName))) + +(defn- load-rule + [^Path f] + (try + (let [entity-type (file->entity-type f)] + (-> f + .toUri + slurp + yaml/parse-string + (assoc :rule entity-type) + (update :applies_to #(or % entity-type)) + rules-validator)) + (catch Exception e + (log/error (format "Error parsing %s:\n%s" + (.getFileName f) + (or (some-> e + ex-data + (select-keys [:error :value]) + u/pprint-to-str) + e))) + nil))) + +(defn- load-rule-dir + ([dir] (load-rule-dir dir [] {})) + ([dir path rules] + (with-open [ds (Files/newDirectoryStream dir)] + (reduce (fn [rules ^Path f] + (cond + (Files/isDirectory f (into-array java.nio.file.LinkOption [])) + (load-rule-dir f (conj path (str (.getFileName f))) rules) + + (file->entity-type f) + (assoc-in rules (concat path [(file->entity-type f) ::leaf]) (load-rule f)) + + :else + rules)) + rules + ds)))) + +(defmacro ^:private with-resource + [[identifier path] & body] + `(let [[jar# path#] (-> ~path .toString (str/split #"!" 2))] + (if path# + (with-open [^FileSystem fs# (-> jar# + java.net.URI/create + (FileSystems/newFileSystem (java.util.HashMap.)))] + (let [~identifier (.getPath fs# path# (into-array String []))] + ~@body)) + (let [~identifier (.getPath (FileSystems/getDefault) (.getPath ~path) (into-array String []))] + ~@body)))) + +(def ^:private rules (delay + (with-resource [path (-> rules-dir io/resource .toURI)] + (into {} (load-rule-dir path))))) + +(defn get-rules + "Get all rules with prefix `prefix`. + prefix is greedy, so [\"table\"] will match table/TransactionTable.yaml, but not + table/TransactionTable/ByCountry.yaml" + [prefix] + (->> prefix + (get-in @rules) + (keep (comp ::leaf val)))) + +(defn get-rule + "Get rule at path `path`." + [path] + (get-in @rules (concat path [::leaf]))) diff --git a/src/metabase/cmd.clj b/src/metabase/cmd.clj index 92a17ce113c73dc822f802372b51531924f224fc..0efcf920bfabc48fa23a1e24ad5a23ee95728eb1 100644 --- a/src/metabase/cmd.clj +++ b/src/metabase/cmd.clj @@ -40,7 +40,7 @@ [] ;; override env var that would normally make Jetty block forever (require 'environ.core) - (intern 'environ.core 'env (assoc environ.core/env :mb-jetty-join "false")) + (intern 'environ.core 'env (assoc @(resolve 'environ.core/env) :mb-jetty-join "false")) (u/profile "start-normally" ((resolve 'metabase.core/start-normally)))) (defn ^:command reset-password @@ -49,6 +49,12 @@ (require 'metabase.cmd.reset-password) ((resolve 'metabase.cmd.reset-password/reset-password!) email-address)) +(defn ^:command refresh-integration-test-db-metadata + "Re-sync the frontend integration test DB's metadata for the Sample Dataset." + [] + (require 'metabase.cmd.refresh-integration-test-db-metadata) + ((resolve 'metabase.cmd.refresh-integration-test-db-metadata/refresh-integration-test-db-metadata))) + (defn ^:command help "Show this help message listing valid Metabase commands." [] diff --git a/src/metabase/cmd/refresh_integration_test_db_metadata.clj b/src/metabase/cmd/refresh_integration_test_db_metadata.clj new file mode 100644 index 0000000000000000000000000000000000000000..19d4a819e13e4552ea86e1887edb95f3b2757887 --- /dev/null +++ b/src/metabase/cmd/refresh_integration_test_db_metadata.clj @@ -0,0 +1,44 @@ +(ns metabase.cmd.refresh-integration-test-db-metadata + (:require [clojure.java.io :as io] + [environ.core :refer [env]] + [metabase + [db :as mdb] + [util :as u]] + [metabase.models + [database :refer [Database]] + [field :refer [Field]] + [table :refer [Table]]] + [metabase.sample-data :as sample-data] + [metabase.sync :as sync] + [toucan.db :as db])) + +(defn- test-fixture-db-path + "Get the path to the test fixture DB that we'll use for `MB_DB_FILE`. Throw an Exception if the file doesn't exist." + [] + (let [path (str (System/getProperty "user.dir") "/frontend/test/__runner__/test_db_fixture.db")] + (when-not (or (.exists (io/file (str path ".h2.db"))) + (.exists (io/file (str path ".mv.db")))) + (throw (Exception. (str "Could not find frontend integration test DB at path: " path ".h2.db (or .mv.db)")))) + path)) + +(defn ^:command refresh-integration-test-db-metadata + "Re-sync the frontend integration test DB's metadata for the Sample Dataset." + [] + (let [db-path (test-fixture-db-path)] + ;; now set the path at MB_DB_FILE + (intern 'environ.core 'env (assoc env :mb-db-type "h2", :mb-db-file db-path)) + ;; set up the DB, make sure sample dataset is added + (mdb/setup-db!) + (sample-data/add-sample-dataset!) + (sample-data/update-sample-dataset-if-needed!) + ;; clear out all Fingerprints so we force analysis to run again. Clear out special type and has_field_values as + ;; well so we can be sure those will be set to the correct values + (db/debug-print-queries + (db/update! Field {:set {:fingerprint_version 0 + :special_type nil + :has_field_values nil + :fk_target_field_id nil}})) + ;; now re-run sync + (sync/sync-database! (Database :is_sample true)) + ;; done! + (println "Finished."))) diff --git a/src/metabase/cmd/reset_password.clj b/src/metabase/cmd/reset_password.clj index e515f464fa2cffd65dcbdc3ed01847f6fd654e64..944a49c0da62406c15d98e74950799919a8d80d4 100644 --- a/src/metabase/cmd/reset_password.clj +++ b/src/metabase/cmd/reset_password.clj @@ -1,23 +1,26 @@ (ns metabase.cmd.reset-password (:require [metabase.db :as mdb] [metabase.models.user :refer [User] :as user] + [puppetlabs.i18n.core :refer [trs]] [toucan.db :as db])) (defn- set-reset-token! "Set and return a new `reset_token` for the user with EMAIL-ADDRESS." [email-address] (let [user-id (or (db/select-one-id User, :email email-address) - (throw (Exception. (format "No user found with email address '%s'. Please check the spelling and try again." email-address))))] + (throw (Exception. (str (trs "No user found with email address ''{0}''. " email-address) + (trs "Please check the spelling and try again.")))))] (user/set-password-reset-token! user-id))) (defn reset-password! "Reset the password for EMAIL-ADDRESS, and return the reset token in a format that can be understood by the Mac App." [email-address] (mdb/setup-db!) - (println (format "Resetting password for %s...\n" email-address)) + (println (str (trs "Resetting password for {0}..." email-address) + "\n")) (try - (println (format "OK [[[%s]]]" (set-reset-token! email-address))) + (println (trs "OK [[[{0}]]]" (set-reset-token! email-address))) (System/exit 0) (catch Throwable e - (println (format "FAIL [[[%s]]]" (.getMessage e))) + (println (trs "FAIL [[[{0}]]]" (.getMessage e))) (System/exit -1)))) diff --git a/src/metabase/core.clj b/src/metabase/core.clj index 6e82abead854676d77587d1c3c69adad06b79134..1023881165ff917d1c2e17ca75808ae2c2674a77 100644 --- a/src/metabase/core.clj +++ b/src/metabase/core.clj @@ -38,7 +38,8 @@ [toucan.db :as db]) (:import [java.io BufferedWriter OutputStream OutputStreamWriter] [java.nio.charset Charset StandardCharsets] - org.eclipse.jetty.server.Server)) + org.eclipse.jetty.server.Server + org.eclipse.jetty.util.thread.QueuedThreadPool)) ;;; CONFIG @@ -71,10 +72,23 @@ (rr/content-type json-response "application/json; charset=utf-8")) response)))) +(def ^:private jetty-instance + (atom nil)) + +(defn- jetty-stats [] + (when-let [^Server jetty-server @jetty-instance] + (let [^QueuedThreadPool pool (.getThreadPool jetty-server)] + {:min-threads (.getMinThreads pool) + :max-threads (.getMaxThreads pool) + :busy-threads (.getBusyThreads pool) + :idle-threads (.getIdleThreads pool) + :queue-size (.getQueueSize pool)}))) + (def ^:private app "The primary entry point to the Ring HTTP server." (-> #'routes/routes ; the #' is to allow tests to redefine endpoints - mb-middleware/log-api-call + (mb-middleware/log-api-call + jetty-stats) mb-middleware/add-security-headers ; Add HTTP headers to API responses to prevent them from being cached (wrap-json-body ; extracts json POST body and makes it avaliable on request {:keywords? true}) @@ -178,10 +192,6 @@ ;;; ## ---------------------------------------- Jetty (Web) Server ---------------------------------------- - -(def ^:private jetty-instance - (atom nil)) - (defn start-jetty! "Start the embedded Jetty web server." [] diff --git a/src/metabase/db.clj b/src/metabase/db.clj index 3cf9ccbfda8900d52fa024eb0c6bb2af00feae00..10cfd8cbf70ab11493b8e4a28d472b250e8173db 100644 --- a/src/metabase/db.clj +++ b/src/metabase/db.clj @@ -421,6 +421,18 @@ (db/qualify dest-entity pk)]]}) +(defn- type-keyword->descendants + "Return a set of descendents of Metabase `type-keyword`. This includes `type-keyword` itself, so the set will always + have at least one element. + + (type-keyword->descendants :type/Coordinate) ; -> #{\"type/Latitude\" \"type/Longitude\" \"type/Coordinate\"}" + [type-keyword] + ;; make sure `type-keyword` is a valid MB type. There may be some cases where we want to use these functions for + ;; types outside of the `:type/` hierarchy. If and when that happens, we can reconsider this check. But since no + ;; such cases currently exist, adding this check to catch typos makes sense. + {:pre [(isa? type-keyword :type/*)]} + (set (map u/keyword->qualified-name (cons type-keyword (descendants type-keyword))))) + (defn isa "Convenience for generating an HoneySQL `IN` clause for a keyword and all of its descendents. Intended for use with the type hierarchy in `metabase.types`. @@ -435,6 +447,9 @@ -> (db/select Field {:where [:in :special_type #{\"type/URL\" \"type/ImageURL\" \"type/AvatarURL\"}]})" ([type-keyword] - [:in (set (map u/keyword->qualified-name (cons type-keyword (descendants type-keyword))))]) + [:in (type-keyword->descendants type-keyword)]) + ;; when using this with an `expr` (e.g. `(isa :special_type :type/URL)`) just go ahead and take the results of the + ;; one-arity impl above and splice expr in as the second element (`[:in #{"type/URL" "type/ImageURL"}]` becomes + ;; `[:in :special_type #{"type/URL" "type/ImageURL"}]`) ([expr type-keyword] - [:in expr (last (isa type-keyword))])) + [:in expr (type-keyword->descendants type-keyword)])) diff --git a/src/metabase/db/metadata_queries.clj b/src/metabase/db/metadata_queries.clj index 8f7b9d412ca89804f6b4979bb6f7953beae47841..7dc23248e6e32ab127ba3eec332ef2284a06f591 100644 --- a/src/metabase/db/metadata_queries.clj +++ b/src/metabase/db/metadata_queries.clj @@ -4,11 +4,11 @@ [metabase [query-processor :as qp] [util :as u]] - [metabase.models - [field-values :as field-values] - [table :refer [Table]]] + [metabase.models.table :refer [Table]] [metabase.query-processor.interface :as qpi] [metabase.query-processor.middleware.expand :as ql] + [metabase.util.schema :as su] + [schema.core :as s] [toucan.db :as db])) (defn- qp-query [db-id query] @@ -42,15 +42,32 @@ (u/pprint-to-str results)) (throw e))))) -(defn field-distinct-values +(def ^:private ^Integer absolute-max-distinct-values-limit + "The absolute maximum number of results to return for a `field-distinct-values` query. Normally Fields with 100 or + less values (at the time of this writing) get marked as `auto-list` Fields, meaning we save all their distinct + values in a FieldValues object, which powers a list widget in the FE when using the Field for filtering in the QB. + Admins can however manually mark any Field as `list`, which is effectively ordering Metabase to keep FieldValues for + the Field regardless of its cardinality. + + Of course, if a User does something crazy, like mark a million-arity Field as List, we don't want Metabase to + explode trying to make their dreams a reality; we need some sort of hard limit to prevent catastrophes. So this + limit is effectively a safety to prevent Users from nuking their own instance for Fields that really shouldn't be + List Fields at all. For these very-high-cardinality Fields, we're effectively capping the number of + FieldValues that get could saved. + + This number should be a balance of: + + * Not being too low, which would definitly result in GitHub issues along the lines of 'My 500-distinct-value Field + that I marked as List is not showing all values in the List Widget' + * Not being too high, which would result in Metabase running out of memory dealing with too many values" + (int 5000)) + +(s/defn field-distinct-values "Return the distinct values of FIELD. This is used to create a `FieldValues` object for `:type/Category` Fields." ([field] - ;; fetch up to one more value than allowed for FieldValues. e.g. if the max is 100 distinct values fetch up to 101. - ;; That way we will know if we're over the limit - (field-distinct-values field (inc field-values/low-cardinality-threshold))) - ([field max-results] - {:pre [(integer? max-results)]} + (field-distinct-values field absolute-max-distinct-values-limit)) + ([field, max-results :- su/IntGreaterThanZero] (mapv first (field-query field (-> {} (ql/breakout (ql/field-id (u/get-id field))) (ql/limit max-results)))))) diff --git a/src/metabase/db/migrations.clj b/src/metabase/db/migrations.clj index e6077ffb556f32d0af8d23675b8dff26df0f29ca..69d3813bf163334c3221cff8ea80dc6028695a81 100644 --- a/src/metabase/db/migrations.clj +++ b/src/metabase/db/migrations.clj @@ -10,6 +10,7 @@ [clojure.string :as str] [clojure.tools.logging :as log] [metabase + [db :as mdb] [config :as config] [driver :as driver] [public-settings :as public-settings] @@ -338,10 +339,15 @@ ;; missing those database ids (defmigration ^{:author "senior", :added "0.27.0"} populate-card-database-id (doseq [[db-id cards] (group-by #(get-in % [:dataset_query :database]) - (db/select [Card :dataset_query :id] :database_id [:= nil])) + (db/select [Card :dataset_query :id :name] :database_id [:= nil])) :when (not= db-id virtual-id)] - (db/update-where! Card {:id [:in (map :id cards)]} - :database_id db-id))) + (if (and (seq cards) + (db/exists? Database :id db-id)) + (db/update-where! Card {:id [:in (map :id cards)]} + :database_id db-id) + (doseq [{id :id card-name :name} cards] + (log/warnf "Cleaning up orphaned Question '%s', associated to a now deleted database" card-name) + (db/delete! Card :id id))))) ;; Prior to version 0.28.0 humanization was configured using the boolean setting `enable-advanced-humanization`. ;; `true` meant "use advanced humanization", while `false` meant "use simple humanization". In 0.28.0, this Setting @@ -369,8 +375,43 @@ ;; `pre-update` implementation. ;; ;; Caching these permissions will prevent 1000+ DB call API calls. See https://github.com/metabase/metabase/issues/6889 +;; +;; NOTE: This used used to be +;; (defmigration ^{:author "camsaul", :added "0.28.2"} populate-card-read-permissions +;; (run! +;; (fn [card] +;; (db/update! Card (u/get-id card) {})) +;; (db/select-reducible Card :archived false, :read_permissions nil))) +;; But due to bug https://github.com/metabase/metabase/issues/7189 was replaced (defmigration ^{:author "camsaul", :added "0.28.2"} populate-card-read-permissions + (log/info "Not running migration `populate-card-read-permissions` as it has been replaced by a subsequent migration ")) + +;; Migration from 0.28.2 above had a flaw in that passing in `{}` to the update results in +;; the functions that do pre-insert permissions checking don't have the query dictionary to analyze +;; and always short-circuit due to the missing query dictionary. Passing the card itself into the +;; check mimicks how this works in-app, and appears to fix things. +(defmigration ^{:author "salsakran", :added "0.28.3"} repopulate-card-read-permissions (run! (fn [card] - (db/update! Card (u/get-id card) {})) - (db/select-reducible Card :archived false, :read_permissions nil))) + (try + (db/update! Card (u/get-id card) card) + (catch Throwable e + (log/error "Error updating Card to set its read_permissions:" + (class e) + (.getMessage e) + (u/filtered-stacktrace e))))) + (db/select-reducible Card :archived false))) + +;; Starting in version 0.29.0 we switched the way we decide which Fields should get FieldValues. Prior to 29, Fields +;; would be marked as special type Category if they should have FieldValues. In 29+, the Category special type no +;; longer has any meaning as far as the backend is concerned. Instead, we use the new `has_field_values` column to +;; keep track of these things. Fields whose value for `has_field_values` is `list` is the equiavalent of the old +;; meaning of the Category special type. +;; +;; Since the meanings of things has changed we'll want to make sure we mark all Category fields as `list` as well so +;; their behavior doesn't suddenly change. +(defmigration ^{:author "camsaul", :added "0.29.0"} mark-category-fields-as-list + (db/update-where! Field {:has_field_values nil + :special_type (mdb/isa :type/Category) + :active true} + :has_field_values "list")) diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj index e818becd072d113cc5908e7223e0073e63cfe8ef..36dcbe3e92731c5540b9d96387aeb1c23c5e9072 100644 --- a/src/metabase/driver.clj +++ b/src/metabase/driver.clj @@ -24,7 +24,9 @@ table] [metabase.sync.interface :as si] [metabase.util :as u] + [puppetlabs.i18n.core :refer [tru]] [schema.core :as s] + [puppetlabs.i18n.core :refer [trs tru]] [toucan.db :as db]) (:import clojure.lang.Keyword java.text.SimpleDateFormat @@ -38,15 +40,23 @@ (def connection-error-messages "Generic error messages that drivers should return in their implementation of `humanize-connection-error-message`." - {:cannot-connect-check-host-and-port "Hmm, we couldn't connect to the database. Make sure your host and port settings are correct" - :ssh-tunnel-auth-fail "We couldn't connect to the ssh tunnel host. Check the username, password" - :ssh-tunnel-connection-fail "We couldn't connect to the ssh tunnel host. Check the hostname and port" - :database-name-incorrect "Looks like the database name is incorrect." - :invalid-hostname "It looks like your host is invalid. Please double-check it and try again." - :password-incorrect "Looks like your password is incorrect." - :password-required "Looks like you forgot to enter your password." - :username-incorrect "Looks like your username is incorrect." - :username-or-password-incorrect "Looks like the username or password is incorrect."}) + {:cannot-connect-check-host-and-port (str (tru "Hmm, we couldn''t connect to the database.") + " " + (tru "Make sure your host and port settings are correct")) + :ssh-tunnel-auth-fail (str (tru "We couldn''t connect to the ssh tunnel host.") + " " + (tru "Check the username, password.")) + :ssh-tunnel-connection-fail (str (tru "We couldn''t connect to the ssh tunnel host.") + " " + (tru "Check the hostname and port.")) + :database-name-incorrect (tru "Looks like the database name is incorrect.") + :invalid-hostname (str (tru "It looks like your host is invalid.") + " " + (tru "Please double-check it and try again.")) + :password-incorrect (tru "Looks like your password is incorrect.") + :password-required (tru "Looks like you forgot to enter your password.") + :username-incorrect (tru "Looks like your username is incorrect.") + :username-or-password-incorrect (tru "Looks like the username or password is incorrect.")}) (defprotocol IDriver "Methods that Metabase drivers must implement. Methods marked *OPTIONAL* have default implementations in @@ -243,7 +253,7 @@ ;;; ## CONFIG -(defsetting report-timezone "Connection timezone to use when executing queries. Defaults to system timezone.") +(defsetting report-timezone (tru "Connection timezone to use when executing queries. Defaults to system timezone.")) (defonce ^:private registered-drivers (atom {})) @@ -255,7 +265,7 @@ [^Keyword engine, driver-instance] {:pre [(keyword? engine) (map? driver-instance)]} (swap! registered-drivers assoc engine driver-instance) - (log/debug (format "Registered driver %s %s" (u/format-color 'blue engine) (u/emoji "🚚")))) + (log/debug (trs "Registered driver {0} {1}" (u/format-color 'blue engine) (u/emoji "🚚")))) (defn available-drivers "Info about available drivers." @@ -270,7 +280,7 @@ (require ns-symb) (if-let [register-driver-fn (ns-resolve ns-symb '-init-driver)] (register-driver-fn) - (log/warn (format "No -init-driver function found for '%s'" (name ns-symb))))) + (log/warn (trs "No -init-driver function found for ''{0}''" (name ns-symb))))) (defn find-and-load-drivers! "Search Classpath for namespaces that start with `metabase.driver.`, then `require` them and look for the @@ -358,7 +368,7 @@ (catch Exception e (throw (Exception. - (format "Unable to parse date string '%s' for database engine '%s'" + (tru "Unable to parse date string ''{0}'' for database engine ''{1}''" time-str (-> database :engine name)) e))))))) (defn class->base-type @@ -386,7 +396,7 @@ [clojure.lang.IPersistentVector :type/Array] [org.bson.types.ObjectId :type/MongoBSONID] [org.postgresql.util.PGobject :type/*]]) - (log/warn (format "Don't know how to map class '%s' to a Field base_type, falling back to :type/*." klass)) + (log/warn (trs "Don''t know how to map class ''{0}'' to a Field base_type, falling back to :type/*." klass)) :type/*)) (defn values->base-type @@ -460,7 +470,7 @@ (u/with-timeout can-connect-timeout-ms (can-connect? driver details-map)) (catch Throwable e - (log/error "Failed to connect to database:" (.getMessage e)) + (log/error (trs "Failed to connect to database: {0}" (.getMessage e))) (when rethrow-exceptions (throw (Exception. (humanize-connection-error-message driver (.getMessage e))))) false)))) diff --git a/src/metabase/driver/FixedHiveConnection.clj b/src/metabase/driver/FixedHiveConnection.clj new file mode 100644 index 0000000000000000000000000000000000000000..6a06958e77514861d0cb8f5eaec6c4784276850a --- /dev/null +++ b/src/metabase/driver/FixedHiveConnection.clj @@ -0,0 +1,26 @@ +(ns metabase.driver.FixedHiveConnection + (:import [org.apache.hive.jdbc HiveConnection] + [java.sql ResultSet SQLException] + java.util.Properties) + (:gen-class + :extends org.apache.hive.jdbc.HiveConnection + :init init + :constructors {[String java.util.Properties] [String java.util.Properties]})) + +(defn -init + "Initializes the connection" + [uri properties] + [[uri properties] nil]) + +(defn -getHoldability + "Returns the holdability setting for this JDBC driver" + [^org.apache.hive.jdbc.HiveConnection this] + ResultSet/CLOSE_CURSORS_AT_COMMIT) + +(defn -setReadOnly + "Sets this connection to read only" + [^org.apache.hive.jdbc.HiveConnection this read-only?] + (when (.isClosed this) + (throw (SQLException. "Connection is closed"))) + (when read-only? + (throw (SQLException. "Enabling read-only mode is not supported")))) diff --git a/src/metabase/driver/FixedHiveDriver.clj b/src/metabase/driver/FixedHiveDriver.clj new file mode 100644 index 0000000000000000000000000000000000000000..b477ab10bb0b5dae26ed7a59346d17b99b718a4d --- /dev/null +++ b/src/metabase/driver/FixedHiveDriver.clj @@ -0,0 +1,19 @@ +(ns metabase.driver.FixedHiveDriver + (:import [org.apache.hive.jdbc HiveDriver] + java.util.Properties) + (:gen-class + :extends org.apache.hive.jdbc.HiveDriver + :init init + :prefix "driver-" + :constructors {[] []})) + +(defn driver-init + "Initializes the Hive driver, fixed to work with Metabase" + [] + [[] nil]) + +(defn driver-connect + "Connects to a Hive compatible database" + [^org.apache.hive.jdbc.HiveDriver this ^String url ^java.util.Properties info] + (when (.acceptsURL this url) + (clojure.lang.Reflector/invokeConstructor (Class/forName "metabase.driver.FixedHiveConnection") (to-array [url info])))) diff --git a/src/metabase/driver/bigquery.clj b/src/metabase/driver/bigquery.clj index f41a2e19ce3f4752e11b02c2f275d29ba3460237..3a1d012cac180d69a90760e42610ae5d7942deb1 100644 --- a/src/metabase/driver/bigquery.clj +++ b/src/metabase/driver/bigquery.clj @@ -29,6 +29,7 @@ [metabase.util.honeysql-extensions :as hx] [toucan.db :as db]) (:import com.google.api.client.googleapis.auth.oauth2.GoogleCredential + [com.google.api.client.http HttpRequestInitializer HttpRequest] [com.google.api.services.bigquery Bigquery Bigquery$Builder BigqueryScopes] [com.google.api.services.bigquery.model QueryRequest QueryResponse Table TableCell TableFieldSchema TableList TableList$Tables TableReference TableRow TableSchema] @@ -44,7 +45,14 @@ ;;; ----------------------------------------------------- Client ----------------------------------------------------- (defn- ^Bigquery credential->client [^GoogleCredential credential] - (.build (doto (Bigquery$Builder. google/http-transport google/json-factory credential) + (.build (doto (Bigquery$Builder. + google/http-transport + google/json-factory + (reify HttpRequestInitializer + (initialize [this httpRequest] + (.initialize credential httpRequest) + (.setConnectTimeout httpRequest 0) + (.setReadTimeout httpRequest 0)))) (.setApplicationName google/application-name)))) (def ^:private ^{:arglists '([database])} ^GoogleCredential database->credential @@ -233,7 +241,6 @@ (hx/->timestamp (microseconds->str format-str (->microseconds timestamp)))) (defn- date [unit expr] - {:pre [expr]} (case unit :default expr :minute (trunc-with-format "%Y-%m-%d %H:%M:00" expr) diff --git a/src/metabase/driver/crate.clj b/src/metabase/driver/crate.clj index 7c85e0f8d37bc13baf26f2b09c176d2d583979ff..605a14aa47193d86e64057bb8542a8531b5f42ba 100644 --- a/src/metabase/driver/crate.clj +++ b/src/metabase/driver/crate.clj @@ -68,11 +68,12 @@ where table_name like '%s' and table_schema like '%s' and data_type != 'object_array'" name schema)])] ; clojure jdbc can't handle fields of type "object_array" atm (set (for [{:keys [column_name type_name]} columns] - {:name column_name - :custom {:column-type type_name} - :base-type (or (column->base-type (keyword type_name)) - (do (log/warn (format "Don't know how to map column type '%s' to a Field base_type, falling back to :type/*." type_name)) - :type/*))})))) + {:name column_name + :custom {:column-type type_name} + :database-type type_name + :base-type (or (column->base-type (keyword type_name)) + (do (log/warn (format "Don't know how to map column type '%s' to a Field base_type, falling back to :type/*." type_name)) + :type/*))})))) (defn- add-table-pks [^DatabaseMetaData metadata, table] diff --git a/src/metabase/driver/druid.clj b/src/metabase/driver/druid.clj index 61e9fdfe358cca40ae513abe2e29bbdf21c9d261..2aeb13e8d9d54458e90edd5a80fdcddd7c0dbe57 100644 --- a/src/metabase/driver/druid.clj +++ b/src/metabase/driver/druid.clj @@ -63,7 +63,10 @@ (catch Throwable e ;; try to extract the error (let [message (or (u/ignore-exceptions - (:error (json/parse-string (:body (:object (ex-data e))) keyword))) + (when-let [body (json/parse-string (:body (:object (ex-data e))) keyword)] + (str (:error body) "\n" + (:errorMessage body) "\n" + "Error class:" (:errorClass body)))) (.getMessage e))] (log/error (u/format-color 'red "Error running query:\n%s" message)) ;; Re-throw a new exception with `message` set to the extracted message diff --git a/src/metabase/driver/generic_sql.clj b/src/metabase/driver/generic_sql.clj index eb8d85a733c742674ea998a5538cc29d993475b2..e5e893257f8f661b0c8931cb7b34c1da8eee6191 100644 --- a/src/metabase/driver/generic_sql.clj +++ b/src/metabase/driver/generic_sql.clj @@ -46,14 +46,15 @@ ;; `HONEYSQL-FORM`. Most drivers can use the default implementations for all of these methods, but some may need to ;; override one or more (e.g. SQL Server needs to override the behavior of `apply-limit`, since T-SQL uses `TOP` ;; instead of `LIMIT`). - (apply-aggregation [this honeysql-form, ^Map query] "*OPTIONAL*.") - (apply-breakout [this honeysql-form, ^Map query] "*OPTIONAL*.") - (apply-fields [this honeysql-form, ^Map query] "*OPTIONAL*.") - (apply-filter [this honeysql-form, ^Map query] "*OPTIONAL*.") - (apply-join-tables [this honeysql-form, ^Map query] "*OPTIONAL*.") - (apply-limit [this honeysql-form, ^Map query] "*OPTIONAL*.") - (apply-order-by [this honeysql-form, ^Map query] "*OPTIONAL*.") - (apply-page [this honeysql-form, ^Map query] "*OPTIONAL*.") + (apply-source-table [this honeysql-form, ^Map query] "*OPTIONAL*.") + (apply-aggregation [this honeysql-form, ^Map query] "*OPTIONAL*.") + (apply-breakout [this honeysql-form, ^Map query] "*OPTIONAL*.") + (apply-fields [this honeysql-form, ^Map query] "*OPTIONAL*.") + (apply-filter [this honeysql-form, ^Map query] "*OPTIONAL*.") + (apply-join-tables [this honeysql-form, ^Map query] "*OPTIONAL*.") + (apply-limit [this honeysql-form, ^Map query] "*OPTIONAL*.") + (apply-order-by [this honeysql-form, ^Map query] "*OPTIONAL*.") + (apply-page [this honeysql-form, ^Map query] "*OPTIONAL*.") (column->base-type ^clojure.lang.Keyword [this, ^Keyword column-type] "Given a native DB column type, return the corresponding `Field` `base-type`.") @@ -389,6 +390,7 @@ {:active-tables fast-active-tables ;; don't resolve the vars yet so during interactive dev if the underlying impl changes we won't have to reload all ;; the drivers + :apply-source-table (resolve 'metabase.driver.generic-sql.query-processor/apply-source-table) :apply-aggregation (resolve 'metabase.driver.generic-sql.query-processor/apply-aggregation) :apply-breakout (resolve 'metabase.driver.generic-sql.query-processor/apply-breakout) :apply-fields (resolve 'metabase.driver.generic-sql.query-processor/apply-fields) diff --git a/src/metabase/driver/generic_sql/query_processor.clj b/src/metabase/driver/generic_sql/query_processor.clj index c486249fb9846b9d37be9c711134db822ba2ac58..d4d823418cef9261090736abe695d559be990416 100644 --- a/src/metabase/driver/generic_sql/query_processor.clj +++ b/src/metabase/driver/generic_sql/query_processor.clj @@ -5,7 +5,6 @@ [clojure.tools.logging :as log] [honeysql [core :as hsql] - [format :as hformat] [helpers :as h]] [metabase [driver :as driver] @@ -32,12 +31,6 @@ Each nested query increments this counter by 1." 0) -;; register the function "distinct-count" with HoneySQL -;; (hsql/format :%distinct-count.x) -> "count(distinct x)" -(defmethod hformat/fn-handler "distinct-count" [_ field] - (str "count(distinct " (hformat/to-sql field) ")")) - - ;;; ## Formatting (defn- qualified-alias @@ -318,7 +311,10 @@ (h/limit items) (h/offset (* items (dec page))))) -(defn- apply-source-table [honeysql-form {{table-name :name, schema :schema} :source-table}] +(defn apply-source-table + "Apply `source-table` clause to `honeysql-form`. Default implementation of `apply-source-table` for SQL drivers. + Override as needed." + [_ honeysql-form {{table-name :name, schema :schema} :source-table}] {:pre [table-name]} (h/from honeysql-form (hx/qualify-and-escape-dots schema table-name))) @@ -338,7 +334,7 @@ ;; will get swapped around and we'll be left with old version of the function that nobody implements ;; 2) This is a vector rather than a map because the order the clauses get handled is important for some drivers. ;; For example, Oracle needs to wrap the entire query in order to apply its version of limit (`WHERE ROWNUM`). - [:source-table (u/drop-first-arg apply-source-table) + [:source-table #'sql/apply-source-table :source-query apply-source-query :aggregation #'sql/apply-aggregation :breakout #'sql/apply-breakout @@ -498,7 +494,11 @@ second) ; so just return the part of the exception that is relevant (.getMessage e))) -(defn- do-with-try-catch {:style/indent 0} [f] +(defn do-with-try-catch + "Tries to run the function `f`, catching and printing exception chains if SQLException is thrown, + and rethrowing the exception as an Exception with a nicely formatted error message." + {:style/indent 0} + [f] (try (f) (catch SQLException e (log/error (jdbc/print-sql-exception-chain e)) diff --git a/src/metabase/driver/googleanalytics/query_processor.clj b/src/metabase/driver/googleanalytics/query_processor.clj index 8c720d7620ed5283d480396766ac1e586ac7d1f6..41e95cf52b83aa62341bae008f1d5ac326a83df0 100644 --- a/src/metabase/driver/googleanalytics/query_processor.clj +++ b/src/metabase/driver/googleanalytics/query_processor.clj @@ -255,8 +255,8 @@ (defn- built-in-metrics [{query :query}] - (if-not (empty? (aggregations query)) - (s/join "," (for [[aggregation-type metric-name] (aggregations query) + (when-let [ags (seq (aggregations query))] + (s/join "," (for [[aggregation-type metric-name] ags :when (and aggregation-type (= :metric (qputil/normalize-token aggregation-type)) (string? metric-name))] diff --git a/src/metabase/driver/hive_like.clj b/src/metabase/driver/hive_like.clj new file mode 100644 index 0000000000000000000000000000000000000000..d7bd5f49e405f0862a031e2b66af92928a9d98f0 --- /dev/null +++ b/src/metabase/driver/hive_like.clj @@ -0,0 +1,141 @@ +(ns metabase.driver.hive-like + (:require [clojure.java.jdbc :as jdbc] + [honeysql + [core :as hsql] + [format :as hformat]] + [metabase.driver.generic-sql.util.unprepare :as unprepare] + [metabase.util.honeysql-extensions :as hx] + [toucan.db :as db]) + (:import java.util.Date)) + +(def column->base-type + "Map of Spark SQL (Hive) column types -> Field base types. + Add more mappings here as you come across them." + {;; Numeric types + :tinyint :type/Integer + :smallint :type/Integer + :int :type/Integer + :integer :type/Integer + :bigint :type/BigInteger + :float :type/Float + :double :type/Float + (keyword "double precision") :type/Float + :decimal :type/Decimal + ;; Date/Time types + :timestamp :type/DateTime + :date :type/Date + :interval :type/* + :string :type/Text + :varchar :type/Text + :char :type/Text + :boolean :type/Boolean + :binary :type/*}) + +(def now + "A SQL function call returning the current time" + (hsql/raw "NOW()")) + +(defn unix-timestamp->timestamp + "Converts datetime string to a valid timestamp" + [expr seconds-or-milliseconds] + (hx/->timestamp + (hsql/call :from_unixtime (case seconds-or-milliseconds + :seconds expr + :milliseconds (hx// expr 1000))))) + +(defn- date-format [format-str expr] + (hsql/call :date_format expr (hx/literal format-str))) + +(defn- str-to-date [format-str expr] + (hx/->timestamp + (hsql/call :from_unixtime + (hsql/call :unix_timestamp + expr (hx/literal format-str))))) + +(defn- trunc-with-format [format-str expr] + (str-to-date format-str (date-format format-str expr))) + +(defn date + "Converts `expr` into a date, truncated to `unit`, using Hive SQL dialect functions" + [unit expr] + (case unit + :default expr + :minute (trunc-with-format "yyyy-MM-dd HH:mm" (hx/->timestamp expr)) + :minute-of-hour (hsql/call :minute (hx/->timestamp expr)) + :hour (trunc-with-format "yyyy-MM-dd HH" (hx/->timestamp expr)) + :hour-of-day (hsql/call :hour (hx/->timestamp expr)) + :day (trunc-with-format "yyyy-MM-dd" (hx/->timestamp expr)) + :day-of-week (hx/->integer (date-format "u" + (hx/+ (hx/->timestamp expr) + (hsql/raw "interval '1' day")))) + :day-of-month (hsql/call :dayofmonth (hx/->timestamp expr)) + :day-of-year (hx/->integer (date-format "D" (hx/->timestamp expr))) + :week (hsql/call :date_sub + (hx/+ (hx/->timestamp expr) + (hsql/raw "interval '1' day")) + (date-format "u" + (hx/+ (hx/->timestamp expr) + (hsql/raw "interval '1' day")))) + :week-of-year (hsql/call :weekofyear (hx/->timestamp expr)) + :month (hsql/call :trunc (hx/->timestamp expr) (hx/literal :MM)) + :month-of-year (hsql/call :month (hx/->timestamp expr)) + :quarter (hsql/call :add_months + (hsql/call :trunc (hx/->timestamp expr) (hx/literal :year)) + (hx/* (hx/- (hsql/call :quarter (hx/->timestamp expr)) + 1) + 3)) + :quarter-of-year (hsql/call :quarter (hx/->timestamp expr)) + :year (hsql/call :year (hx/->timestamp expr)))) + +(defn date-interval + "Returns a SQL expression to calculate a time interval using the Hive SQL dialect" + [unit amount] + (hsql/raw (format "(NOW() + INTERVAL '%d' %s)" (int amount) (name unit)))) + +(defn string-length-fn + "A SQL function call that returns the string length of `field-key`" + [field-key] + (hsql/call :length field-key)) + +;; ignore the schema when producing the identifier +(defn qualified-name-components + "Return the pieces that represent a path to FIELD, of the form `[table-name parent-fields-name* field-name]`. + This function should be used by databases where schemas do not make much sense." + [{field-name :name, table-id :table_id, parent-id :parent_id}] + (conj (vec (if-let [parent (metabase.models.field/Field parent-id)] + (qualified-name-components parent) + (let [{table-name :name, schema :schema} (db/select-one ['Table :name :schema], :id table-id)] + [table-name]))) + field-name)) + +(defn field->identifier + "Returns an identifier for the given field" + [field] + (apply hsql/qualify (qualified-name-components field))) + +(defn- run-query + "Run the query itself." + [{sql :query, params :params, remark :remark} connection] + (let [sql (str "-- " remark "\n" (hx/unescape-dots sql)) + statement (into [sql] params) + [columns & rows] (jdbc/query connection statement {:identifiers identity, :as-arrays? true})] + {:rows (or rows []) + :columns columns})) + +(defn run-query-without-timezone + "Runs the given query without trying to set a timezone" + [driver settings connection query] + (run-query query connection)) + +(defmethod hformat/fn-handler "hive-like-from-unixtime" [_ datetime-literal] + (hformat/to-sql + (hsql/call :from_unixtime + (hsql/call :unix_timestamp + datetime-literal + (hx/literal "yyyy-MM-dd\\\\'T\\\\'HH:mm:ss.SSS\\\\'Z\\\\'"))))) + +(defn unprepare + "Convert a normal SQL `[statement & prepared-statement-args]` vector into a flat, non-prepared statement. + Deals with iso-8601-fn in a Hive compatible way" + [sql-and-args] + (unprepare/unprepare sql-and-args :iso-8601-fn :hive-like-from-unixtime)) diff --git a/src/metabase/driver/mongo/query_processor.clj b/src/metabase/driver/mongo/query_processor.clj index 05c99968fc9eccfc7b29116642fa46505b16b86b..7ec04960d424279b1685c321fb0542161f69f1ec 100644 --- a/src/metabase/driver/mongo/query_processor.clj +++ b/src/metabase/driver/mongo/query_processor.clj @@ -18,8 +18,8 @@ [operators :refer :all]]) (:import java.sql.Timestamp java.util.Date - [metabase.query_processor.interface AgFieldRef DateTimeField DateTimeValue Field RelativeDateTimeValue - Value] + [metabase.query_processor.interface AgFieldRef DateTimeField DateTimeValue Field FieldLiteral + RelativeDateTimeValue Value] org.bson.types.ObjectId org.joda.time.DateTime)) @@ -91,6 +91,13 @@ (->initial-rvalue [this] (str \$ (field->name this "."))) + FieldLiteral + (->lvalue [this] + (field->name this "___")) + + (->initial-rvalue [this] + (str \$ (field->name this "."))) + AgFieldRef (->lvalue [{:keys [index]}] (let [{:keys [aggregation-type]} (nth (:aggregation (:query *query*)) index)] @@ -177,7 +184,7 @@ {:___date (u/format-date format-string v)})) extract (u/rpartial u/date-extract value)] (case (or unit :default) - :default (u/->Date value) + :default (some-> value u/->Date) :minute (stringify "yyyy-MM-dd'T'HH:mm:00") :minute-of-hour (extract :minute) :hour (stringify "yyyy-MM-dd'T'HH:00:00") diff --git a/src/metabase/driver/mysql.clj b/src/metabase/driver/mysql.clj index 52f42b5e2f5ec5b6de27ae5891f8bc21d6853909..fcc46e09633f51c573d696572cea3ccf2541d6ae 100644 --- a/src/metabase/driver/mysql.clj +++ b/src/metabase/driver/mysql.clj @@ -1,6 +1,7 @@ (ns metabase.driver.mysql "MySQL driver. Builds off of the Generic SQL driver." (:require [clj-time + [coerce :as tcoerce] [core :as t] [format :as time]] [clojure @@ -108,24 +109,30 @@ (time/formatter "ZZ")) (defn- timezone-id->offset-str - "Get an appropriate timezone offset string for a timezone with `timezone-id`. MySQL only accepts these offsets as - strings like `-8:00`. + "Get an appropriate timezone offset string for a timezone with `timezone-id` and `date-time`. MySQL only accepts + these offsets as strings like `-8:00`. - (timezone-id->offset-str \"US/Pacific\") ; -> \"-08:00\" + (timezone-id->offset-str \"US/Pacific\", date-time) ; -> \"-08:00\" - Returns `nil` if `timezone-id` is itself `nil`." - [^String timezone-id] + Returns `nil` if `timezone-id` is itself `nil`. The `date-time` must be included as some timezones vary their + offsets at different times of the year (i.e. daylight savings time)." + [^String timezone-id date-time] (when timezone-id - (time/unparse (.withZone timezone-offset-formatter (t/time-zone-for-id timezone-id)) (t/now)))) + (time/unparse (.withZone timezone-offset-formatter (t/time-zone-for-id timezone-id)) date-time))) -(def ^:private ^String system-timezone-offset-str - (timezone-id->offset-str (.getID (TimeZone/getDefault)))) +(defn- ^String system-timezone->offset-str + "Get the system/JVM timezone offset specified at `date-time`. The time is needed here as offsets can change for a + given timezone based on the time of year (i.e. daylight savings time)." + [date-time] + (timezone-id->offset-str (.getID (TimeZone/getDefault)) date-time)) ;; MySQL doesn't seem to correctly want to handle timestamps no matter how nicely we ask. SAD! Thus we will just ;; convert them to appropriate timestamp literals and include functions to convert timezones as needed (defmethod sqlqp/->honeysql [MySQLDriver Date] [_ date] - (let [report-timezone-offset-str (timezone-id->offset-str (driver/report-timezone))] + (let [date-as-dt (tcoerce/from-date date) + report-timezone-offset-str (timezone-id->offset-str (driver/report-timezone) date-as-dt) + system-timezone-offset-str (system-timezone->offset-str date-as-dt)] (if (and report-timezone-offset-str (not= report-timezone-offset-str system-timezone-offset-str)) ;; if we have a report timezone we want to generate SQL like convert_tz('2004-01-01T12:00:00','-8:00','-2:00') diff --git a/src/metabase/driver/oracle.clj b/src/metabase/driver/oracle.clj index 61b339f33102e5a9a5480a866a47b215260693cd..4f1e22b46939003356f344971800ad571af8d97f 100644 --- a/src/metabase/driver/oracle.clj +++ b/src/metabase/driver/oracle.clj @@ -78,7 +78,7 @@ "Apply truncation / extraction to a date field or value for Oracle." [unit v] (case unit - :default (hx/->date v) + :default (some-> v hx/->date) :minute (trunc :mi v) ;; you can only extract minute + hour from TIMESTAMPs, even though DATEs still have them (WTF), so cast first :minute-of-hour (hsql/call :extract :minute (hx/->timestamp v)) diff --git a/src/metabase/driver/sparksql.clj b/src/metabase/driver/sparksql.clj new file mode 100644 index 0000000000000000000000000000000000000000..419dba5a3ba5e0d1fb82a810bba656cc18c16047 --- /dev/null +++ b/src/metabase/driver/sparksql.clj @@ -0,0 +1,229 @@ +(ns metabase.driver.sparksql + (:require [clojure + [set :as set] + [string :as s]] + [clojure.java.jdbc :as jdbc] + [honeysql + [core :as hsql] + [helpers :as h]] + [metabase + [config :as config] + [driver :as driver] + [util :as u]] + [metabase.driver + [generic-sql :as sql] + [hive-like :as hive-like]] + [metabase.driver.generic-sql.query-processor :as sqlqp] + [metabase.query-processor.util :as qputil] + [metabase.util.honeysql-extensions :as hx]) + (:import clojure.lang.Reflector + java.sql.DriverManager + metabase.query_processor.interface.Field)) + +(defrecord SparkSQLDriver [] + clojure.lang.Named + (getName [_] "Spark SQL")) + + +;;; ------------------------------------------ Custom HoneySQL Clause Impls ------------------------------------------ + +(def ^:private source-table-alias "t1") + +(defn- resolve-table-alias [{:keys [schema-name table-name special-type field-name] :as field}] + (let [source-table (or (get-in sqlqp/*query* [:query :source-table]) + (get-in sqlqp/*query* [:query :source-query :source-table]))] + (if (and (= schema-name (:schema source-table)) + (= table-name (:name source-table))) + (-> (assoc field :schema-name nil) + (assoc :table-name source-table-alias)) + (if-let [matching-join-table (->> (get-in sqlqp/*query* [:query :join-tables]) + (filter #(and (= schema-name (:schema %)) + (= table-name (:table-name %)))) + first)] + (-> (assoc field :schema-name nil) + (assoc :table-name (:join-alias matching-join-table))) + field)))) + +(defmethod sqlqp/->honeysql [SparkSQLDriver Field] + [driver field-before-aliasing] + (let [{:keys [schema-name table-name special-type field-name]} (resolve-table-alias field-before-aliasing) + field (keyword (hx/qualify-and-escape-dots schema-name table-name field-name))] + (cond + (isa? special-type :type/UNIXTimestampSeconds) (sql/unix-timestamp->timestamp driver field :seconds) + (isa? special-type :type/UNIXTimestampMilliseconds) (sql/unix-timestamp->timestamp driver field :milliseconds) + :else field))) + +(defn- apply-join-tables + [honeysql-form {join-tables :join-tables, {source-table-name :name, source-schema :schema} :source-table}] + (loop [honeysql-form honeysql-form, [{:keys [table-name pk-field source-field schema join-alias]} & more] join-tables] + (let [honeysql-form (h/merge-left-join honeysql-form + [(hx/qualify-and-escape-dots schema table-name) (keyword join-alias)] + [:= (hx/qualify-and-escape-dots source-table-alias (:field-name source-field)) + (hx/qualify-and-escape-dots join-alias (:field-name pk-field))])] + (if (seq more) + (recur honeysql-form more) + honeysql-form)))) + +(defn- apply-page-using-row-number-for-offset + "Apply `page` clause to HONEYSQL-FROM, using row_number() for drivers that do not support offsets" + [honeysql-form {{:keys [items page]} :page}] + (let [offset (* (dec page) items)] + (if (zero? offset) + ;; if there's no offset we can simply use limit + (h/limit honeysql-form items) + ;; if we need to do an offset we have to do nesting to generate a row number and where on that + (let [over-clause (format "row_number() OVER (%s)" + (first (hsql/format (select-keys honeysql-form [:order-by]) + :allow-dashed-names? true + :quoting :mysql)))] + (-> (apply h/select (map last (:select honeysql-form))) + (h/from (h/merge-select honeysql-form [(hsql/raw over-clause) :__rownum__])) + (h/where [:> :__rownum__ offset]) + (h/limit items)))))) + +(defn- apply-source-table + [honeysql-form {{table-name :name, schema :schema} :source-table}] + {:pre [table-name]} + (h/from honeysql-form [(hx/qualify-and-escape-dots schema table-name) source-table-alias])) + + +;;; ------------------------------------------- Other Driver Method Impls -------------------------------------------- + +(defn- sparksql + "Create a database specification for a Spark SQL database." + [{:keys [host port db jdbc-flags] + :or {host "localhost", port 10000, db "", jdbc-flags ""} + :as opts}] + ;; manually register our FixedHiveDriver with java.sql.DriverManager and make sure it's the only driver returned for + ;; jdbc:hive2, since we do not want to use the driver registered by the super class of our FixedHiveDriver. + ;; + ;; Class/forName and invokeConstructor is required to make this compile, but it may be possible to solve this with + ;; the right project.clj magic + (DriverManager/registerDriver + (Reflector/invokeConstructor + (Class/forName "metabase.driver.FixedHiveDriver") + (into-array []))) + (loop [] + (when-let [driver (try + (DriverManager/getDriver "jdbc:hive2://localhost:10000") + (catch java.sql.SQLException _ + nil))] + (when-not (instance? (Class/forName "metabase.driver.FixedHiveDriver") driver) + (DriverManager/deregisterDriver driver) + (recur)))) + (merge {:classname "metabase.driver.FixedHiveDriver" + :subprotocol "hive2" + :subname (str "//" host ":" port "/" db jdbc-flags)} + (dissoc opts :host :port :jdbc-flags))) + +(defn- connection-details->spec [details] + (-> details + (update :port (fn [port] + (if (string? port) + (Integer/parseInt port) + port))) + (set/rename-keys {:dbname :db}) + sparksql + (sql/handle-additional-options details))) + +(defn- dash-to-underscore [s] + (when s + (s/replace s #"-" "_"))) + +;; workaround for SPARK-9686 Spark Thrift server doesn't return correct JDBC metadata +(defn- describe-database [driver {:keys [details] :as database}] + {:tables (with-open [conn (jdbc/get-connection (sql/db->jdbc-connection-spec database))] + (set (for [result (jdbc/query {:connection conn} + ["show tables"])] + {:name (:tablename result) + :schema (when (> (count (:database result)) 0) + (:database result))})))}) + +;; workaround for SPARK-9686 Spark Thrift server doesn't return correct JDBC metadata +(defn- describe-table [driver {:keys [details] :as database} table] + (with-open [conn (jdbc/get-connection (sql/db->jdbc-connection-spec database))] + {:name (:name table) + :schema (:schema table) + :fields (set (for [result (jdbc/query {:connection conn} + [(if (:schema table) + (format "describe `%s`.`%s`" + (dash-to-underscore (:schema table)) + (dash-to-underscore (:name table))) + (str "describe " (dash-to-underscore (:name table))))])] + {:name (:col_name result) + :database-type (:data_type result) + :base-type (hive-like/column->base-type (keyword (:data_type result)))}))})) + +;; we need this because transactions are not supported in Hive 1.2.1 +;; bound variables are not supported in Spark SQL (maybe not Hive either, haven't checked) +(defn- execute-query + "Process and run a native (raw SQL) QUERY." + [driver {:keys [database settings], query :native, :as outer-query}] + (let [query (-> (assoc query :remark (qputil/query->remark outer-query)) + (assoc :query (if (seq (:params query)) + (hive-like/unprepare (cons (:query query) (:params query))) + (:query query))) + (dissoc :params))] + (sqlqp/do-with-try-catch + (fn [] + (let [db-connection (sql/db->jdbc-connection-spec database)] + (hive-like/run-query-without-timezone driver settings db-connection query)))))) + + +(u/strict-extend SparkSQLDriver + driver/IDriver + (merge (sql/IDriverSQLDefaultsMixin) + {:date-interval (u/drop-first-arg hive-like/date-interval) + :describe-database describe-database + :describe-table describe-table + :describe-table-fks (constantly #{}) + :details-fields (constantly [{:name "host" + :display-name "Host" + :default "localhost"} + {:name "port" + :display-name "Port" + :type :integer + :default 10000} + {:name "dbname" + :display-name "Database name" + :placeholder "default"} + {:name "user" + :display-name "Database username" + :placeholder "What username do you use to login to the database?"} + {:name "password" + :display-name "Database password" + :type :password + :placeholder "*******"} + {:name "jdbc-flags" + :display-name "Additional JDBC settings, appended to the connection string" + :placeholder ";transportMode=http"}]) + :execute-query execute-query + :features (constantly (set/union #{:basic-aggregations + :binning + :expression-aggregations + :expressions + :native-parameters + :native-query-params + :nested-queries + :standard-deviation-aggregations} + (when-not config/is-test? + ;; during unit tests don't treat Spark SQL as having FK support + #{:foreign-keys})))}) + sql/ISQLDriver + (merge (sql/ISQLDriverDefaultsMixin) + {:apply-page (u/drop-first-arg apply-page-using-row-number-for-offset) + :apply-source-table (u/drop-first-arg apply-source-table) + :apply-join-tables (u/drop-first-arg apply-join-tables) + :column->base-type (u/drop-first-arg hive-like/column->base-type) + :connection-details->spec (u/drop-first-arg connection-details->spec) + :date (u/drop-first-arg hive-like/date) + :field->identifier (u/drop-first-arg hive-like/field->identifier) + :quote-style (constantly :mysql) + :current-datetime-fn (u/drop-first-arg (constantly hive-like/now)) + :string-length-fn (u/drop-first-arg hive-like/string-length-fn) + :unix-timestamp->timestamp (u/drop-first-arg hive-like/unix-timestamp->timestamp)})) + +(defn -init-driver + "Register the SparkSQL driver." + [] + (driver/register-driver! :sparksql (SparkSQLDriver.))) diff --git a/src/metabase/email/_footer_pulse.mustache b/src/metabase/email/_footer_pulse.mustache new file mode 100644 index 0000000000000000000000000000000000000000..a0a2e699733f0a2d7b1dd1032c4979d9dee45a55 --- /dev/null +++ b/src/metabase/email/_footer_pulse.mustache @@ -0,0 +1,11 @@ + </div> + {{#quotation}} + <div style="padding-top: 2em; padding-bottom: 1em; padding-left: 16px; text-align: left; color: #CCCCCC; font-size: small;">"{{quotation}}"<br/>- {{quotationAuthor}}</div> + {{/quotation}} + {{#logoFooter}} + <div style="padding-bottom: 2em; padding-top: 1em; padding-left: 16px; text-align: left;"> + <img width="32" height="40" src="http://static.metabase.com/email_logo.png"/> + </div> + {{/logoFooter}} +</body> +</html> diff --git a/src/metabase/email/_header.mustache b/src/metabase/email/_header.mustache index 9541f783cac427b737057b370af6a5eae331689a..85c926ad42a85155f06ec1d9f7f7e93a55e1297f 100644 --- a/src/metabase/email/_header.mustache +++ b/src/metabase/email/_header.mustache @@ -14,4 +14,4 @@ <img width="47" height="60" src="http://static.metabase.com/email_logo.png"/> </div> {{/logoHeader}} - <div class="container" style="margin: 0 auto; padding: 0 0 2em 0; max-width: 500px; font-size: 16px; line-height: 24px; color: #616D75;"> + <div class="container" style="margin: 0; padding: 0 0 2em 0; max-width: 100%; font-size: 16px; line-height: 24px; color: #616D75;"> diff --git a/src/metabase/email/alert.mustache b/src/metabase/email/alert.mustache index 7b63947376755bd2fb3eeeadf4e007ac391d66ae..512ede5d4a347f97acf148056f41bf435811a4ba 100644 --- a/src/metabase/email/alert.mustache +++ b/src/metabase/email/alert.mustache @@ -1,7 +1,6 @@ {{> metabase/email/_header}} - <p><a href="{{questionURL}}">{{questionName}}</a> has {{alertCondition}}.</p> {{#firstRunOnly?}} <p>We’ll stop sending you alerts about this question now.</p> {{/firstRunOnly?}} {{{pulse}}} -{{> metabase/email/_footer}} +{{> metabase/email/_footer_pulse}} diff --git a/src/metabase/email/messages.clj b/src/metabase/email/messages.clj index 6464251693070ddbf104a522ee23078c706c1a86..4639f218e56d82b5a838418383f7689761dcde57 100644 --- a/src/metabase/email/messages.clj +++ b/src/metabase/email/messages.clj @@ -188,7 +188,7 @@ (defn- pulse-context [pulse] (merge {:emailType "pulse" :pulseName (:name pulse) - :sectionStyle (render/style render/section-style) + :sectionStyle (render/style (render/section-style)) :colorGrey4 render/color-gray-4 :logoFooter true} (random-quote-context))) @@ -204,19 +204,21 @@ :content-type content-type :file-name (format "%s.%s" card-name ext) :content (-> attachment-file .toURI .toURL) - :description (format "Full results for '%s'" card-name)})) + :description (format "More results for '%s'" card-name)})) (defn- result-attachments [results] (remove nil? (apply concat - (for [{{card-name :name, csv? :include_csv, xls? :include_xls} :card :as result} results - :when (and (or csv? xls?) - (seq (get-in result [:result :data :rows])))] - [(when-let [temp-file (and csv? (create-temp-file "csv"))] + (for [{{card-name :name, :as card} :card :as result} results + :let [{:keys [rows] :as result-data} (get-in result [:result :data])] + :when (seq rows)] + [(when-let [temp-file (and (render/include-csv-attachment? card result-data) + (create-temp-file "csv"))] (export/export-to-csv-writer temp-file result) (create-result-attachment-map "csv" card-name temp-file)) - (when-let [temp-file (and xls? (create-temp-file "xlsx"))] + (when-let [temp-file (and (render/include-xls-attachment? card result-data) + (create-temp-file "xlsx"))] (export/export-to-xlsx-file temp-file result) (create-result-attachment-map "xlsx" card-name temp-file))])))) @@ -264,7 +266,7 @@ (merge {:questionURL (url/card-url card-id) :questionName card-name :emailType "alert" - :sectionStyle render/section-style + :sectionStyle (render/section-style) :colorGrey4 render/color-gray-4 :logoFooter true} (random-quote-context) @@ -317,7 +319,7 @@ (defn send-new-alert-email! "Send out the initial 'new alert' email to the `CREATOR` of the alert" [{:keys [creator] :as alert}] - (send-email! creator "You setup an alert" new-alert-template + (send-email! creator "You set up an alert" new-alert-template (default-alert-context alert alert-condition-text))) (defn send-you-unsubscribed-alert-email! diff --git a/src/metabase/email/pulse.mustache b/src/metabase/email/pulse.mustache index d2f5e05cc7a2e1518f250bae9bdee722dfff6a20..e024ababd09f0a304f9949164db62eaea1515d6f 100644 --- a/src/metabase/email/pulse.mustache +++ b/src/metabase/email/pulse.mustache @@ -1,8 +1,8 @@ {{> metabase/email/_header}} {{#pulseName}} - <h1 style="{{sectionStyle}} margin: 16px; color: {{colorGrey4}};"> + <h1 style="{{sectionStyle}} margin: 16px; color: {{color-brand}};"> {{pulseName}} </h1> {{/pulseName}} {{{pulse}}} -{{> metabase/email/_footer}} +{{> metabase/email/_footer_pulse}} diff --git a/src/metabase/feature_extraction/core.clj b/src/metabase/feature_extraction/core.clj index f40d8ad643505d1f62aadc7081bb4e80ace6629b..3610ef7d6d5a22ca754f79c9061e556e4112c5e3 100644 --- a/src/metabase/feature_extraction/core.clj +++ b/src/metabase/feature_extraction/core.clj @@ -126,18 +126,11 @@ :sample? (sampled? opts dataset) :comparables (comparables table)})) -(defn index-of - "Return index of the first element in `coll` for which `pred` reutrns true." - [pred coll] - (first (keep-indexed (fn [i x] - (when (pred x) i)) - coll))) - (defn- ensure-aligment [fields cols rows] (if (not= fields (take 2 cols)) (eduction (map (apply juxt (for [field fields] - (let [idx (index-of #{field} cols)] + (let [idx (u/index-of #{field} cols)] #(nth % idx))))) rows) rows)) diff --git a/src/metabase/feature_extraction/costs.clj b/src/metabase/feature_extraction/costs.clj index 5a4d591d11bff1d8bd7c8e433c0a65ed385e9096..660c25b56ad721c993bd0d08de38174afdf08a91 100644 --- a/src/metabase/feature_extraction/costs.clj +++ b/src/metabase/feature_extraction/costs.clj @@ -1,6 +1,7 @@ (ns metabase.feature-extraction.costs "Predicates for limiting resource expanditure during feature extraction." (:require [metabase.models.setting :refer [defsetting] :as setting] + [puppetlabs.i18n.core :refer [tru]] [schema.core :as s])) (def ^:private query-costs {:cache 1 @@ -24,7 +25,7 @@ (s/maybe (s/enum "exact" "approximate" "extended"))) (defsetting xray-max-cost - "Cap resorce expanditure for all x-rays. (exact, approximate, or extended)" + (tru "Cap resorce expanditure for all x-rays. (exact, approximate, or extended)") :type :string :default "extended" :setter (fn [new-value] @@ -54,7 +55,7 @@ max-cost))})) (defsetting enable-xrays - "Should x-raying be available at all?" + (tru "Should x-raying be available at all?") :type :boolean :default true) diff --git a/src/metabase/feature_extraction/feature_extractors.clj b/src/metabase/feature_extraction/feature_extractors.clj index de14e607e08a343d4d794c5902f3547d78bcce98..230337b9178f0e46ef56e45e16d17451bd7a34e4 100644 --- a/src/metabase/feature_extraction/feature_extractors.clj +++ b/src/metabase/feature_extraction/feature_extractors.clj @@ -10,7 +10,7 @@ [math :as math :refer [safe-divide]] [timeseries :as ts] [values :as values]] - [metabase.models.table :refer [Table]] + [metabase.models.field :as field] [metabase.query-processor.middleware.binning :as binning] [metabase [query-processor :as qp] @@ -116,10 +116,10 @@ ; The largest dataset returned will be 2*target-1 points as we need at least ; 2 points per bucket for downsampling to have any effect. -(def ^:private ^Integer datapoint-target-smooth 100) -(def ^:private ^Integer datapoint-target-noisy 300) +(def ^:private ^Long ^:const datapoint-target-smooth 100) +(def ^:private ^Long ^:const datapoint-target-noisy 300) -(def ^:private ^Double noisiness-threshold 0.05) +(def ^:private ^Double ^:const noisiness-threshold 0.05) (defn- target-size [series] @@ -163,19 +163,14 @@ :week-of-year :month-of-year :quarter-of-year} :unit)) -(defn- unix-timestamp? - [{:keys [base_type special_type]}] - (and (isa? base_type :type/Integer) - (isa? special_type :type/DateTime))) - (defn- field-type [field] (if (sequential? field) (mapv field-type field) [(cond - (periodic-date-time? field) :type/Integer - (unix-timestamp? field) :type/DateTime - :else (:base_type field)) + (periodic-date-time? field) :type/Integer + (field/unix-timestamp? field) :type/DateTime + :else (:base_type field)) (or (:special_type field) :type/*)])) (defmulti @@ -211,18 +206,14 @@ (def ^:private Num [:type/Number :type/*]) (def ^:private DateTime [:type/DateTime :type/*]) (def ^:private Category [:type/* :type/Category]) -(def ^:private Any [:type/* :type/*]) (def ^:private Text [:type/Text :type/*]) (prefer-method feature-extractor Category Text) (prefer-method feature-extractor Num Category) -(prefer-method feature-extractor [DateTime Num] [Any Num]) (prefer-method x-ray Category Text) (prefer-method x-ray Num Category) -(prefer-method x-ray [DateTime Num] [Any Num]) (prefer-method comparison-vector Category Text) (prefer-method comparison-vector Num Category) -(prefer-method comparison-vector [DateTime Num] [Any Num]) (defn- histogram-extractor [{:keys [histogram]}] @@ -247,7 +238,7 @@ {:field field :model field :type (field-type field) - :table (Table (:table_id field))})) + :table (field/table field)})) (defmethod feature-extractor Num [{:keys [max-cost]} field] @@ -519,15 +510,15 @@ :display_name "Decomposition residual" :base_type :type/Float}]))))) -(defmethod feature-extractor [Any Num] +(defmethod feature-extractor [Category Num] [{:keys [max-cost]} field] (redux/post-complete - (redux/fuse {:histogram (h/histogram-aggregated first second)}) + (redux/fuse {:histogram (h/map->histogram-categorical first second)}) (merge-juxt (field-metadata-extractor field) histogram-extractor))) -(defmethod x-ray [Any Num] +(defmethod x-ray [Category Num] [{:keys [field histogram] :as features}] (-> features (update :histogram (partial histogram-aggregated->dataset field)))) diff --git a/src/metabase/feature_extraction/histogram.clj b/src/metabase/feature_extraction/histogram.clj index 1e702f70c579ed3768d18c11518d77414b1bd11a..804524aea11cea0a7a346212fc9cec57fbf2849e 100644 --- a/src/metabase/feature_extraction/histogram.clj +++ b/src/metabase/feature_extraction/histogram.clj @@ -19,15 +19,28 @@ ([^Histogram histogram] histogram) ([^Histogram histogram x] (impl/insert-categorical! histogram (when x 1) x))) -(defn histogram-aggregated - "Transducer that summarizes preaggregated data with a histogram." - [fx fw] +(defn map->histogram + "Transducer that summarizes preaggregated numerical data with a histogram." + [fbin fcount] (fn ([] (impl/create)) ([^Histogram histogram] histogram) ([^Histogram histogram e] - (let [x (fx e)] - (impl/insert-categorical! histogram (when x (fw e)) x))))) + (impl/insert-bin! histogram {:mean (fbin e) + :count (-> e fcount double)})))) + +(defn map->histogram-categorical + "Transducer that summarizes preaggregated categorical data with a histogram." + [fbin fcount] + (fn + ([] (impl/create :group-types [:categorical])) + ([^Histogram histogram] histogram) + ([^Histogram histogram e] + (let [[bin count] ((juxt fbin (comp double fcount)) e)] + (impl/insert-bin! histogram {:mean 1.0 + :count count + :target {:counts {bin count} + :missing-count 0.0}}))))) (def ^{:arglists '([^Histogram histogram])} categorical? "Returns true if given histogram holds categorical values." @@ -73,7 +86,7 @@ (defn equidistant-bins "Split histogram into `bin-width` wide bins. If `bin-width` is not given use `optimal-bin-width` to calculate optimal width. Optionally takes `min` and - `max` and projects histogram into that interval rather than hisogram bounds." + `max` and projects histogram into that interval rather than histogram bounds." ([^Histogram histogram] (if (categorical? histogram) (-> histogram impl/bins first :target :counts) diff --git a/src/metabase/feature_extraction/timeseries.clj b/src/metabase/feature_extraction/timeseries.clj index 87c0255f98458e553e26ef51012278f74b1b10f8..5b7db07afb82faf79943afb3944ddd44f24b5eea 100644 --- a/src/metabase/feature_extraction/timeseries.clj +++ b/src/metabase/feature_extraction/timeseries.clj @@ -150,9 +150,9 @@ We then pick out all outlier etas. These are break candidates. However as we are using a sliding window there will likely be several candidates for the same break (even when the pivot is not perfectly positioned we still expect a - significant difference between left and right half-window). We select the point - with the highest eta among consecutive points (this also means we can only - detect breaks that are more than w apart). + significant difference between left and right half-window). We select the + point with the highest eta among consecutive points (this also means we can + only detect breaks that are more than w apart). https://en.wikipedia.org/wiki/Structural_break http://journals.plos.org/plosone/article?id=10.1371/journal.pone.0059279#pone.0059279.s003" diff --git a/src/metabase/integrations/slack.clj b/src/metabase/integrations/slack.clj index aa3243976a26d6c509631ee6c2567b2e822e6f2f..9d130ec3ea48fd1f23ad549b8e52e1e881f37aac 100644 --- a/src/metabase/integrations/slack.clj +++ b/src/metabase/integrations/slack.clj @@ -3,10 +3,11 @@ [clj-http.client :as http] [clojure.tools.logging :as log] [metabase.models.setting :as setting :refer [defsetting]] + [puppetlabs.i18n.core :refer [tru]] [metabase.util :as u])) ;; Define a setting which captures our Slack api token -(defsetting slack-token "Slack API bearer token obtained from https://api.slack.com/web#authentication") +(defsetting slack-token (tru "Slack API bearer token obtained from https://api.slack.com/web#authentication")) (def ^:private ^:const ^String slack-api-base-url "https://slack.com/api") (def ^:private ^:const ^String files-channel-name "metabase_files") diff --git a/src/metabase/metabot.clj b/src/metabase/metabot.clj index f5eacf8ca79bc0cf2eed3cf5a7dc500f086ddaed..6b034ba180fadd26adee745cc7062b8800d8baf8 100644 --- a/src/metabase/metabot.clj +++ b/src/metabase/metabot.clj @@ -85,7 +85,7 @@ (defn- format-exception "Format a `Throwable` the way we'd like for posting it on slack." [^Throwable e] - (tru "Uh oh! :cry:\n>" (.getMessage e))) + (tru "Uh oh! :cry:\n> {0}" (.getMessage e))) (defmacro ^:private do-async {:style/indent 0} [& body] `(future (try ~@body diff --git a/src/metabase/middleware.clj b/src/metabase/middleware.clj index a763df65a2fb52efc281bac23b72ff753a9380dc..5376e25d3f39444d0b15889ab8ddca0fab9867cb 100644 --- a/src/metabase/middleware.clj +++ b/src/metabase/middleware.clj @@ -16,6 +16,7 @@ [setting :refer [defsetting]] [user :as user :refer [User]]] monger.json + [puppetlabs.i18n.core :refer [tru]] [toucan [db :as db] [models :as models]]) @@ -213,8 +214,9 @@ (format "%s %s; " (name k) (apply str (interpose " " vs)))))}) (defsetting ssl-certificate-public-key - "Base-64 encoded public key for this site's SSL certificate. Specify this to enable HTTP Public Key Pinning. - See http://mzl.la/1EnfqBf for more information.") + (str (tru "Base-64 encoded public key for this site's SSL certificate.") + (tru "Specify this to enable HTTP Public Key Pinning.") + (tru "See {0} for more information." "http://mzl.la/1EnfqBf"))) ;; TODO - it would be nice if we could make this a proper link in the UI; consider enabling markdown parsing #_(defn- public-key-pins-header [] @@ -307,25 +309,36 @@ ;;; ---------------------------------------------------- LOGGING ----------------------------------------------------- -(defn- log-response [{:keys [uri request-method]} {:keys [status body]} elapsed-time db-call-count] - (let [log-error #(log/error %) ; these are macros so we can't pass by value :sad: +(def ^:private jetty-stats-coll + (juxt :min-threads :max-threads :busy-threads :idle-threads :queue-size)) + +(defn- log-response [jetty-stats-fn {:keys [uri request-method]} {:keys [status body]} elapsed-time db-call-count] + (let [log-error #(log/error %) ; these are macros so we can't pass by value :sad: log-debug #(log/debug %) log-warn #(log/warn %) - [error? color log-fn] (cond - (>= status 500) [true 'red log-error] - (= status 403) [true 'red log-warn] - (>= status 400) [true 'red log-debug] - :else [false 'green log-debug])] - (log-fn (str (u/format-color color "%s %s %d (%s) (%d DB calls)" - (.toUpperCase (name request-method)) uri status elapsed-time db-call-count) + ;; stats? here is to avoid incurring the cost of collecting the Jetty stats and concatenating the extra + ;; strings when they're just going to be ignored. This is automatically handled by the macro , but is bypassed + ;; once we wrap it in a function + [error? color log-fn stats?] (cond + (>= status 500) [true 'red log-error false] + (= status 403) [true 'red log-warn false] + (>= status 400) [true 'red log-debug false] + :else [false 'green log-debug true])] + (log-fn (str (apply u/format-color color (str "%s %s %d (%s) (%d DB calls)." + (when stats? + " Jetty threads: %s/%s (%s busy, %s idle, %s queued)")) + (.toUpperCase (name request-method)) uri status elapsed-time db-call-count + (when stats? + (jetty-stats-coll (jetty-stats-fn)))) ;; only print body on error so we don't pollute our environment by over-logging (when (and error? (or (string? body) (coll? body))) (str "\n" (u/pprint-to-str body))))))) (defn log-api-call - "Middleware to log `:request` and/or `:response` by passing corresponding OPTIONS." - [handler & options] + "Takes a handler and a `jetty-stats-fn`. Logs `:request` and/or `:response` by passing corresponding + OPTIONS. `jetty-stats-fn` returns threadpool metadata that is included in the api request log" + [handler jetty-stats-fn & options] (fn [{:keys [uri], :as request}] (if (or (not (api-call? request)) (= uri "/api/health") ; don't log calls to /health or /util/logs because they clutter up @@ -334,7 +347,7 @@ (let [start-time (System/nanoTime)] (db/with-call-counting [call-count] (u/prog1 (handler request) - (log-response request <> (u/format-nanoseconds (- (System/nanoTime) start-time)) (call-count)))))))) + (log-response jetty-stats-fn request <> (u/format-nanoseconds (- (System/nanoTime) start-time)) (call-count)))))))) ;;; ----------------------------------------------- EXCEPTION HANDLING ----------------------------------------------- diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj index 421f4ef9753c93cbcf6ae772b054e75709c044fa..ac98c753a0f6b19912d1ebe8d9f055d40731d38f 100644 --- a/src/metabase/models/card.clj +++ b/src/metabase/models/card.clj @@ -18,10 +18,12 @@ [label :refer [Label]] [params :as params] [permissions :as perms] + [query :as query] [revision :as revision]] [metabase.query-processor.middleware.permissions :as qp-perms] [metabase.query-processor.util :as qputil] [metabase.util.query :as q] + [puppetlabs.i18n.core :refer [tru]] [toucan [db :as db] [models :as models]])) @@ -69,6 +71,38 @@ ;;; ---------------------------------------------- Permissions Checking ---------------------------------------------- +;; Is calculating permissions for Cards complicated? Some would say so. Refer to this handy flow chart to see how things +;; get calculated. +;; +;; Note that `can-read?/can-write?` and `pre-insert/pre-update` are the two entry points into the permissions +;; labyrinth. `pre-insert`/`pre-update` calculate permissions for the *query* (disregarding collection and publicness) +;; and thus skip to `query-perms-set`; `can-read?`/`can-write?` want to take those into account (as well as cached +;; read permissions, if available) and thus starts higher up. +;; +;; +;; can-read?/can-write? --> perms-set-taking-collection-etc-into-account +;; | +;; public? <------------------------------+---------------------------> in collection? +;; ↓ else ↓ +;; #{} ↓ collection/perms-objects-set +;; card-perms-set-for-query +;; | +;; write perms <---------------------+---------------------> read perms +;; | | +;; | does not have cached read_permissions <-----+-----> has cached read_permissions +;; | ↓ ↓ +;; +-------------------> query-perms-set <------------------+ return cached read_permssions +;; pre-insert/ ↑ | | +;; pre-update ---------------------------+ | | +;; (maybe-update | | +;; -read-perms) native card? <------+-----> mbql card? | +;; ↓ ↓ | +;; native-perms-path mbql-perms-path-set | (recursively for source card) +;; | | +;; no source card <----+----> has source card +;; ↓ +;; tables->permissions-path-set + (defn- native-permissions-path "Return the `:read` (for running) or `:write` (for saving) native permissions path for DATABASE-OR-ID." [read-or-write database-or-id] @@ -103,8 +137,18 @@ (declare query-perms-set) (defn- mbql-permissions-path-set - "Return the set of required permissions needed to run QUERY." - [read-or-write query] + "Return the set of required permissions needed to run QUERY. + + Optionally specify `disallowed-source-card-ids`: this is a sequence of Card IDs that should not be allowed to be a + source Card ID in this case. For example, you would want to disallow a Card from being its own source; when + recursing, this is used to keep track of source Card IDs we've already seen in order to prevent circular + references. + + Also optionally specify `throw-exceptions?` -- normally this function avoids throwing Exceptions to avoid breaking + things when a single Card is busted (e.g. API endpoints that filter out unreadable Cards) and instead returns 'only + admins can see this' permissions -- `#{\"db/0\"}` (DB 0 will never exist, thus normal users will never be able to + get permissions for it, but admins have root perms and will still get to see (and hopefully fix) it)." + [read-or-write query & [disallowed-source-card-ids throw-exceptions?]] {:pre [(map? query) (map? (:query query))]} (try (or @@ -116,13 +160,26 @@ ;; ;; See issue #6845 for further discussion. (when-let [source-card-id (qputil/query->source-card-id query)] - (query-perms-set (db/select-one-field :dataset_query Card :id source-card-id) :read)) + ;; If this source card ID is disallowed (e.g. due to it being a circular reference) then throw an Exception. + ;; Bye Felicia! + (when ((set disallowed-source-card-ids) source-card-id) + (throw + (Exception. + (str (tru "Cannot calculate permissions due to circular references.") + (tru "This means a question is either using itself as a source or one or more questions are using each other as sources."))))) + ;; ok, if we've decided that this is not a loooopy situation then go ahead and recurse + (query-perms-set (db/select-one-field :dataset_query Card :id source-card-id) + :read + (conj disallowed-source-card-ids source-card-id) + throw-exceptions?)) ;; otherwise if there's no source card then calculate perms based on the Tables referenced in the query (let [{:keys [query database]} (qp/expand query)] (tables->permissions-path-set read-or-write database (query->source-and-join-tables query)))) ;; if for some reason we can't expand the Card (i.e. it's an invalid legacy card) just return a set of permissions ;; that means no one will ever get to see it (except for superusers who get to see everything) (catch Throwable e + (when throw-exceptions? + (throw e)) (log/warn "Error getting permissions for card:" (.getMessage e) "\n" (u/pprint-to-str (u/filtered-stacktrace e))) #{"/db/0/"}))) ; DB 0 will never exist @@ -138,39 +195,45 @@ for read permissions you should look at a Card's `:read_permissions`, which is precalculated. If you specifically need to calculate permissions for a query directly, and ignore anything precomputed, use this function. Otherwise you should rely on one of the optimized ones below." - [{query-type :type, database :database, :as query} read-or-write] + [{query-type :type, database :database, :as query} read-or-write & [disallowed-source-card-ids throw-exceptions?]] (cond (empty? query) #{} (= (keyword query-type) :native) #{(native-permissions-path read-or-write database)} - (= (keyword query-type) :query) (mbql-permissions-path-set read-or-write query) - :else (throw (Exception. (str "Invalid query type: " query-type))))) + (= (keyword query-type) :query) (mbql-permissions-path-set read-or-write query disallowed-source-card-ids throw-exceptions?) + :else (throw (Exception. (str (tru "Invalid query type: {0}" query-type)))))) (defn- card-perms-set-for-query "Return the permissions required to `read-or-write` `card` based on its query, disregarding the collection the Card is in, whether it is publicly shared, etc. This will return precalculated `:read_permissions` if they are present." - [{read-perms :read_permissions, id :id, query :dataset_query} read-or-write] + [{read-perms :read_permissions, card-id :id, query :dataset_query} read-or-write] (cond ;; for WRITE permissions always recalculate since these will be determined relatively infrequently (hopefully) ;; e.g. when updating a Card - (= :write read-or-write) (query-perms-set query :write) - ;; if the Card has populated `:read_permissions` and we're looking up read pems return those rather than calculating - ;; on-the-fly. Cast to `set` to be extra-double-sure it's a set like we'd expect when it gets deserialized from JSON - read-perms (set read-perms) + (= :write read-or-write) (query-perms-set query :write [card-id]) + ;; if the Card has *populated* `:read_permissions` and we're looking up read pems return those rather than + ;; calculating on-the-fly. Cast to `set` to be extra-double-sure it's a set like we'd expect when it gets + ;; deserialized from JSON + (seq read-perms) (set read-perms) ;; otherwise if :read_permissions was NOT populated. This should not normally happen since the data migration ;; should have pre-populated values for all the Cards. If it does it might mean something like we fetched the Card ;; without its `read_permissions` column. Since that would be "doing something wrong" warn about it. - :else (do (log/warn "Card" id "is missing its read_permissions. Calculating them now...") - (query-perms-set query :read)))) + :else (do (log/info "Card" card-id "does not have cached read_permissions.") + (query-perms-set query :read [card-id])))) -(defn- card-perms-set-for-current-user - "Calculate the permissions required to `read-or-write` `card` *for the current user*. This takes into account whether - the Card is publicly available, or in a collection the current user can view; it also attempts to use precalcuated +(defn- card-perms-set-taking-collection-etc-into-account + "Calculate the permissions required to `read-or-write` `card`*for a user. This takes into account whether the Card is + publicly available, or in a collection the current user can view; it also attempts to use precalcuated `read_permissions` when possible. This is the function that should be used for general permissions checking for a - Card." + Card. + + This function works the same regardless of whether called with a current user (e.g. `api/*current-user*`, etc.) or + not! It simply calculates the permssions a User would need to see the Card." [{collection-id :collection_id, public-uuid :public_uuid, in-public-dash? :in_public_dashboard, - outer-query :dataset_query, :as card} + outer-query :dataset_query, card-id :id, :as card} read-or-write] + (when-not (seq card) + (throw (Exception. (str (tru "`card` is nil or empty. Cannot calculate permissions."))))) (let [source-card-id (qputil/query->source-card-id outer-query)] (cond ;; you don't need any permissions to READ a public card, which is PUBLIC by definition :D @@ -182,12 +245,6 @@ collection-id (collection/perms-objects-set collection-id read-or-write) - ;; if this is based on a source card then our permissions are based on that; recurse. You can always save a new - ;; Card based on a source card you can read, thus read/write permissions for this new Card will be the same as - ;; read permissions for its source. This is dicussed in further detail above in `mbql-permissions-path-set` - source-card-id - (card-perms-set-for-current-user (Card source-card-id) :read) - :else (card-perms-set-for-query card read-or-write)))) @@ -217,37 +274,47 @@ ;;; -------------------------------------------------- Lifecycle -------------------------------------------------- -(defn- native-query? [query-type] - (or (= query-type "native") - (= query-type :native))) - -(defn query->database-and-table-ids - "Return a map with `:database-id` and source `:table-id` that should be saved for a Card. Handles queries that use - other queries as their source (ones that come in with a `:source-table` like `card__100`) recursively, as well as - normal queries." - [outer-query] - (let [database-id (qputil/get-normalized outer-query :database) - query-type (qputil/get-normalized outer-query :type) - source-table (qputil/get-in-normalized outer-query [:query :source-table])] - (cond - (native-query? query-type) {:database-id database-id, :table-id nil} - (integer? source-table) {:database-id database-id, :table-id source-table} - (string? source-table) (let [[_ card-id] (re-find #"^card__(\d+)$" source-table)] - (db/select-one [Card [:table_id :table-id] [:database_id :database-id]] - :id (Integer/parseInt card-id)))))) - -(defn- populate-query-fields [{{query-type :type, :as outer-query} :dataset_query, :as card}] +(defn populate-query-fields + "Lift `database_id`, `table_id`, and `query_type` from query definition." + [{{query-type :type, :as outer-query} :dataset_query, :as card}] (merge (when-let [{:keys [database-id table-id]} (and query-type - (query->database-and-table-ids outer-query))] + (query/query->database-and-table-ids outer-query))] {:database_id database-id :table_id table-id :query_type (keyword query-type)}) card)) +(defn- maybe-update-read-permissions + "When inserting or updating a `card`, if `:dataset_query` is going to change, calculate the updated `:read_permssions` + and `assoc` those to the output so they get changed as well. These cached `read_permissions` are the permissions for + the underlying query, disregarding whether the Card is in a collection or present in a public Dashboard or is itself + public. Only query permissions are expensive to calculate, so that is the only thing we cache. The other stuff is + caclulated every time by `card-perms-set-taking-collection-etc-into-account`. + + (maybe-update-read-permssions card-to-be-saved) ;-> updated-card-to-be-saved" + [{query :dataset_query, card-id :id, :as card}] + (if-not (seq query) + card + ;; Calculate read_permissions using `query-perms-set`, which calculates perms based on the query along (ignoring + ;; collection perms, presence on public dashboards, etc.). + (assoc card :read_permissions (query-perms-set query + :read + ;; If this is an UPDATE operation send along the `card-id` to the + ;; list of `disallowed-source-card-ids` because, needless to say, a + ;; Card should not be allowed to use itself as a source, whether + ;; directly or indirectly. See `query-perms-set` itself for further + ;; discussion. + (when card-id [card-id]) + ;; tell `query-perms-set` to throw Exceptions so we don't end up + ;; saving a Card that is for some reason invalid + :throw-exceptions)))) + (defn- pre-insert [{query :dataset_query, :as card}] ;; TODO - make sure if `collection_id` is specified that we have write permissions for that collection - ;; Save the new Card with read permissions since calculating them dynamically is so expensive. - (u/prog1 (assoc card :read_permissions (query-perms-set query :read)) + ;; + ;; updated Card with updated read permissions when applicable. (New Cards should never be created without a valid + ;; `:dataset_query` so this should always happen) + (u/prog1 (maybe-update-read-permissions card) ;; for native queries we need to make sure the user saving the card has native query permissions for the DB ;; because users can always see native Cards and we don't want someone getting around their lack of permissions ;; that way @@ -265,8 +332,8 @@ (field-values/update-field-values-for-on-demand-dbs! field-ids)))) (defn- pre-update [{archived? :archived, query :dataset_query, :as card}] - ;; save the updated Card with updated read permissions. - (u/prog1 (assoc card :read_permissions (query-perms-set query :read)) + ;; save the updated Card with updated read permissions when applicable. + (u/prog1 (maybe-update-read-permissions card) ;; if the Card is archived, then remove it from any Dashboards (when archived? (db/delete! 'DashboardCard :card_id (u/get-id card))) @@ -318,7 +385,7 @@ (merge i/IObjectPermissionsDefaults {:can-read? (partial i/current-user-has-full-permissions? :read) :can-write? (partial i/current-user-has-full-permissions? :write) - :perms-objects-set card-perms-set-for-current-user}) + :perms-objects-set card-perms-set-taking-collection-etc-into-account}) revision/IRevisioned (assoc revision/IRevisionedDefaults diff --git a/src/metabase/models/collection.clj b/src/metabase/models/collection.clj index 7db45ffd528ffdf8f01eca7cafe0200129a89930..32b5545c5e7fcfa3964ee2bc3a783c9f27e23aab 100644 --- a/src/metabase/models/collection.clj +++ b/src/metabase/models/collection.clj @@ -9,6 +9,7 @@ [permissions :as perms]] [metabase.util :as u] [metabase.util.schema :as su] + [puppetlabs.i18n.core :refer [tru]] [schema.core :as s] [toucan [db :as db] @@ -22,8 +23,8 @@ (defn- assert-unique-slug [slug] (when (db/exists? Collection :slug slug) - (throw (ex-info "Name already taken" - {:status-code 400, :errors {:name "A collection with this name already exists"}})))) + (throw (ex-info (tru "Name already taken") + {:status-code 400, :errors {:name (tru "A collection with this name already exists")}})))) (def ^:const ^java.util.regex.Pattern hex-color-regex "Regex for a valid value of `:color`, a 7-character hex string including the preceding hash sign." @@ -32,14 +33,14 @@ (defn- assert-valid-hex-color [^String hex-color] (when (or (not (string? hex-color)) (not (re-matches hex-color-regex hex-color))) - (throw (ex-info "Invalid color" - {:status-code 400, :errors {:color "must be a valid 6-character hex color code"}})))) + (throw (ex-info (tru "Invalid color") + {:status-code 400, :errors {:color (tru "must be a valid 6-character hex color code")}})))) (defn- slugify [collection-name] ;; double-check that someone isn't trying to use a blank string as the collection name (when (str/blank? collection-name) - (throw (ex-info "Collection name cannot be blank!" - {:status-code 400, :errors {:name "cannot be blank"}}))) + (throw (ex-info (tru "Collection name cannot be blank!") + {:status-code 400, :errors {:name (tru "cannot be blank")}}))) (u/slugify collection-name collection-slug-max-length)) (defn- pre-insert [{collection-name :name, color :color, :as collection}] diff --git a/src/metabase/models/collection_revision.clj b/src/metabase/models/collection_revision.clj index c91d27974745c6647f0876e4e7f546ee2a278eba..c13317bad508be79ec444eae78bcd388b4a50112 100644 --- a/src/metabase/models/collection_revision.clj +++ b/src/metabase/models/collection_revision.clj @@ -1,5 +1,6 @@ (ns metabase.models.collection-revision (:require [metabase.util :as u] + [puppetlabs.i18n.core :refer [tru]] [toucan [db :as db] [models :as models]])) @@ -16,7 +17,7 @@ :after :json :remark :clob}) :pre-insert pre-insert - :pre-update (fn [& _] (throw (Exception. "You cannot update a CollectionRevision!")))})) + :pre-update (fn [& _] (throw (Exception. (str (tru "You cannot update a CollectionRevision!")))))})) (defn latest-id diff --git a/src/metabase/models/common.clj b/src/metabase/models/common.clj index cc35601ae04f0108acd0e13df3b66e956a90bfb5..f244680c73215b6f538f3ccacc050cc601cd6686 100644 --- a/src/metabase/models/common.clj +++ b/src/metabase/models/common.clj @@ -20,6 +20,7 @@ "America/Mexico_City" "America/Montevideo" "America/Santiago" + "America/Sao_Paulo" "America/Tijuana" "Asia/Amman" "Asia/Baghdad" diff --git a/src/metabase/models/dashboard.clj b/src/metabase/models/dashboard.clj index c3ad7a139c19b54e4250d864aca97865b45e73dc..b91bde9c8aab02434eaa095fb8ff81d75f010274 100644 --- a/src/metabase/models/dashboard.clj +++ b/src/metabase/models/dashboard.clj @@ -1,9 +1,12 @@ (ns metabase.models.dashboard (:require [clojure [data :refer [diff]] - [set :as set]] + [set :as set] + [string :as str]] [clojure.tools.logging :as log] + [metabase.automagic-dashboards.populate :as magic.populate] [metabase + [events :as events] [public-settings :as public-settings] [util :as u]] [metabase.models @@ -13,6 +16,8 @@ [interface :as i] [params :as params] [revision :as revision]] + [metabase.query-processor :as qp] + [metabase.query-processor.interface :as qpi] [metabase.models.revision.diff :refer [build-sentence]] [toucan [db :as db] @@ -226,3 +231,68 @@ (dashboard-card/update-dashboard-card! (update dashboard-card :series #(filter identity (map :id %)))))) (let [new-param-field-ids (dashboard-id->param-field-ids dashboard-or-id)] (update-field-values-for-on-demand-dbs! dashboard-or-id old-param-field-ids new-param-field-ids)))) + + +(defn- result-metadata-for-query + "Fetch the results metadata for a QUERY by running the query and seeing what the QP gives us in return." + [query] + (binding [qpi/*disable-qp-logging* true] + (get-in (qp/process-query query) [:data :results_metadata :columns]))) + +(defn- save-card! + [card] + (when (-> card :dataset_query not-empty) + (let [card (db/insert! 'Card + (-> card + (update :result_metadata #(or % (-> card + :dataset_query + result-metadata-for-query))) + (dissoc :id)))] + (events/publish-event! :card-create card) + (hydrate card :creator :dashboard_count :labels :can_write :collection)))) + +(defn- applied-filters-blurb + [applied-filters] + (some->> applied-filters + not-empty + (map (fn [{:keys [field value]}] + (format "%s %s" (str/join " " field) value))) + (str/join ", ") + (str "Filtered by: "))) + +(defn- ensure-unique-collection-name + [collection] + (let [c (db/count 'Collection :name [:like (format "%s%%" collection)])] + (if (zero? c) + collection + (format "%s %s" collection (inc c))))) + +(defn save-transient-dashboard! + "Save a denormalized description of dashboard." + [dashboard] + (let [dashcards (:ordered_cards dashboard) + dashboard (db/insert! Dashboard + (-> dashboard + (dissoc :ordered_cards :rule :related :transient_name + :transient_filters) + (assoc :description (->> dashboard + :transient_filters + applied-filters-blurb)))) + collection (magic.populate/create-collection! + (ensure-unique-collection-name + (format "Questions for the dashboard \"%s\"" (:name dashboard))) + (rand-nth magic.populate/colors) + "Automatically generated cards.")] + (doseq [dashcard dashcards] + (let [card (some-> dashcard :card (assoc :collection_id (:id collection)) save-card!) + series (some->> dashcard :series (map (fn [card] + (-> card + (assoc :collection_id (:id collection)) + save-card!)))) + dashcard (-> dashcard + (dissoc :card :id :card_id) + (update :parameter_mappings + (partial map #(assoc % :card_id (:id card)))) + (assoc :series series))] + (add-dashcard! dashboard card dashcard))) + dashboard)) diff --git a/src/metabase/models/field.clj b/src/metabase/models/field.clj index 8a995ff01cab4bc2ccade04f93fd8fdd6f1a1e0f..09ae4b590779315749401fab31743bd0b81500d0 100644 --- a/src/metabase/models/field.clj +++ b/src/metabase/models/field.clj @@ -15,7 +15,7 @@ ;;; ------------------------------------------------- Type Mappings -------------------------------------------------- -(def ^:const visibility-types +(def visibility-types "Possible values for `Field.visibility_type`." #{:normal ; Default setting. field has no visibility restrictions. :details-only ; For long blob like columns such as JSON. field is not shown in some places on the frontend. @@ -23,6 +23,37 @@ :sensitive ; Strict removal of field from all places except data model listing. queries should error if someone attempts to access. :retired}) ; For fields that no longer exist in the physical db. automatically set by Metabase. QP should error if encountered in a query. +(def has-field-values-options + "Possible options for `has_field_values`. This column is used to determine whether we keep FieldValues for a Field, + and which type of widget should be used to pick values of this Field when filtering by it in the Query Builder." + ;; AUTOMATICALLY-SET VALUES, SET DURING SYNC + ;; + ;; `nil` -- means infer which widget to use based on logic in `with-has-field-values`; this will either return + ;; `:search` or `:none`. + ;; + ;; This is the default state for Fields not marked `auto-list`. Admins cannot explicitly mark a Field as + ;; `has_field_values` `nil`. This value is also subject to automatically change in the future if the values of a + ;; Field change in such a way that it can now be marked `auto-list`. Fields marked `nil` do *not* have FieldValues + ;; objects. + ;; + #{;; The other automatically-set option. Automatically marked as a 'List' Field based on cardinality and other factors + ;; during sync. Store a FieldValues object; use the List Widget. If this Field goes over the distinct value + ;; threshold in a future sync, the Field will get switched back to `has_field_values = nil`. + :auto-list + ;; + ;; EXPLICITLY-SET VALUES, SET BY AN ADMIN + ;; + ;; Admin explicitly marked this as a 'Search' Field, which means we should *not* keep FieldValues, and should use + ;; Search Widget. + :search + ;; Admin explicitly marked this as a 'List' Field, which means we should keep FieldValues, and use the List + ;; Widget. Unlike `auto-list`, if this Field grows past the normal cardinality constraints in the future, it will + ;; remain `List` until explicitly marked otherwise. + :list + ;; Admin explicitly marked that this Field shall always have a plain-text widget, neither allowing search, nor + ;; showing a list of possible values. FieldValues not kept. + :none}) + ;;; ----------------------------------------------- Entity & Lifecycle ----------------------------------------------- @@ -92,12 +123,13 @@ (u/strict-extend (class Field) models/IModel (merge models/IModelDefaults - {:hydration-keys (constantly [:destination :field :origin]) - :types (constantly {:base_type :keyword - :special_type :keyword - :visibility_type :keyword - :description :clob - :fingerprint :json}) + {:hydration-keys (constantly [:destination :field :origin :human_readable_field]) + :types (constantly {:base_type :keyword + :special_type :keyword + :visibility_type :keyword + :description :clob + :has_field_values :keyword + :fingerprint :json}) :properties (constantly {:timestamped? true}) :pre-insert pre-insert :pre-update pre-update @@ -124,9 +156,12 @@ [{:keys [id]}] (db/select [FieldValues :field_id :values], :field_id id)) -(defn- keyed-by-field-ids - "Queries for `MODEL` instances related by `FIELDS`, returns a map - keyed by :field_id" +(defn- select-field-id->instance + "Select instances of `model` related by `field_id` FK to a Field in `fields`, and return a map of Field ID -> model + instance. This only returns a single instance for each Field! Duplicates are discarded! + + (select-field-id->instance [(Field 1) (Field 2)] FieldValues) + ;; -> {1 #FieldValues{...}, 2 #FieldValues{...}}" [fields model] (let [field-ids (set (map :id fields))] (u/key-by :field_id (when (seq field-ids) @@ -136,7 +171,7 @@ "Efficiently hydrate the `FieldValues` for a collection of FIELDS." {:batched-hydrate :values} [fields] - (let [id->field-values (keyed-by-field-ids fields FieldValues)] + (let [id->field-values (select-field-id->instance fields FieldValues)] (for [field fields] (assoc field :values (get id->field-values (:id field) []))))) @@ -144,8 +179,8 @@ "Efficiently hydrate the `FieldValues` for visibility_type normal FIELDS." {:batched-hydrate :normal_values} [fields] - (let [id->field-values (keyed-by-field-ids (filter fv/field-should-have-field-values? fields) - [FieldValues :id :human_readable_values :values :field_id])] + (let [id->field-values (select-field-id->instance (filter fv/field-should-have-field-values? fields) + [FieldValues :id :human_readable_values :values :field_id])] (for [field fields] (assoc field :values (get id->field-values (:id field) []))))) @@ -153,30 +188,48 @@ "Efficiently hydrate the `Dimension` for a collection of FIELDS." {:batched-hydrate :dimensions} [fields] - (let [id->dimensions (keyed-by-field-ids fields Dimension)] + ;; TODO - it looks like we obviously thought this code would return *all* of the Dimensions for a Field, not just + ;; one! This code is obviously wrong! It will either assoc a single Dimension or an empty vector under the + ;; `:dimensions` key!!!! + ;; TODO - consult with tom and see if fixing this will break any hacks that surely must exist in the frontend to deal + ;; with this + (let [id->dimensions (select-field-id->instance fields Dimension)] (for [field fields] (assoc field :dimensions (get id->dimensions (:id field) []))))) -(defn with-has-field-values - "Infer what the value of the `has_field_values` should be for Fields where it's not set. Admins can set this to one - of the values below, but if it's `nil` in the DB we'll infer it automatically. +(defn- is-searchable? + "Is this `field` a Field that you should be presented with a search widget for (to search its values)? If so, we can + give it a `has_field_values` value of `search`." + [{base-type :base_type}] + ;; For the time being we will consider something to be "searchable" if it's a text Field since the `starts-with` + ;; filter that powers the search queries (see `metabase.api.field/search-values`) doesn't work on anything else + (or (isa? base-type :type/Text) + (isa? base-type :type/TextLike))) + +(defn- infer-has-field-values + "Determine the value of `has_field_values` we should return for a `Field` As of 0.29.1 this doesn't require any DB + calls! :D" + [{has-field-values :has_field_values, :as field}] + (or + ;; if `has_field_values` is set in the DB, use that value; but if it's `auto-list`, return the value as `list` to + ;; avoid confusing FE code, which can remain blissfully unaware that `auto-list` is a thing + (when has-field-values + (if (= (keyword has-field-values) :auto-list) + :list + has-field-values)) + ;; otherwise if it does not have value set in DB we will infer it + (if (is-searchable? field) + :search + :none))) - * `list` = has an associated FieldValues object - * `search` = does not have FieldValues - * `none` = admin has explicitly disabled search behavior for this Field" +(defn with-has-field-values + "Infer what the value of the `has_field_values` should be for Fields where it's not set. See documentation for + `has-field-values-options` above for a more detailed explanation of what these values mean." {:batched-hydrate :has_field_values} [fields] - (let [fields-without-has-field-values-ids (set (for [field fields - :when (nil? (:has_field_values field))] - (:id field))) - fields-with-fieldvalues-ids (when (seq fields-without-has-field-values-ids) - (db/select-field :field_id FieldValues - :field_id [:in fields-without-has-field-values-ids]))] - (for [field fields] - (assoc field :has_field_values (or (:has_field_values field) - (if (contains? fields-with-fieldvalues-ids (u/get-id field)) - :list - :search)))))) + (for [field fields] + (when field + (assoc field :has_field_values (infer-has-field-values field))))) (defn readable-fields-only "Efficiently checks if each field is readable and returns only readable fields" @@ -221,3 +274,9 @@ {:arglists '([field])} [{:keys [table_id]}] (db/select-one 'Table, :id table_id)) + +(defn unix-timestamp? + "Is field a UNIX timestamp?" + [{:keys [base_type special_type]}] + (and (isa? base_type :type/Integer) + (isa? special_type :type/DateTime))) diff --git a/src/metabase/models/field_values.clj b/src/metabase/models/field_values.clj index f5ba4694ad3d0c342d2687a1debf9477805c122e..3f425ee713bbee014f5a16859627aba164eec1f7 100644 --- a/src/metabase/models/field_values.clj +++ b/src/metabase/models/field_values.clj @@ -1,21 +1,31 @@ (ns metabase.models.field-values (:require [clojure.tools.logging :as log] [metabase.util :as u] + [metabase.util.schema :as su] + [puppetlabs.i18n.core :refer [trs]] + [schema.core :as s] [toucan [db :as db] [models :as models]])) -(def ^:const ^Integer low-cardinality-threshold - "Fields with less than this many distinct values should automatically be given a special type of `:type/Category`." - 300) +(def ^:const ^Integer category-cardinality-threshold + "Fields with less than this many distinct values should automatically be given a special type of `:type/Category`. + This no longer has any meaning whatsoever as far as the backend code is concerned; it is used purely to inform + frontend behavior such as widget choices." + (int 30)) + +(def ^:const ^Integer auto-list-cardinality-threshold + "Fields with less than this many distincy values should be given a `has_field_values` value of `list`, which means + the Field should have FieldValues." + (int 100)) (def ^:private ^:const ^Integer entry-max-length "The maximum character length for a stored `FieldValues` entry." - 100) + (int 100)) (def ^:private ^:const ^Integer total-max-length "Maximum total length for a `FieldValues` entry (combined length of all values for the field)." - (* low-cardinality-threshold entry-max-length)) + (int (* auto-list-cardinality-threshold entry-max-length))) ;; ## Entity + DB Multimethods @@ -32,23 +42,23 @@ ;; ## `FieldValues` Helper Functions -(defn field-should-have-field-values? - "Should this `Field` be backed by a corresponding `FieldValues` object?" +(s/defn field-should-have-field-values? :- s/Bool + "Should this `field` be backed by a corresponding `FieldValues` object?" {:arglists '([field])} - [{:keys [base_type special_type visibility_type] :as field}] - {:pre [visibility_type - (contains? field :base_type) - (contains? field :special_type)]} - (and (not (contains? #{:retired :sensitive :hidden :details-only} (keyword visibility_type))) - (not (isa? (keyword base_type) :type/DateTime)) - (or (isa? (keyword base_type) :type/Boolean) - (isa? (keyword special_type) :type/Category) - (isa? (keyword special_type) :type/Enum)))) + [{base-type :base_type, visibility-type :visibility_type, has-field-values :has_field_values, :as field} + :- {:visibility_type su/KeywordOrString + :base_type (s/maybe su/KeywordOrString) + :has_field_values (s/maybe su/KeywordOrString) + s/Keyword s/Any}] + (boolean + (and (not (contains? #{:retired :sensitive :hidden :details-only} (keyword visibility-type))) + (not (isa? (keyword base-type) :type/DateTime)) + (#{:list :auto-list} (keyword has-field-values))))) (defn- values-less-than-total-max-length? - "`true` if the combined length of all the values in DISTINCT-VALUES is below the - threshold for what we'll allow in a FieldValues entry. Does some logging as well." + "`true` if the combined length of all the values in DISTINCT-VALUES is below the threshold for what we'll allow in a + FieldValues entry. Does some logging as well." [distinct-values] (let [total-length (reduce + (map (comp count str) distinct-values))] @@ -58,57 +68,61 @@ "FieldValues are allowed for this Field." "FieldValues are NOT allowed for this Field."))))) -(defn- cardinality-less-than-threshold? - "`true` if the number of DISTINCT-VALUES is less that `low-cardinality-threshold`. - Does some logging as well." - [distinct-values] - (let [num-values (count distinct-values)] - (u/prog1 (<= num-values low-cardinality-threshold) - (log/debug (if <> - (format "Field has %d distinct values (max %d). FieldValues are allowed for this Field." num-values low-cardinality-threshold) - (format "Field has over %d values. FieldValues are NOT allowed for this Field." low-cardinality-threshold)))))) - (defn- distinct-values - "Fetch a sequence of distinct values for FIELD that are below the `total-max-length` threshold. - If the values are past the threshold, this returns `nil`." + "Fetch a sequence of distinct values for `field` that are below the `total-max-length` threshold. If the values are + past the threshold, this returns `nil`." [field] (require 'metabase.db.metadata-queries) (let [values ((resolve 'metabase.db.metadata-queries/field-distinct-values) field)] - (when (cardinality-less-than-threshold? values) - (when (values-less-than-total-max-length? values) - values)))) + (when (values-less-than-total-max-length? values) + values))) (defn- fixup-human-readable-values - "Field values and human readable values are lists that are zipped - together. If the field values have changes, the human readable - values will need to change too. This function reconstructs the - human_readable_values to reflect `NEW-VALUES`. If a new field value - is found, a string version of that is used" + "Field values and human readable values are lists that are zipped together. If the field values have changes, the + human readable values will need to change too. This function reconstructs the `human_readable_values` to reflect + `NEW-VALUES`. If a new field value is found, a string version of that is used" [{old-values :values, old-hrv :human_readable_values} new-values] (when (seq old-hrv) (let [orig-remappings (zipmap old-values old-hrv)] (map #(get orig-remappings % (str %)) new-values)))) (defn create-or-update-field-values! - "Create or update the FieldValues object for FIELD. If the FieldValues object already exists, then update values for + "Create or update the FieldValues object for `field`. If the FieldValues object already exists, then update values for it; otherwise create a new FieldValues object with the newly fetched values." [field & [human-readable-values]] (let [field-values (FieldValues :field_id (u/get-id field)) values (distinct-values field) field-name (or (:name field) (:id field))] (cond + ;; If this Field is marked `auto-list`, and the number of values in now over the list threshold, we need to + ;; unmark it as `auto-list`. Switch it to `has_field_values` = `nil` and delete the FieldValues; this will + ;; result in it getting a Search Widget in the UI when `has_field_values` is automatically inferred by the + ;; `metabase.models.field/infer-has-field-values` hydration function (see that namespace for more detailed + ;; discussion) + ;; + ;; It would be nicer if we could do this in analysis where it gets marked `:auto-list` in the first place, but + ;; Fingerprints don't get updated regularly enough that we could detect the sudden increase in cardinality in a + ;; way that could make this work. Thus, we are stuck doing it here :( + (and (> (count values) auto-list-cardinality-threshold) + (= :auto-list (keyword (:has_field_values field)))) + (do + (log/info (trs "Field {0} was previously automatically set to show a list widget, but now has {1} values." + field-name (count values)) + (trs "Switching Field to use a search widget instead.")) + (db/update! 'Field (u/get-id field) :has_field_values nil) + (db/delete! FieldValues :field_id (u/get-id field))) ;; if the FieldValues object already exists then update values in it (and field-values values) (do - (log/debug (format "Storing updated FieldValues for Field %s..." field-name)) + (log/debug (trs "Storing updated FieldValues for Field {0}..." field-name)) (db/update-non-nil-keys! FieldValues (u/get-id field-values) :values values :human_readable_values (fixup-human-readable-values field-values values))) ;; if FieldValues object doesn't exist create one values (do - (log/debug (format "Storing FieldValues for Field %s..." field-name)) + (log/debug (trs "Storing FieldValues for Field {0}..." field-name)) (db/insert! FieldValues :field_id (u/get-id field) :values values @@ -120,7 +134,7 @@ (defn field-values->pairs "Returns a list of pairs (or single element vectors if there are no human_readable_values) for the given - `FIELD-VALUES` instance." + `FIELD-VALUES` instance." [{:keys [values human_readable_values] :as field-values}] (if (seq human_readable_values) (map vector values human_readable_values) @@ -152,8 +166,8 @@ (defn- table-ids->table-id->is-on-demand? "Given a collection of TABLE-IDS return a map of Table ID to whether or not its Database is subject to 'On Demand' - FieldValues updating. This means the FieldValues for any Fields belonging to the Database should be updated only - when they are used in new Dashboard or Card parameters." + FieldValues updating. This means the FieldValues for any Fields belonging to the Database should be updated only + when they are used in new Dashboard or Card parameters." [table-ids] (let [table-ids (set table-ids) table-id->db-id (when (seq table-ids) @@ -166,11 +180,12 @@ (defn update-field-values-for-on-demand-dbs! "Update the FieldValues for any Fields with FIELD-IDS if the Field should have FieldValues and it belongs to a - Database that is set to do 'On-Demand' syncing." + Database that is set to do 'On-Demand' syncing." [field-ids] (let [fields (when (seq field-ids) (filter field-should-have-field-values? - (db/select ['Field :name :id :base_type :special_type :visibility_type :table_id] + (db/select ['Field :name :id :base_type :special_type :visibility_type :table_id + :has_field_values] :id [:in field-ids]))) table-id->is-on-demand? (table-ids->table-id->is-on-demand? (map :table_id fields))] (doseq [{table-id :table_id, :as field} fields] diff --git a/src/metabase/models/humanization.clj b/src/metabase/models/humanization.clj index e2236610444e5eeb62eee369b62bf009632c0d29..3498c171d157d88b0ae976b0eb48958de5d872fd 100644 --- a/src/metabase/models/humanization.clj +++ b/src/metabase/models/humanization.clj @@ -13,6 +13,7 @@ [clojure.tools.logging :as log] [metabase.models.setting :as setting :refer [defsetting]] [metabase.util.infer-spaces :refer [infer-spaces]] + [puppetlabs.i18n.core :refer [tru]] [toucan.db :as db])) (def ^:private ^:const acronyms @@ -112,8 +113,8 @@ (re-humanize-table-and-field-names!)) (defsetting ^{:added "0.28.0"} humanization-strategy - "Metabase can attempt to transform your table and field names into more sensible, human-readable versions, e.g. - \"somehorriblename\" becomes \"Some Horrible Name\". This doesn’t work all that well if the names are in a language - other than English, however. Do you want us to take a guess?" + (str (tru "Metabase can attempt to transform your table and field names into more sensible, human-readable versions, e.g. \"somehorriblename\" becomes \"Some Horrible Name\".") + (tru "This doesn’t work all that well if the names are in a language other than English, however.") + (tru "Do you want us to take a guess?")) :default "advanced" :setter set-humanization-strategy!) diff --git a/src/metabase/models/interface.clj b/src/metabase/models/interface.clj index b938bed4f8dd4bf53234119f41ee8e665f1fc510..55ac4a67666bee3283e3411ca2a9afaaff9b4ba0 100644 --- a/src/metabase/models/interface.clj +++ b/src/metabase/models/interface.clj @@ -7,10 +7,14 @@ [encryption :as encryption]] [schema.core :as s] [taoensso.nippy :as nippy] - [toucan.models :as models]) + [toucan + [models :as models] + [util :as toucan-util]]) (:import java.sql.Blob)) -;;; ------------------------------------------------------------ Toucan Extensions ------------------------------------------------------------ +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Toucan Extensions | +;;; +----------------------------------------------------------------------------------------------------------------+ (models/set-root-namespace! 'metabase.models) @@ -46,7 +50,8 @@ (def ^:private encrypted-json-out (comp json-out encryption/maybe-decrypt)) ;; cache the decryption/JSON parsing because it's somewhat slow (~500µs vs ~100µs on a *fast* computer) -(def ^:private cached-encrypted-json-out (memoize/ttl encrypted-json-out :ttl/threshold (* 60 60 1000))) ; cache decrypted JSON for one hour +;; cache the decrypted JSON for one hour +(def ^:private cached-encrypted-json-out (memoize/ttl encrypted-json-out :ttl/threshold (* 60 60 1000))) (models/add-type! :encrypted-json :in encrypted-json-in @@ -75,6 +80,13 @@ :in validate-cron-string :out identity) +;; Toucan ships with a Keyword type, but on columns that are marked 'TEXT' it doesn't work properly since the values +;; might need to get de-CLOB-bered first. So replace the default Toucan `:keyword` implementation with one that +;; handles those cases. +(models/add-type! :keyword + :in toucan-util/keyword->qualified-name + :out (comp keyword u/jdbc-clob->str)) + ;;; properties @@ -94,14 +106,18 @@ :update add-updated-at-timestamp) -;;; ------------------------------------------------------------ New Permissions Stuff ------------------------------------------------------------ +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | New Permissions Stuff | +;;; +----------------------------------------------------------------------------------------------------------------+ (defprotocol IObjectPermissions "Methods for determining whether the current user has read/write permissions for a given object." (perms-objects-set [this, ^clojure.lang.Keyword read-or-write] - "Return a set of permissions object paths that a user must have access to in order to access this object. This should be something like #{\"/db/1/schema/public/table/20/\"}. - READ-OR-WRITE will be either `:read` or `:write`, depending on which permissions set we're fetching (these will be the same sets for most models; they can ignore this param).") + "Return a set of permissions object paths that a user must have access to in order to access this object. This + should be something like #{\"/db/1/schema/public/table/20/\"}. READ-OR-WRITE will be either `:read` or `:write`, + depending on which permissions set we're fetching (these will be the same sets for most models; they can ignore + this param).") (can-read? ^Boolean [instance], ^Boolean [entity, ^Integer id] "Return whether `*current-user*` has *read* permissions for an object. You should use one of these implmentations: @@ -109,7 +125,8 @@ * `(constantly true)` * `superuser?` * `(partial current-user-has-full-permissions? :read)` (you must also implement `perms-objects-set` to use this) - * `(partial current-user-has-partial-permissions? :read)` (you must also implement `perms-objects-set` to use this)") + * `(partial current-user-has-partial-permissions? :read)` (you must also implement `perms-objects-set` to use + this)") (^{:hydrate :can_write} can-write? ^Boolean [instance], ^Boolean [entity, ^Integer id] "Return whether `*current-user*` has *write* permissions for an object. You should use one of these implmentations: @@ -117,7 +134,8 @@ * `(constantly true)` * `superuser?` * `(partial current-user-has-full-permissions? :write)` (you must also implement `perms-objects-set` to use this) - * `(partial current-user-has-partial-permissions? :write)` (you must also implement `perms-objects-set` to use this)")) + * `(partial current-user-has-partial-permissions? :write)` (you must also implement `perms-objects-set` to use + this)")) (def IObjectPermissionsDefaults "Default implementations for `IObjectPermissions`." @@ -147,14 +165,14 @@ (def ^{:arglists '([read-or-write entity object-id] [read-or-write object])} ^Boolean current-user-has-full-permissions? - "Implementation of `can-read?`/`can-write?` for the new permissions system. - `true` if the current user has *full* permissions for the paths returned by its implementation of `perms-objects-set`. - (READ-OR-WRITE is either `:read` or `:write` and passed to `perms-objects-set`; you'll usually want to partially bind it in the implementation map)." + "Implementation of `can-read?`/`can-write?` for the new permissions system. `true` if the current user has *full* + permissions for the paths returned by its implementation of `perms-objects-set`. (READ-OR-WRITE is either `:read` or + `:write` and passed to `perms-objects-set`; you'll usually want to partially bind it in the implementation map)." (make-perms-check-fn 'metabase.models.permissions/set-has-full-permissions-for-set?)) (def ^{:arglists '([read-or-write entity object-id] [read-or-write object])} ^Boolean current-user-has-partial-permissions? - "Implementation of `can-read?`/`can-write?` for the new permissions system. - `true` if the current user has *partial* permissions for the paths returned by its implementation of `perms-objects-set`. - (READ-OR-WRITE is either `:read` or `:write` and passed to `perms-objects-set`; you'll usually want to partially bind it in the implementation map)." + "Implementation of `can-read?`/`can-write?` for the new permissions system. `true` if the current user has *partial* + permissions for the paths returned by its implementation of `perms-objects-set`. (READ-OR-WRITE is either `:read` or + `:write` and passed to `perms-objects-set`; you'll usually want to partially bind it in the implementation map)." (make-perms-check-fn 'metabase.models.permissions/set-has-partial-permissions-for-set?)) diff --git a/src/metabase/models/label.clj b/src/metabase/models/label.clj index fd364a3eb26734aa6738d063d54df83e6a3dde87..b55689e19e3922af94478996995c04dac2a23b16 100644 --- a/src/metabase/models/label.clj +++ b/src/metabase/models/label.clj @@ -2,6 +2,7 @@ "Labels that can be applied to Cards. Deprecated in favor of Collections." (:require [metabase.models.interface :as i] [metabase.util :as u] + [puppetlabs.i18n.core :refer [tru]] [toucan [db :as db] [models :as models]])) @@ -10,8 +11,8 @@ (defn- assert-unique-slug [slug] (when (db/exists? Label :slug slug) - (throw (ex-info "Name already taken" - {:status-code 400, :errors {:name "A label with this name already exists"}})))) + (throw (ex-info (tru "Name already taken") + {:status-code 400, :errors {:name (tru "A label with this name already exists")}})))) (defn- pre-insert [{label-name :name, :as label}] (assoc label :slug (u/prog1 (u/slugify label-name) diff --git a/src/metabase/models/params.clj b/src/metabase/models/params.clj index 8ff5b66265d24370f337c9d9dfc35dec971e2e50..ce540903d2c7be95ebe40ab8049abf9ac2ef05ed 100644 --- a/src/metabase/models/params.clj +++ b/src/metabase/models/params.clj @@ -1,16 +1,21 @@ (ns metabase.models.params "Utility functions for dealing with parameters for Dashboards and Cards." - (:require [metabase.query-processor.middleware.expand :as ql] + (:require [clojure.set :as set] + [metabase.query-processor.middleware.expand :as ql] metabase.query-processor.interface - [metabase.util :as u] - [toucan.db :as db]) + [metabase + [db :as mdb] + [util :as u]] + [toucan + [db :as db] + [hydrate :refer [hydrate]]]) (:import metabase.query_processor.interface.FieldPlaceholder)) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | SHARED | ;;; +----------------------------------------------------------------------------------------------------------------+ -(defn- field-form->id +(defn field-form->id "Expand a `field-id` or `fk->` FORM and return the ID of the Field it references. (field-form->id [:field-id 100]) ; -> 100" @@ -35,8 +40,8 @@ (get-in dashcard [:card :dataset_query :native :template_tags (keyword tag) :dimension])) (defn- param-target->field-id - "Parse a Card parameter TARGET form, which looks something like `[:dimension [:field-id 100]]`, and return the Field ID - it references (if any)." + "Parse a Card parameter TARGET form, which looks something like `[:dimension [:field-id 100]]`, and return the Field + ID it references (if any)." [target dashcard] (when (ql/is-clause? :dimension target) (let [[_ dimension] target] @@ -45,12 +50,105 @@ dimension))))) +(defn- pk-fields + "Return the `fields` that are PK Fields." + [fields] + (filter #(isa? (:special_type %) :type/PK) fields)) + +(def ^:private Field:params-columns-only + "Form for use in Toucan `db/select` expressions (as a drop-in replacement for using `Field`) that returns Fields with + only the columns that are appropriate for returning in public/embedded API endpoints, which make heavy use of the + functions in this namespace. Use `conj` to add additional Fields beyond the ones already here. Use `rest` to get + just the column identifiers, perhaps for use with something like `select-keys`. Clutch! + + (db/select Field:params-columns-only)" + ['Field :id :table_id :display_name :base_type :special_type :has_field_values]) + +(defn- fields->table-id->name-field + "Given a sequence of `fields,` return a map of Table ID -> to a `:type/Name` Field in that Table, if one exists. In + cases where more than one name Field exists for a Table, this just adds the first one it finds." + [fields] + (when-let [table-ids (seq (map :table_id fields))] + (u/key-by :table_id (-> (db/select Field:params-columns-only + :table_id [:in table-ids] + :special_type (mdb/isa :type/Name)) + ;; run `metabase.models.field/infer-has-field-values` on these Fields so their values of + ;; `has_field_values` will be consistent with what the FE expects. (e.g. we'll return + ;; `list` instead of `auto-list`.) + (hydrate :has_field_values))))) + +(defn add-name-field + "For all `fields` that are `:type/PK` Fields, look for a `:type/Name` Field belonging to the same Table. For each + Field, if a matching name Field exists, add it under the `:name_field` key. This is so the Fields can be used in + public/embedded field values search widgets. This only includes the information needed to power those widgets, and + no more." + {:batched-hydrate :name_field} + [fields] + (let [table-id->name-field (fields->table-id->name-field (pk-fields fields))] + (for [field fields] + ;; add matching `:name_field` if it's a PK + (assoc field :name_field (when (isa? (:special_type field) :type/PK) + (table-id->name-field (:table_id field))))))) + + +;; We hydrate the `:human_readable_field` for each Dimension using the usual hydration logic, so it contains columns we +;; don't want to return. The two functions below work to remove the unneeded ones. + +(defn- remove-dimension-nonpublic-columns + "Strip nonpublic columns from a `dimension` and from its hydrated human-readable Field." + [dimension] + (-> dimension + (update :human_readable_field #(select-keys % (rest Field:params-columns-only))) + ;; these aren't exactly secret but you the frontend doesn't need them either so while we're at it let's go ahead + ;; and strip them out + (dissoc :created_at :updated_at))) + +(defn- remove-dimensions-nonpublic-columns + "Strip nonpublic columns from the hydrated human-readable Field in the hydrated Dimensions in `fields`." + [fields] + (for [field fields] + (update field :dimensions + (fn [dimension-or-dimensions] + ;; as disucssed in `metabase.models.field` the hydration code for `:dimensions` is + ;; WRONG and the value ends up either being a single Dimension or an empty vector. + ;; However at some point we will fix this so deal with either a map or a sequence of + ;; maps + (cond + (map? dimension-or-dimensions) + (remove-dimension-nonpublic-columns dimension-or-dimensions) + + (sequential? dimension-or-dimensions) + (map remove-dimension-nonpublic-columns dimension-or-dimensions)))))) + + +(defn- param-field-ids->fields + "Get the Fields (as a map of Field ID -> Field) that shoudl be returned for hydrated `:param_fields` for a Card or + Dashboard. These only contain the minimal amount of information neccesary needed to power public or embedded + parameter widgets." + [field-ids] + (when (seq field-ids) + (u/key-by :id (-> (db/select Field:params-columns-only :id [:in field-ids]) + (hydrate :has_field_values :name_field [:dimensions :human_readable_field]) + remove-dimensions-nonpublic-columns)))) + +(defmulti ^:private ^{:hydrate :param_values} param-values + "Add a `:param_values` map (Field ID -> FieldValues) containing FieldValues for the Fields referenced by the + parameters of a Card or a Dashboard. Implementations are in respective sections below." + name) + +(defmulti ^:private ^{:hydrate :param_fields} param-fields + "Add a `:param_fields` map (Field ID -> Field) for all of the Fields referenced by the parameters of a Card or + Dashboard. Implementations are below in respective sections." + name) + + ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | DASHBOARD-SPECIFIC | ;;; +----------------------------------------------------------------------------------------------------------------+ -(defn dashboard->param-field-ids - "Return a set of Field IDs referenced by parameters in Cards in this DASHBOARD, or `nil` if none are referenced." +(defn- dashboard->parameter-mapping-field-ids + "Return the IDs of any Fields referenced directly by the Dashboard's `:parameters` (i.e., 'explicit' parameters) by + looking at the appropriate `:parameter_mappings` entries for its Dashcards." [dashboard] (when-let [ids (seq (for [dashcard (:ordered_cards dashboard) param (:parameter_mappings dashcard) @@ -59,16 +157,38 @@ field-id))] (set ids))) +(declare card->template-tag-field-ids) + +(defn- dashboard->card-param-field-ids + "Return the IDs of any Fields referenced in the 'implicit' template tag field filter parameters for native queries in + the Cards in `dashboard`." + [dashboard] + (reduce + set/union + (for [{card :card} (:ordered_cards dashboard)] + (card->template-tag-field-ids card)))) + +(defn dashboard->param-field-ids + "Return a set of Field IDs referenced by parameters in Cards in this DASHBOARD, or `nil` if none are referenced. This + also includes IDs of Fields that are to be found in the 'implicit' parameters for SQL template tag Field filters." + [dashboard] + (let [dashboard (hydrate dashboard [:ordered_cards :card])] + (set/union + (dashboard->parameter-mapping-field-ids dashboard) + (dashboard->card-param-field-ids dashboard)))) + (defn- dashboard->param-field-values "Return a map of Field ID to FieldValues (if any) for any Fields referenced by Cards in DASHBOARD, or `nil` if none are referenced or none of them have FieldValues." [dashboard] (field-ids->param-field-values (dashboard->param-field-ids dashboard))) -(defn add-field-values-for-parameters - "Add a `:param_values` map containing FieldValues for the parameter Fields in the DASHBOARD." - [dashboard] - (assoc dashboard :param_values (dashboard->param-field-values dashboard))) +(defmethod param-values "Dashboard" [dashboard] + (dashboard->param-field-values dashboard)) + +(defmethod param-fields "Dashboard" [dashboard] + (-> dashboard dashboard->param-field-ids param-field-ids->fields)) + ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | CARD-SPECIFIC | @@ -83,7 +203,8 @@ :when field-id] field-id))) -(defn add-card-param-values - "Add FieldValues for any Fields referenced in CARD's `:template_tags`." - [card] - (assoc card :param_values (field-ids->param-field-values (card->template-tag-field-ids card)))) +(defmethod param-values "Card" [card] + (field-ids->param-field-values (card->template-tag-field-ids card))) + +(defmethod param-fields "Card" [card] + (-> card card->template-tag-field-ids param-field-ids->fields)) diff --git a/src/metabase/models/permissions.clj b/src/metabase/models/permissions.clj index 1bb3ac67e9f999875dea08cc0a6a8a2765b23201..eb8186a17c6242b9cf0da30b1c789d6c9e2f5ff3 100644 --- a/src/metabase/models/permissions.clj +++ b/src/metabase/models/permissions.clj @@ -13,6 +13,7 @@ [metabase.util [honeysql-extensions :as hx] [schema :as su]] + [puppetlabs.i18n.core :refer [tru]] [schema.core :as s] [toucan [db :as db] @@ -62,7 +63,7 @@ [{:keys [group_id]}] (when (and (= group_id (:id (group/admin))) (not *allow-admin-permissions-changes*)) - (throw (ex-info "You cannot create or revoke permissions for the 'Admin' group." + (throw (ex-info (tru "You cannot create or revoke permissions for the 'Admin' group.") {:status-code 400})))) (defn- assert-valid-object @@ -72,7 +73,7 @@ (not (valid-object-path? object)) (or (not= object "/") (not *allow-root-entries*))) - (throw (ex-info (format "Invalid permissions object path: '%s'." object) + (throw (ex-info (tru "Invalid permissions object path: ''{0}''." object) {:status-code 400})))) (defn- assert-valid @@ -186,7 +187,8 @@ (log/debug (u/format-color 'green "Granting permissions for group %d: %s" (:group_id permissions) (:object permissions))))) (defn- pre-update [_] - (throw (Exception. "You cannot update a permissions entry! Delete it and create a new one."))) + (throw (Exception. (str (tru "You cannot update a permissions entry!") + (tru "Delete it and create a new one."))))) (defn- pre-delete [permissions] (log/debug (u/format-color 'red "Revoking permissions for group %d: %s" (:group_id permissions) (:object permissions))) @@ -465,7 +467,8 @@ Return a 409 (Conflict) if the numbers don't match up." [old-graph new-graph] (when (not= (:revision old-graph) (:revision new-graph)) - (throw (ex-info "Looks like someone else edited the permissions and your data is out of date. Please fetch new data and try again." + (throw (ex-info (str (tru "Looks like someone else edited the permissions and your data is out of date.") + (tru "Please fetch new data and try again.")) {:status-code 409})))) (defn- save-perms-revision! diff --git a/src/metabase/models/permissions_group.clj b/src/metabase/models/permissions_group.clj index 1dac6e94532e53a333489ec5836ee3e80f9f070f..34621c2193e818af21896541cdd826710c5a13f9 100644 --- a/src/metabase/models/permissions_group.clj +++ b/src/metabase/models/permissions_group.clj @@ -10,6 +10,7 @@ [clojure.tools.logging :as log] [metabase.models.setting :as setting] [metabase.util :as u] + [puppetlabs.i18n.core :refer [trs tru]] [toucan [db :as db] [models :as models]])) @@ -25,7 +26,8 @@ :name group-name) (u/prog1 (db/insert! PermissionsGroup :name group-name) - (log/info (u/format-color 'green "Created magic permissions group '%s' (ID = %d)" group-name (:id <>)))))))) + (log/info (u/format-color 'green (trs "Created magic permissions group ''{0}'' (ID = {1})" + group-name (:id <>))))))))) (def ^{:arglists '([])} ^metabase.models.permissions_group.PermissionsGroupInstance all-users @@ -55,7 +57,7 @@ (defn- check-name-not-already-taken [group-name] (when (exists-with-name? group-name) - (throw (ex-info "A group with that name already exists." {:status-code 400})))) + (throw (ex-info (tru "A group with that name already exists.") {:status-code 400})))) (defn- check-not-magic-group "Make sure we're not trying to edit/delete one of the magic groups, or throw an exception." @@ -65,7 +67,7 @@ (admin) (metabot)]] (when (= id (:id magic-group)) - (throw (ex-info (format "You cannot edit or delete the '%s' permissions group!" (:name magic-group)) + (throw (ex-info (tru "You cannot edit or delete the ''{0}'' permissions group!" (:name magic-group)) {:status-code 400}))))) diff --git a/src/metabase/models/permissions_group_membership.clj b/src/metabase/models/permissions_group_membership.clj index 2058206bc250fff63dc610bf0db16191fec49bd9..87e90ddd1279fe43655b6c9e67ac7b626cb3a48f 100644 --- a/src/metabase/models/permissions_group_membership.clj +++ b/src/metabase/models/permissions_group_membership.clj @@ -1,6 +1,7 @@ (ns metabase.models.permissions-group-membership (:require [metabase.models.permissions-group :as group] [metabase.util :as u] + [puppetlabs.i18n.core :refer [tru]] [toucan [db :as db] [models :as models]])) @@ -11,7 +12,7 @@ "Throw an Exception if we're trying to add or remove a user to the MetaBot group." [group-id] (when (= group-id (:id (group/metabot))) - (throw (ex-info "You cannot add or remove users to/from the 'MetaBot' group." + (throw (ex-info (tru "You cannot add or remove users to/from the 'MetaBot' group.") {:status-code 400})))) (def ^:dynamic ^Boolean *allow-changing-all-users-group-members* @@ -24,14 +25,14 @@ [group-id] (when (= group-id (:id (group/all-users))) (when-not *allow-changing-all-users-group-members* - (throw (ex-info "You cannot add or remove users to/from the 'All Users' group." + (throw (ex-info (tru "You cannot add or remove users to/from the 'All Users' group.") {:status-code 400}))))) (defn- check-not-last-admin [] (when (<= (db/count PermissionsGroupMembership :group_id (:id (group/admin))) 1) - (throw (ex-info "You cannot remove the last member of the 'Admin' group!" + (throw (ex-info (tru "You cannot remove the last member of the 'Admin' group!") {:status-code 400})))) (defn- pre-delete [{:keys [group_id user_id]}] diff --git a/src/metabase/models/permissions_revision.clj b/src/metabase/models/permissions_revision.clj index 9d14c93f0d8afd89bd020c65ead9e4d4ede0526b..10892a6f3c553116ba8b3c702b45a29be7a80bdf 100644 --- a/src/metabase/models/permissions_revision.clj +++ b/src/metabase/models/permissions_revision.clj @@ -1,5 +1,6 @@ (ns metabase.models.permissions-revision (:require [metabase.util :as u] + [puppetlabs.i18n.core :refer [tru]] [toucan [db :as db] [models :as models]])) @@ -16,7 +17,7 @@ :after :json :remark :clob}) :pre-insert pre-insert - :pre-update (fn [& _] (throw (Exception. "You cannot update a PermissionsRevision!")))})) + :pre-update (fn [& _] (throw (Exception. (str (tru "You cannot update a PermissionsRevision!")))))})) (defn latest-id diff --git a/src/metabase/models/query.clj b/src/metabase/models/query.clj index 9ca6036813eb2d98eba309d4761286054c363b94..d6b88581bfacc06a50db025355872ac6aa56e733 100644 --- a/src/metabase/models/query.clj +++ b/src/metabase/models/query.clj @@ -1,5 +1,6 @@ (ns metabase.models.query (:require [metabase.db :as mdb] + [metabase.query-processor.util :as qputil] [metabase.util.honeysql-extensions :as hx] [toucan [db :as db] @@ -56,3 +57,30 @@ (or (update-rolling-average-execution-time! query-hash execution-time-ms) ;; rethrow e if updating an existing average execution time failed (throw e)))))) + + +(defn- native-query? [query-type] + (or (= query-type "native") + (= query-type :native))) + +(defn query->database-and-table-ids + "Return a map with `:database-id` and source `:table-id` that should be saved for a Card. Handles queries that use + other queries as their source (ones that come in with a `:source-table` like `card__100`) recursively, as well as + normal queries." + [outer-query] + (let [database-id (qputil/get-normalized outer-query :database) + query-type (qputil/get-normalized outer-query :type) + source-table (qputil/get-in-normalized outer-query [:query :source-table])] + (cond + (native-query? query-type) {:database-id database-id, :table-id nil} + (integer? source-table) {:database-id database-id, :table-id source-table} + (string? source-table) (let [[_ card-id] (re-find #"^card__(\d+)$" source-table)] + (db/select-one ['Card [:table_id :table-id] [:database_id :database-id]] + :id (Integer/parseInt card-id)))))) + +(defn adhoc-query + "Wrap query map into a Query object (mostly to fascilitate type dispatch)." + [query] + (->> {:dataset_query query} + (merge (query->database-and-table-ids query)) + map->QueryInstance)) diff --git a/src/metabase/models/query_execution.clj b/src/metabase/models/query_execution.clj index 851cc8c7f696bc4f8bc244bd6c836be537561965..4fda12dd6fe27f78de5412dbf33d54592963fa05 100644 --- a/src/metabase/models/query_execution.clj +++ b/src/metabase/models/query_execution.clj @@ -1,5 +1,6 @@ (ns metabase.models.query-execution (:require [metabase.util :as u] + [puppetlabs.i18n.core :refer [tru]] [schema.core :as s] [toucan.models :as models])) @@ -41,5 +42,5 @@ (merge models/IModelDefaults {:types (constantly {:json_query :json, :status :keyword, :context :keyword, :error :clob}) :pre-insert pre-insert - :pre-update (fn [& _] (throw (Exception. "You cannot update a QueryExecution!"))) + :pre-update (fn [& _] (throw (Exception. (str (tru "You cannot update a QueryExecution!"))))) :post-select post-select})) diff --git a/src/metabase/models/revision.clj b/src/metabase/models/revision.clj index baa99c179ede0602786a2d91d8f33b84942b94ba..c96cfee15c676e37bc8c9fdcbb85b6ceec3283ef 100644 --- a/src/metabase/models/revision.clj +++ b/src/metabase/models/revision.clj @@ -3,6 +3,7 @@ [metabase.models.revision.diff :refer [diff-string]] [metabase.models.user :refer [User]] [metabase.util :as u] + [puppetlabs.i18n.core :refer [tru]] [toucan [db :as db] [hydrate :refer [hydrate]] @@ -69,7 +70,7 @@ (merge models/IModelDefaults {:types (constantly {:object :json, :message :clob}) :pre-insert pre-insert - :pre-update (fn [& _] (throw (Exception. "You cannot update a Revision!")))})) + :pre-update (fn [& _] (throw (Exception. (str (tru "You cannot update a Revision!")))))})) ;;; # Functions diff --git a/src/metabase/models/setting.clj b/src/metabase/models/setting.clj index 862d3ed7012abd07dd304f64c68acbb37055015a..fd80041abc20e55e337e9604edb7993e25d9db8b 100644 --- a/src/metabase/models/setting.clj +++ b/src/metabase/models/setting.clj @@ -36,6 +36,7 @@ [metabase [events :as events] [util :as u]] + [puppetlabs.i18n.core :refer [tru]] [schema.core :as s] [toucan [db :as db] @@ -73,7 +74,7 @@ setting-or-name (let [k (keyword setting-or-name)] (or (@registered-settings k) - (throw (Exception. (format "Setting %s does not exist.\nFound: %s" k (sort (keys @registered-settings))))))))) + (throw (Exception. (str (tru "Setting {0} does not exist.\nFound: {1}" k (sort (keys @registered-settings)))))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -143,7 +144,7 @@ (case (str/lower-case string-value) "true" true "false" false - (throw (Exception. "Invalid value for string: must be either \"true\" or \"false\" (case-insensitive)."))))) + (throw (Exception. (str (tru "Invalid value for string: must be either \"true\" or \"false\" (case-insensitive)."))))))) (defn get-boolean "Get boolean value of (presumably `:boolean`) SETTING-OR-NAME. This is the default getter for `:boolean` settings. diff --git a/src/metabase/models/table.clj b/src/metabase/models/table.clj index f5eab443db20e7cca9d5c7272e5011d419f673a9..e03464a6d001e6e0baa318acc03afc9d760d02f1 100644 --- a/src/metabase/models/table.clj +++ b/src/metabase/models/table.clj @@ -17,11 +17,6 @@ ;;; ----------------------------------------------- Constants + Entity ----------------------------------------------- -;; TODO - I don't think this is used for anything anymore -(def ^:const ^:deprecated entity-types - "Valid values for `Table.entity_type` (field may also be `nil`)." - #{:person :event :photo :place}) - (def ^:const visibility-types "Valid values for `Table.visibility_type` (field may also be `nil`). (Basically any non-nil value is a reason for hiding the table.)" @@ -67,7 +62,11 @@ (defn fields "Return the `FIELDS` belonging to a single TABLE." [{:keys [id]}] - (db/select Field, :table_id id :visibility_type [:not= "retired"], {:order-by [[:position :asc] [:name :asc]]})) + (db/select Field + :table_id id + :active true + :visibility_type [:not= "retired"] + {:order-by [[:position :asc] [:name :asc]]})) (defn metrics "Retrieve the `Metrics` for a single TABLE." @@ -113,7 +112,7 @@ [tables] (with-objects :segments (fn [table-ids] - (db/select Segment :table_id [:in table-ids], {:order-by [[:name :asc]]})) + (db/select Segment :table_id [:in table-ids], :is_active true, {:order-by [[:name :asc]]})) tables)) (defn with-metrics @@ -122,7 +121,7 @@ [tables] (with-objects :metrics (fn [table-ids] - (db/select Metric :table_id [:in table-ids], {:order-by [[:name :asc]]})) + (db/select Metric :table_id [:in table-ids], :is_active true, {:order-by [[:name :asc]]})) tables)) (defn with-fields @@ -132,6 +131,7 @@ (with-objects :fields (fn [table-ids] (db/select Field + :active true :table_id [:in table-ids] :visibility_type [:not= "retired"] {:order-by [[:position :asc] [:name :asc]]})) diff --git a/src/metabase/public_settings.clj b/src/metabase/public_settings.clj index a1b707ac377f1f633603aae626cec95ba4194249..df5f518eae3be215460e3182cdeca5912d1cfbc5 100644 --- a/src/metabase/public_settings.clj +++ b/src/metabase/public_settings.clj @@ -101,7 +101,7 @@ :default 1000) (defsetting query-caching-max-ttl - (tru "The absoulte maximum time to keep any cached query results, in seconds.") + (tru "The absolute maximum time to keep any cached query results, in seconds.") :type :integer :default (* 60 60 24 100)) ; 100 days @@ -177,6 +177,7 @@ :site_url (site-url) :timezone_short (short-timezone-name (setting/get :report-timezone)) :timezones common/timezones - :types (types/types->parents) + :types (types/types->parents :type/*) + :entities (types/types->parents :entity/*) :version config/mb-version-info :xray_max_cost (setting/get :xray-max-cost)}) diff --git a/src/metabase/public_settings/metastore.clj b/src/metabase/public_settings/metastore.clj index 78393c344c879561d391fd035651fbb84a01c29f..861b990c51aaf319791f6f70587b131640798221 100644 --- a/src/metabase/public_settings/metastore.clj +++ b/src/metabase/public_settings/metastore.clj @@ -9,6 +9,7 @@ [metabase.config :as config] [metabase.util :as u] [metabase.util.schema :as su] + [puppetlabs.i18n.core :refer [trs tru]] [schema.core :as s])) (def ^:private ValidToken @@ -47,20 +48,20 @@ ;; slurp will throw a FileNotFoundException for 404s, so in that case just return an appropriate ;; 'Not Found' message (catch java.io.FileNotFoundException e - {:valid false, :status "invalid token: not found."}) + {:valid false, :status (tru "Unable to validate token.")}) ;; if there was any other error fetching the token, log it and return a generic message about the ;; token being invalid. This message will get displayed in the Settings page in the admin panel so ;; we do not want something complicated (catch Throwable e - (log/error "Error fetching token status:" e) - {:valid false, :status "there was an error checking whether this token was valid."}))) + (log/error e (trs "Error fetching token status:")) + {:valid false, :status (tru "There was an error checking whether this token was valid.")}))) fetch-token-status-timeout-ms - {:valid false, :status "token validation timed out."}))) + {:valid false, :status (tru "Token validation timed out.")}))) (defn- check-embedding-token-is-valid* [token] (when (s/check ValidToken token) - (throw (Exception. "Invalid token: token isn't in the right format."))) - (log/info "Checking with the MetaStore to see whether" token "is valid...") + (throw (Exception. (str (trs "Invalid token: token isn't in the right format."))))) + (log/info (trs "Checking with the MetaStore to see whether {0} is valid..." token)) (let [{:keys [valid status]} (fetch-token-status token)] (or valid ;; if token isn't valid throw an Exception with the `:status` message @@ -83,13 +84,17 @@ ;; TODO - better docstring (defsetting premium-embedding-token - "Token for premium embedding. Go to the MetaStore to get yours!" + (tru "Token for premium embedding. Go to the MetaStore to get yours!") :setter (fn [new-value] ;; validate the new value if we're not unsetting it - (when (seq new-value) - (check-embedding-token-is-valid new-value) - (log/info "Token is valid.")) - (setting/set-string! :premium-embedding-token new-value))) + (try + (when (seq new-value) + (check-embedding-token-is-valid new-value) + (log/info (trs "Token is valid."))) + (setting/set-string! :premium-embedding-token new-value) + (catch Throwable e + (log/error e (trs "Error setting premium embedding token")) + (throw (ex-info (.getMessage e) {:status-code 400})))))) (defn hide-embed-branding? "Should we hide the 'Powered by Metabase' attribution on the embedding pages? `true` if we have a valid premium diff --git a/src/metabase/pulse.clj b/src/metabase/pulse.clj index b2e718de90a7c3fc41da02e79f99e24f191ddb79..41ab09ac923d04a59d8ce76bf4bf7fc4c64e0088 100644 --- a/src/metabase/pulse.clj +++ b/src/metabase/pulse.clj @@ -34,9 +34,10 @@ (let [{:keys [creator_id dataset_query]} card] (try {:card card - :result (qp/process-query-and-save-execution! dataset_query - (merge {:executed-by creator_id, :context :pulse, :card-id card-id} - options))} + :result (qp/process-query-and-save-with-max! dataset_query (merge {:executed-by creator_id, + :context :pulse, + :card-id card-id} + options))} (catch Throwable t (log/warn (format "Error running card query (%n)" card-id) t)))))) diff --git a/src/metabase/pulse/render.clj b/src/metabase/pulse/render.clj index 2c02ae9fb86bb0fe07af09f1388765566a48dd22..5c38a745653021df49483680af9ad6de66fc97d2 100644 --- a/src/metabase/pulse/render.clj +++ b/src/metabase/pulse/render.clj @@ -8,7 +8,9 @@ [string :as str]] [clojure.java.io :as io] [clojure.tools.logging :as log] - [hiccup.core :refer [h html]] + [hiccup + [core :refer [h html]] + [util :as hutil]] [metabase.util :as u] [metabase.util [ui-logic :as ui-logic] @@ -35,52 +37,83 @@ ;;; # ------------------------------------------------------------ STYLES ------------------------------------------------------------ (def ^:private ^:const card-width 400) -(def ^:private ^:const rows-limit 10) -(def ^:private ^:const cols-limit 3) +(def ^:private ^:const rows-limit 20) +(def ^:private ^:const cols-limit 10) (def ^:private ^:const sparkline-dot-radius 6) (def ^:private ^:const sparkline-thickness 3) (def ^:private ^:const sparkline-pad 8) ;;; ## STYLES -(def ^:private ^:const color-brand "rgb(45,134,212)") -(def ^:private ^:const color-purple "rgb(135,93,175)") -(def ^:private ^:const color-gold "#F9D45C") -(def ^:private ^:const color-error "#EF8C8C") -(def ^:private ^:const color-gray-1 "rgb(248,248,248)") -(def ^:private ^:const color-gray-2 "rgb(189,193,191)") -(def ^:private ^:const color-gray-3 "rgb(124,131,129)") +(def ^:private ^:const color-brand "rgb(45,134,212)") +(def ^:private ^:const color-purple "rgb(135,93,175)") +(def ^:private ^:const color-gold "#F9D45C") +(def ^:private ^:const color-error "#EF8C8C") +(def ^:private ^:const color-gray-1 "rgb(248,248,248)") +(def ^:private ^:const color-gray-2 "rgb(189,193,191)") +(def ^:private ^:const color-gray-3 "rgb(124,131,129)") (def ^:const color-gray-4 "A ~25% Gray color." "rgb(57,67,64)") +(def ^:private ^:const color-dark-gray "#616D75") +(def ^:private ^:const color-row-border "#EDF0F1") -(def ^:private ^:const font-style {:font-family "Lato, \"Helvetica Neue\", Helvetica, Arial, sans-serif"}) -(def ^:const section-style + +(defn- primary-color [] + color-brand) + +(defn- font-style [] + {:font-family "Lato, \"Helvetica Neue\", Helvetica, Arial, sans-serif"}) + +(defn section-style "CSS style for a Pulse section." - font-style) - -(def ^:private ^:const header-style - (merge font-style {:font-size :16px - :font-weight 700 - :color color-gray-4 - :text-decoration :none})) - -(def ^:private ^:const scalar-style - (merge font-style {:font-size :24px - :font-weight 700 - :color color-brand})) - -(def ^:private ^:const bar-th-style - (merge font-style {:font-size :10px - :font-weight 400 - :color color-gray-4 - :border-bottom (str "4px solid " color-gray-1) - :padding-top :0px - :padding-bottom :10px})) - -(def ^:private ^:const bar-td-style - (merge font-style {:font-size :16px - :font-weight 400 - :text-align :left - :padding-right :1em - :padding-top :8px})) + [] + (font-style)) + +(defn- header-style [] + (merge (font-style) {:font-size :16px + :font-weight 700 + :color color-gray-4 + :text-decoration :none})) + +(defn- scalar-style [] + (merge (font-style) {:font-size :24px + :font-weight 700 + :color (primary-color)})) + +(defn- bar-th-style [] + (merge (font-style) {:font-size :14.22px + :font-weight 700 + :color color-gray-4 + :border-bottom (str "1px solid " color-row-border) + :padding-top :20px + :padding-bottom :5px})) + +(defn- bar-td-style [] + (merge (font-style) {:font-size :16px + :font-weight 400 + :text-align :left + :padding-right :1em + :padding-top :8px})) + +;; TO-DO for @senior: apply this style to headings of numeric columns +(defn- bar-th-numeric-style [] + (merge (font-style) {:text-align :right + :font-size :14.22px + :font-weight 700 + :color color-gray-4 + :border-bottom (str "1px solid " color-row-border) + :padding-top :20px + :padding-bottom :5px})) + +;; TO-DO for @senior: apply this style to numeric cells +(defn- bar-td-style-numeric [] + (merge (font-style) {:font-size :14.22px + :font-weight 400 + :color color-dark-gray + :text-align :right + :padding-right :1em + :padding-top :2px + :padding-bottom :1px + :font-family "Courier, Monospace" + :border-bottom (str "1px solid " color-row-border)})) (def ^:private RenderedPulseCard "Schema used for functions that operate on pulse card contents and their attachments" @@ -98,6 +131,11 @@ :let [v (if (keyword? v) (name v) v)]] (str (name k) ": " v ";")))) +(defn- graphing-columns [card {:keys [cols] :as data}] + [(or (ui-logic/x-axis-rowfn card data) + first) + (or (ui-logic/y-axis-rowfn card data) + second)]) (defn- datetime-field? [field] @@ -109,12 +147,67 @@ (or (isa? (:base_type field) :type/Number) (isa? (:special_type field) :type/Number))) +(defn detect-pulse-card-type + "Determine the pulse (visualization) type of a CARD, e.g. `:scalar` or `:bar`." + [card data] + (let [col-count (-> data :cols count) + row-count (-> data :rows count) + [col-1-rowfn col-2-rowfn] (graphing-columns card data) + col-1 (col-1-rowfn (:cols data)) + col-2 (col-2-rowfn (:cols data)) + aggregation (-> card :dataset_query :query :aggregation first)] + (cond + (or (zero? row-count) + ;; Many aggregations result in [[nil]] if there are no rows to aggregate after filters + (= [[nil]] (-> data :rows))) :empty + (contains? #{:pin_map :state :country} (:display card)) nil + (and (= col-count 1) + (= row-count 1)) :scalar + (and (= col-count 2) + (> row-count 1) + (datetime-field? col-1) + (number-field? col-2)) :sparkline + (and (= col-count 2) + (number-field? col-2)) :bar + :else :table))) + +(defn- show-in-table? [{:keys [special_type visibility_type] :as column}] + (and (not (isa? special_type :type/Description)) + (not (contains? #{:details-only :retired :sensitive} visibility_type)))) + +(defn include-csv-attachment? + "Returns true if this card and resultset should include a CSV attachment" + [{:keys [include_csv] :as card} {:keys [cols rows] :as result-data}] + (or (:include_csv card) + (and (= :table (detect-pulse-card-type card result-data)) + (or + ;; If some columns are not shown, include an attachment + (some (complement show-in-table?) cols) + ;; If there are too many rows or columns, include an attachment + (< cols-limit (count cols)) + (< rows-limit (count rows)))))) + +(defn include-xls-attachment? + "Returns true if this card and resultset should include an XLS attachment" + [{:keys [include_csv] :as card} result-data] + (:include_xls card)) + +(defn count-displayed-columns + "Return a count of the number of columns to be included in a table display" + [cols] + (count (filter show-in-table? cols))) ;;; # ------------------------------------------------------------ FORMATTING ------------------------------------------------------------ +(defrecord NumericWrapper [num-str] + hutil/ToString + (to-str [_] num-str) + java.lang.Object + (toString [_] num-str)) + (defn- format-number [n] - (cl-format nil (if (integer? n) "~:d" "~,2f") n)) + (NumericWrapper. (cl-format nil (if (integer? n) "~:d" "~,2f") n))) (defn- reformat-timestamp [timezone old-format-timestamp new-format-string] (f/unparse (f/with-zone (f/formatter new-format-string) @@ -212,6 +305,14 @@ [card] (h (urls/card-url (:id card)))) +(defn- write-image + [^BufferedImage image ^String format-name ^ByteArrayOutputStream output-stream] + (try + (ImageIO/write image format-name output-stream) + (catch javax.imageio.IIOException iioex + (log/error iioex "Error writing image to output stream") + (throw iioex)))) + ;; ported from https://github.com/radkovo/CSSBox/blob/cssbox-4.10/src/main/java/org/fit/cssbox/demo/ImageRenderer.java (defn- render-to-png [^String html, ^ByteArrayOutputStream os, width] @@ -238,7 +339,7 @@ (.setLoadImages true) (.setLoadBackgroundImages true)) (.createLayout content-canvas window-size) - (ImageIO/write (.getImage content-canvas) "png" os))) + (write-image (.getImage content-canvas) "png" os))) (s/defn ^:private render-html-to-png :- bytes [{:keys [content]} :- RenderedPulseCard @@ -251,26 +352,39 @@ (render-to-png html os width) (.toByteArray os))) + +(defn- heading-style-for-type + [cell] + (if (instance? NumericWrapper cell) + (bar-th-numeric-style) + (bar-th-style))) + +(defn- row-style-for-type + [cell] + (if (instance? NumericWrapper cell) + (bar-td-style-numeric) + (bar-td-style))) + (defn- render-table [header+rows] - [:table {:style (style {:padding-bottom :8px, :border-bottom (str "4px solid " color-gray-1)})} + [:table {:style (style {:max-width (str "100%"), :white-space :nowrap, :padding-bottom :8px, :border-collapse :collapse})} (let [{header-row :row bar-width :bar-width} (first header+rows)] [:thead [:tr (for [header-cell header-row] - [:th {:style (style bar-td-style bar-th-style {:min-width :60px})} + [:th {:style (style (row-style-for-type header-cell) (heading-style-for-type header-cell) {:min-width :60px})} (h header-cell)]) (when bar-width - [:th {:style (style bar-td-style bar-th-style {:width (str bar-width "%")})}])]]) + [:th {:style (style (bar-td-style) (bar-th-style) {:width (str bar-width "%")})}])]]) [:tbody (map-indexed (fn [row-idx {:keys [row bar-width]}] - [:tr {:style (style {:color (if (odd? row-idx) color-gray-2 color-gray-3)})} + [:tr {:style (style {:color color-gray-3})} (map-indexed (fn [col-idx cell] - [:td {:style (style bar-td-style (when (and bar-width (= col-idx 1)) {:font-weight 700}))} + [:td {:style (style (row-style-for-type cell) (when (and bar-width (= col-idx 1)) {:font-weight 700}))} (h cell)]) row) (when bar-width - [:td {:style (style bar-td-style {:width :99%})} + [:td {:style (style (bar-td-style) {:width :99%})} [:div {:style (style {:background-color color-purple :max-height :10px :height :10px @@ -294,13 +408,18 @@ are strings that are ready to be rendered as HTML" [remapping-lookup cols include-bar?] {:row (for [maybe-remapped-col cols - :let [col (if (:remapped_to maybe-remapped-col) - (nth cols (get remapping-lookup (:name maybe-remapped-col))) - maybe-remapped-col)] + :when (show-in-table? maybe-remapped-col) + :let [{:keys [base_type special_type] :as col} (if (:remapped_to maybe-remapped-col) + (nth cols (get remapping-lookup (:name maybe-remapped-col))) + maybe-remapped-col) + column-name (name (or (:display_name col) (:name col)))] ;; If this column is remapped from another, it's already ;; in the output and should be skipped :when (not (:remapped_from maybe-remapped-col))] - (str/upper-case (name (or (:display_name col) (:name col))))) + (if (or (isa? base_type :type/Number) + (isa? special_type :type/Number)) + (NumericWrapper. column-name) + column-name)) :bar-width (when include-bar? 99)}) (defn- query-results->row-seq @@ -311,7 +430,8 @@ ;; cast to double to avoid "Non-terminating decimal expansion" errors (float (* 100 (/ (double (bar-column row)) max-value)))) :row (for [[maybe-remapped-col maybe-remapped-row-cell] (map vector cols row) - :when (not (:remapped_from maybe-remapped-col)) + :when (and (not (:remapped_from maybe-remapped-col)) + (show-in-table? maybe-remapped-col)) :let [[col row-cell] (if (:remapped_to maybe-remapped-col) [(nth cols (get remapping-lookup (:name maybe-remapped-col))) (nth row (get remapping-lookup (:name maybe-remapped-col)))] @@ -328,38 +448,59 @@ (query-results->header-row remapping-lookup limited-cols bar-column) (query-results->row-seq timezone remapping-lookup limited-cols (take rows-limit rows) bar-column max-value)))) +(defn- strong-limit-text [number] + [:strong {:style (style {:color color-gray-3})} (h (format-number number))]) + (defn- render-truncation-warning [col-limit col-count row-limit row-count] - (if (or (> row-count row-limit) - (> col-count col-limit)) - [:div {:style (style {:padding-top :16px})} - (cond - (> row-count row-limit) - [:div {:style (style {:color color-gray-2 - :padding-bottom :10px})} - "Showing " [:strong {:style (style {:color color-gray-3})} (format-number row-limit)] - " of " [:strong {:style (style {:color color-gray-3})} (format-number row-count)] - " rows."] - - (> col-count col-limit) - [:div {:style (style {:color color-gray-2 - :padding-bottom :10px})} - "Showing " [:strong {:style (style {:color color-gray-3})} (format-number col-limit)] - " of " [:strong {:style (style {:color color-gray-3})} (format-number col-count)] - " columns."])])) + (let [over-row-limit (> row-count row-limit) + over-col-limit (> col-count col-limit)] + (when (or over-row-limit over-col-limit) + [:div {:style (style {:padding-top :16px})} + (cond + + (and over-row-limit over-col-limit) + [:div {:style (style {:color color-gray-2 + :padding-bottom :10px})} + "Showing " (strong-limit-text row-limit) + " of " (strong-limit-text row-count) + " rows and " (strong-limit-text col-limit) + " of " (strong-limit-text col-count) + " columns."] + + over-row-limit + [:div {:style (style {:color color-gray-2 + :padding-bottom :10px})} + "Showing " (strong-limit-text row-limit) + " of " (strong-limit-text row-count) + " rows."] + + over-col-limit + [:div {:style (style {:color color-gray-2 + :padding-bottom :10px})} + "Showing " (strong-limit-text col-limit) + " of " (strong-limit-text col-count) + " columns."])]))) + +(defn- attached-results-text + "Returns hiccup structures to indicate truncated results are available as an attachment" + [render-type cols cols-limit rows rows-limit] + (when (and (not= :inline render-type) + (or (< cols-limit (count-displayed-columns cols)) + (< rows-limit (count rows)))) + [:div {:style (style {:color color-gray-2 + :margin-bottom :16px})} + "More results have been included as a file attachment"])) (s/defn ^:private render:table :- RenderedPulseCard - [timezone card {:keys [cols rows] :as data}] - {:attachments nil - :content [:div - (render-table (prep-for-html-rendering timezone cols rows nil nil cols-limit)) - (render-truncation-warning cols-limit (count cols) rows-limit (count rows))]}) - -(defn- graphing-columns [card {:keys [cols] :as data}] - [(or (ui-logic/x-axis-rowfn card data) - first) - (or (ui-logic/y-axis-rowfn card data) - second)]) + [render-type timezone card {:keys [cols rows] :as data}] + (let [table-body [:div + (render-table (prep-for-html-rendering timezone cols rows nil nil cols-limit)) + (render-truncation-warning cols-limit (count-displayed-columns cols) rows-limit (count rows))]] + {:attachments nil + :content (if-let [results-attached (attached-results-text render-type cols cols-limit rows rows-limit)] + (list results-attached table-body) + (list table-body))})) (s/defn ^:private render:bar :- RenderedPulseCard [timezone card {:keys [cols rows] :as data}] @@ -368,12 +509,12 @@ {:attachments nil :content [:div (render-table (prep-for-html-rendering timezone cols rows y-axis-rowfn max-value 2)) - (render-truncation-warning 2 (count cols) rows-limit (count rows))]})) + (render-truncation-warning 2 (count-displayed-columns cols) rows-limit (count rows))]})) (s/defn ^:private render:scalar :- RenderedPulseCard [timezone card {:keys [cols rows]}] {:attachments nil - :content [:div {:style (style scalar-style)} + :content [:div {:style (style (scalar-style))} (h (format-cell timezone (ffirst rows) (first cols)))]}) (defn- render-sparkline-to-png @@ -402,7 +543,7 @@ (* 2 sparkline-dot-radius) (* 2 sparkline-dot-radius))) (when-not (ImageIO/write image "png" os) ; returns `true` if successful -- see JavaDoc - (let [^String msg (tru "No approprate image writer found!")] + (let [^String msg (tru "No appropriate image writer found!")] (throw (Exception. msg)))) (.toByteArray os))) @@ -563,7 +704,7 @@ :content [:div {:style (style {:text-align :center})} [:img {:style (style {:width :104px}) :src (:image-src image-bundle)}] - [:div {:style (style font-style + [:div {:style (style (font-style) {:margin-top :8px :color color-gray-4})} "No results"]]})) @@ -575,7 +716,7 @@ :content [:div {:style (style {:text-align :center})} [:img {:style (style {:width :30px}) :src (:image-src image-bundle)}] - [:div {:style (style font-style + [:div {:style (style (font-style) {:margin-top :8px :color color-gray-4})} "This question has been included as a file attachment"]]})) @@ -583,7 +724,7 @@ (s/defn ^:private render:unknown :- RenderedPulseCard [_ _] {:attachments nil - :content [:div {:style (style font-style + :content [:div {:style (style (font-style) {:color color-gold :font-weight 700})} "We were unable to display this card." @@ -593,37 +734,12 @@ (s/defn ^:private render:error :- RenderedPulseCard [_ _] {:attachments nil - :content [:div {:style (style font-style + :content [:div {:style (style (font-style) {:color color-error :font-weight 700 :padding :16px})} "An error occurred while displaying this card."]}) -(defn detect-pulse-card-type - "Determine the pulse (visualization) type of a CARD, e.g. `:scalar` or `:bar`." - [card data] - (let [col-count (-> data :cols count) - row-count (-> data :rows count) - [col-1-rowfn col-2-rowfn] (graphing-columns card data) - col-1 (col-1-rowfn (:cols data)) - col-2 (col-2-rowfn (:cols data)) - aggregation (-> card :dataset_query :query :aggregation first)] - (cond - (or (zero? row-count) - ;; Many aggregations result in [[nil]] if there are no rows to aggregate after filters - (= [[nil]] (-> data :rows))) :empty - (or (> col-count 3) - (contains? #{:pin_map :state :country} (:display card))) nil - (and (= col-count 1) - (= row-count 1)) :scalar - (and (= col-count 2) - (> row-count 1) - (datetime-field? col-1) - (number-field? col-2)) :sparkline - (and (= col-count 2) - (number-field? col-2)) :bar - :else :table))) - (s/defn ^:private make-title-if-needed :- (s/maybe RenderedPulseCard) [render-type card] (when *include-title* @@ -631,11 +747,14 @@ (external-link-image-bundle render-type))] {:attachments (when image-bundle (image-bundle->attachment image-bundle)) - :content [:table {:style (style {:margin-bottom :8px - :width :100%})} + :content [:table {:style (style {:margin-bottom :8px + :border-collapse :collapse + :width :100%})} [:tbody [:tr - [:td [:span {:style (style header-style)} + [:td {:style (style {:padding :0 + :margin :0})} + [:span {:style (style (header-style))} (-> card :name h)]] [:td {:style (style {:text-align :right})} (when *include-buttons* @@ -659,12 +778,16 @@ :scalar (render:scalar timezone card data) :sparkline (render:sparkline render-type timezone card data) :bar (render:bar timezone card data) - :table (render:table timezone card data) + :table (render:table render-type timezone card data) (if (is-attached? card) (render:attached render-type card data) (render:unknown card data))) (catch Throwable e - (log/error e (trs "Pulse card render error")) + (log/error (trs "Pulse card render error") + (class e) + (.getMessage e) + "\n" + (u/pprint-to-str (u/filtered-stacktrace e))) (render:error card data)))) (s/defn ^:private render-pulse-card :- RenderedPulseCard @@ -675,7 +798,7 @@ {:attachments (merge title-attachments body-attachments) :content [:a {:href (card-href card) :target "_blank" - :style (style section-style + :style (style (section-style) {:margin :16px :margin-bottom :16px :display :block @@ -691,16 +814,20 @@ (s/defn render-pulse-section :- RenderedPulseCard "Render a specific section of a Pulse, i.e. a single Card, to Hiccup HTML." - [timezone {:keys [card result]}] + [timezone {card :card {:keys [data] :as result} :result}] (let [{:keys [attachments content]} (binding [*include-title* true] (render-pulse-card :attachment timezone card result))] {:attachments attachments - :content [:div {:style (style {:margin-top :10px - :margin-bottom :20px - :border "1px solid #dddddd" - :border-radius :2px - :background-color :white - :box-shadow "0 1px 2px rgba(0, 0, 0, .08)"})} + :content [:div {:style (style (merge {:margin-top :10px + :margin-bottom :20px} + ;; Don't include the border on cards rendered with a table as the table + ;; will be to larger and overrun the border + (when-not (= :table (detect-pulse-card-type card data)) + {:border "1px solid #dddddd" + :border-radius :2px + :background-color :white + :width "500px !important" + :box-shadow "0 1px 2px rgba(0, 0, 0, .08)"})))} content]})) (defn render-pulse-card-to-png diff --git a/src/metabase/query_processor.clj b/src/metabase/query_processor.clj index b87f5e8d1dedb2cea8dd30cc7d6e4ee4a00cf12e..fe8b7012c509e2b71709ccfaca5617846e41e3c7 100644 --- a/src/metabase/query_processor.clj +++ b/src/metabase/query_processor.clj @@ -138,6 +138,8 @@ expand/expand-middleware parameters/substitute-parameters expand-macros/expand-macros + driver-specific/process-query-in-context + resolve-driver/resolve-driver fetch-source-query/fetch-source-query)) ;; ▲▲▲ This only does PRE-PROCESSING, so it happens from bottom to top, eventually returning the preprocessed query ;; instead of running it @@ -281,3 +283,22 @@ (run-and-save-query! (assoc query :info (assoc options :query-hash (qputil/query-hash query) :query-type (if (qputil/mbql-query? query) "MBQL" "native"))))) + +(def ^:private ^:const max-results-bare-rows + "Maximum number of rows to return specifically on :rows type queries via the API." + 2000) + +(def ^:private ^:const max-results + "General maximum number of rows to return from an API query." + 10000) + +(def default-query-constraints + "Default map of constraints that we apply on dataset queries executed by the api." + {:max-results max-results + :max-results-bare-rows max-results-bare-rows}) + +(s/defn process-query-and-save-with-max! + "Same as `process-query-and-save-execution!` but will include the default max rows returned as a constraint" + {:style/indent 1} + [query, options :- DatasetQueryOptions] + (process-query-and-save-execution! (assoc query :constraints default-query-constraints) options)) diff --git a/src/metabase/query_processor/interface.clj b/src/metabase/query_processor/interface.clj index 84096d96939ed40580d87fce838ea604f54f5f77..6fd4af9cd16cf6e3810b8ce4cd2ef447cbb6d929 100644 --- a/src/metabase/query_processor/interface.clj +++ b/src/metabase/query_processor/interface.clj @@ -304,7 +304,7 @@ FieldPlaceholder)]) ;; e.g. an absolute point in time (literal) -(s/defrecord DateTimeValue [value :- Timestamp +(s/defrecord DateTimeValue [value :- (s/maybe Timestamp) field :- DateTimeField]) (def OrderableValue diff --git a/src/metabase/query_processor/middleware/expand.clj b/src/metabase/query_processor/middleware/expand.clj index a2561f8a48d0945bad50ea72fd241af1a448f7e2..0c1d5c968f011ffb8b2cc081199204c635913d59 100644 --- a/src/metabase/query_processor/middleware/expand.clj +++ b/src/metabase/query_processor/middleware/expand.clj @@ -90,10 +90,12 @@ (defn- datetime-unit "Determine the appropriate datetime unit that should be used for a field F and a value V. - (Sometimes the value may already have a 'default' value that should be replaced with the - value from the field it is being used with, e.g. in a filter clause. - For example when filtering by minute it is important both F and V are bucketed as minutes, - and thus both most have the same unit." + + (Sometimes the value may already have a 'default' value that should be replaced with the value from the field it is + being used with, e.g. in a filter clause.) + + For example when filtering by minute it is important both F and V are bucketed as minutes, and thus both most have + the same unit." [f v] (qputil/normalize-token (core/or (:datetime-unit f) (:unit f) @@ -200,7 +202,8 @@ (log/warn "The syntax for aggregate fields has changed in MBQL '98. Instead of `[:aggregation 0]`, please use `[:aggregate-field 0]` instead.") (aggregate-field index)) - ;; Handle :aggregation top-level clauses. This is either a single map (single aggregation) or a vector of maps (multiple aggregations) + ;; Handle :aggregation top-level clauses. This is either a single map (single aggregation) or a vector of maps + ;; (multiple aggregations) ([query ag-or-ags :- (s/maybe (s/cond-pre su/Map [su/Map]))] (cond (map? ag-or-ags) (recur query [ag-or-ags]) @@ -221,7 +224,8 @@ ;;; ## breakout & fields (s/defn ^:ql binning-strategy :- FieldPlaceholder - "Reference to a `BinnedField`. This is just a `Field` reference with an associated `STRATEGY-NAME` and `STRATEGY-PARAM`" + "Reference to a `BinnedField`. This is just a `Field` reference with an associated `STRATEGY-NAME` and + `STRATEGY-PARAM`" ([f strategy-name & [strategy-param]] (let [strategy (qputil/normalize-token strategy-name) field (field f)] diff --git a/src/metabase/query_processor/middleware/expand_macros.clj b/src/metabase/query_processor/middleware/expand_macros.clj index d8edbc5e199c2e1e492ffff8ccd515cc954b9d01..b40b54d579a4dcb34a5d4bb90f3ba5c828630547 100644 --- a/src/metabase/query_processor/middleware/expand_macros.clj +++ b/src/metabase/query_processor/middleware/expand_macros.clj @@ -112,13 +112,17 @@ (expand-metric form filter-clauses-atom))) query-dict)) -(defn- merge-filter-clauses [base-clause additional-clauses] - (cond - (and (seq base-clause) - (seq additional-clauses)) [:and base-clause additional-clauses] - (seq base-clause) base-clause - (seq additional-clauses) additional-clauses - :else [])) +(defn merge-filter-clauses + "Merge filter clauses." + ([] []) + ([clause] clause) + ([base-clause additional-clauses] + (cond + (and (seq base-clause) + (seq additional-clauses)) [:and base-clause additional-clauses] + (seq base-clause) base-clause + (seq additional-clauses) additional-clauses + :else []))) (defn- add-metrics-filter-clauses "Add any FILTER-CLAUSES to the QUERY-DICT. If query has existing filter clauses, the new ones are @@ -163,5 +167,6 @@ (log/debug (u/format-color 'cyan "\n\nMACRO/SUBSTITUTED: %s\n%s" (u/emoji "😻") (u/pprint-to-str <>))))))) (defn expand-macros - "Middleware that looks for `METRIC` and `SEGMENT` macros in an unexpanded MBQL query and substitute the macros for their contents." + "Middleware that looks for `METRIC` and `SEGMENT` macros in an unexpanded MBQL query and substitute the macros for + their contents." [qp] (comp qp expand-macros*)) diff --git a/src/metabase/query_processor/middleware/fetch_source_query.clj b/src/metabase/query_processor/middleware/fetch_source_query.clj index 76a95249c44ef6f42bf2ed64a4609d638d6eb82e..c8498e412692453ad9dd4a47bc915ac615be02df 100644 --- a/src/metabase/query_processor/middleware/fetch_source_query.clj +++ b/src/metabase/query_processor/middleware/fetch_source_query.clj @@ -1,12 +1,26 @@ (ns metabase.query-processor.middleware.fetch-source-query "Middleware responsible for 'hydrating' the source query for queries that use another query as their source." - (:require [clojure.tools.logging :as log] + (:require [clojure.string :as str] + [clojure.tools.logging :as log] [metabase.query-processor [interface :as i] [util :as qputil]] [metabase.util :as u] + [puppetlabs.i18n.core :refer [trs]] [toucan.db :as db])) +(defn- trim-query + "Native queries can have trailing SQL comments. This works when executed directly, but when we use the query in a + nested query, we wrap it in another query, which can cause the last part of the query to be unintentionally + commented out, causing it to fail. This function removes any trailing SQL comment." + [card-id query-str] + (let [trimmed-string (str/replace query-str #"--.*(\n|$)" "")] + (if (= query-str trimmed-string) + query-str + (do + (log/info (trs "Trimming trailing comment from card with id {0}" card-id)) + trimmed-string)))) + (defn- card-id->source-query "Return the source query info for Card with CARD-ID." [card-id] @@ -14,7 +28,7 @@ card-query (:dataset_query card)] (assoc (or (:query card-query) (when-let [native (:native card-query)] - {:native (:query native) + {:native (trim-query card-id (:query native)) :template_tags (:template_tags native)}) (throw (Exception. (str "Missing source query in Card " card-id)))) ;; include database ID as well; we'll pass that up the chain so it eventually gets put in its spot in the diff --git a/src/metabase/query_processor/middleware/parameters/mbql.clj b/src/metabase/query_processor/middleware/parameters/mbql.clj index 3b9f1bdcbc162146ce2274f950eed9473d70d6b4..e3a2b1e20d1a54a1bcbda1b1d30858c18fe691e3 100644 --- a/src/metabase/query_processor/middleware/parameters/mbql.clj +++ b/src/metabase/query_processor/middleware/parameters/mbql.clj @@ -1,23 +1,38 @@ (ns metabase.query-processor.middleware.parameters.mbql "Code for handling parameter substitution in MBQL queries." (:require [clojure.string :as str] - [metabase.query-processor.middleware.parameters.dates :as date-params])) + [metabase.models + [field :refer [Field]] + [params :as params]] + [metabase.query-processor.middleware.parameters.dates :as date-params] + [toucan.db :as db])) (defn- parse-param-value-for-type "Convert PARAM-VALUE to a type appropriate for PARAM-TYPE. The frontend always passes parameters in as strings, which is what we want in most cases; for numbers, instead convert the parameters to integers or floating-point numbers." - [param-type param-value] + [param-type param-value field-id] (cond + ;; for `id` type params look up the base-type of the Field and see if it's a number or not. If it *is* a number + ;; then recursively call this function and parse the param value as a number as appropriate. + (and (= (keyword param-type) :id) + (isa? (db/select-one-field :base_type Field :id field-id) :type/Number)) + (recur :number param-value field-id) + ;; no conversion needed if PARAM-TYPE isn't :number or PARAM-VALUE isn't a string (or (not= (keyword param-type) :number) - (not (string? param-value))) param-value + (not (string? param-value))) + param-value + ;; if PARAM-VALUE contains a period then convert to a Double - (re-find #"\." param-value) (Double/parseDouble param-value) + (re-find #"\." param-value) + (Double/parseDouble param-value) + ;; otherwise convert to a Long - :else (Long/parseLong param-value))) + :else + (Long/parseLong param-value))) -(defn- build-filter-clause [{param-type :type, param-value :value, [_ field :as target] :target}] +(defn- build-filter-clause [{param-type :type, param-value :value, [_ field :as target] :target, :as param}] (cond ;; multipe values. Recursively handle them all and glue them all together with an OR clause (sequential? param-value) @@ -26,11 +41,12 @@ ;; single value, date range. Generate appropriate MBQL clause based on date string (str/starts-with? param-type "date") - (date-params/date-string->filter (parse-param-value-for-type param-type param-value) field) + (date-params/date-string->filter (parse-param-value-for-type param-type param-value (params/field-form->id field)) + field) ;; single-value, non-date param. Generate MBQL [= <field> <value>] clause :else - [:= field (parse-param-value-for-type param-type param-value)])) + [:= field (parse-param-value-for-type param-type param-value (params/field-form->id field))])) (defn- merge-filter-clauses [base addtl] (cond diff --git a/src/metabase/query_processor/middleware/parameters/sql.clj b/src/metabase/query_processor/middleware/parameters/sql.clj index 86f9a27faa8c3833326978c55ba8fcf64579fcf6..759c3cf8e3415c6d11698530754dfc30221c5241 100644 --- a/src/metabase/query_processor/middleware/parameters/sql.clj +++ b/src/metabase/query_processor/middleware/parameters/sql.clj @@ -7,17 +7,20 @@ [clojure.tools.logging :as log] [honeysql.core :as hsql] [instaparse.core :as insta] + [medley.core :as m] [metabase.driver :as driver] [metabase.models.field :as field :refer [Field]] [metabase.query-processor.middleware.parameters.dates :as date-params] [metabase.query-processor.middleware.expand :as ql] [metabase.util :as u] [metabase.util.schema :as su] + [puppetlabs.i18n.core :refer [tru]] [schema.core :as s] [toucan.db :as db]) (:import clojure.lang.Keyword honeysql.types.SqlCall java.text.NumberFormat + java.util.regex.Pattern metabase.models.field.FieldInstance)) ;; The Basics: @@ -86,9 +89,16 @@ (s/optional-key :default) s/Any}) (def ^:private DimensionValue - {:type su/NonBlankString - :target s/Any - (s/optional-key :value) s/Any}) ; not specified if the param has no value. TODO - make this stricter + {:type su/NonBlankString + :target s/Any + ;; not specified if the param has no value. TODO - make this stricter + (s/optional-key :value) s/Any + ;; The following are not used by the code in this namespace but may or may not be specified depending on what the + ;; code that constructs the query params is doing. We can go ahead and ignore these when present. + (s/optional-key :slug) su/NonBlankString + (s/optional-key :name) su/NonBlankString + (s/optional-key :default) s/Any + (s/optional-key :id) s/Any}) ; used internally by the frontend (def ^:private SingleValue "Schema for a valid *single* value for a param. As of 0.28.0 params can either be single-value or multiple value." @@ -112,7 +122,7 @@ {s/Keyword ParamValue}) (def ^:private ParamSnippetInfo - {(s/optional-key :replacement-snippet) s/Str ; allowed to be blank if this is an optional param + {(s/optional-key :replacement-snippet) s/Str ; allowed to be blank if this is an optional param (s/optional-key :prepared-statement-args) [s/Any]}) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -197,12 +207,12 @@ (.parse (NumberFormat/getInstance) ^String s)) (s/defn ^:private value->number :- (s/cond-pre s/Num CommaSeparatedNumbers) - "Parse a 'numeric' param value. Normally this returns an integer or floating-point number, - but as a somewhat undocumented feature it also accepts comma-separated lists of numbers. This was a side-effect of - the old parameter code that unquestioningly substituted any parameter passed in as a number directly into the SQL. - This has long been changed for security purposes (avoiding SQL injection), but since users have come to expect - comma-separated numeric values to work we'll allow that (with validation) and return an instance of - `CommaSeperatedNumbers`. (That is converted to SQL as a simple comma-separated list.)" + "Parse a 'numeric' param value. Normally this returns an integer or floating-point number, but as a somewhat + undocumented feature it also accepts comma-separated lists of numbers. This was a side-effect of the old parameter + code that unquestioningly substituted any parameter passed in as a number directly into the SQL. This has long been + changed for security purposes (avoiding SQL injection), but since users have come to expect comma-separated numeric + values to work we'll allow that (with validation) and return an instance of `CommaSeperatedNumbers`. (That is + converted to SQL as a simple comma-separated list.)" [value] (cond ;; if not a string it's already been parsed @@ -274,7 +284,8 @@ (defprotocol ^:private ISQLParamSubstituion "Protocol for specifying what SQL should be generated for parameters of various types." (^:private ->replacement-snippet-info [this] - "Return information about how THIS should be converted to SQL, as a map with keys `:replacement-snippet` and `:prepared-statement-args`. + "Return information about how THIS should be converted to SQL, as a map with keys `:replacement-snippet` and + `:prepared-statement-args`. (->replacement-snippet-info \"ABC\") -> {:replacement-snippet \"?\", :prepared-statement-args \"ABC\"}")) @@ -393,23 +404,6 @@ ;;; | PARSING THE SQL TEMPLATE | ;;; +----------------------------------------------------------------------------------------------------------------+ -(def ^:private sql-template-parser - (insta/parser - "SQL := (ANYTHING_NOT_RESERVED | SINGLE_BRACKET_PLUS_ANYTHING | SINGLE_BRACE_PLUS_ANYTHING | OPTIONAL | PARAM)* - - (* Treat double brackets and braces as special, pretty much everything else is good to go *) - <SINGLE_BRACKET_PLUS_ANYTHING> := !'[[' '[' (ANYTHING_NOT_RESERVED | ']' | SINGLE_BRACKET_PLUS_ANYTHING | SINGLE_BRACE_PLUS_ANYTHING)* - <SINGLE_BRACE_PLUS_ANYTHING> := !'{{' '{' (ANYTHING_NOT_RESERVED | '}' | SINGLE_BRACE_PLUS_ANYTHING | SINGLE_BRACKET_PLUS_ANYTHING)* - <ANYTHING_NOT_RESERVED> := #'[^\\[\\]\\{\\}]+' - - (* Parameters can have whitespace, but must be word characters for the name of the parameter *) - PARAM = <'{{'> <WHITESPACE*> TOKEN <WHITESPACE*> <'}}'> - - (* Parameters, braces and brackets are all good here, just no nesting of optional clauses *) - OPTIONAL := <'[['> (ANYTHING_NOT_RESERVED | SINGLE_BRACKET_PLUS_ANYTHING | SINGLE_BRACE_PLUS_ANYTHING | PARAM)* <']]'> - <TOKEN> := #'(\\w)+' - WHITESPACE := #'\\s+'")) - (defrecord ^:private Param [param-key sql-value prepared-statement-args]) (defn- param? [maybe-param] @@ -436,48 +430,80 @@ (and (param? maybe-param) (no-value? (:sql-value maybe-param)))) -(defn- transform-sql - "Returns the combined query-map from all of the parameters, optional clauses etc. At this point there should not be - a NoValue leaf. If so, it's an error (i.e. missing a required parameter." - [param-key->value] - (fn [& nodes] - (doseq [maybe-param nodes - :when (no-value-param? maybe-param)] - (throw (ex-info (format "Unable to substitute '%s': param not specified.\nFound: %s" - (:param-name maybe-param) (keys param-key->value)) - {:status-code 400}))) - (-> (reduce merge-query-map empty-query-map nodes) - (update :query str/trim)))) - -(defn- transform-optional - "Converts the `OPTIONAL` clause to a query map. If one or more parameters are not populated for this optional - clause, it will return an empty-query-map, which will be omitted from the query." - [& nodes] - (if (some no-value-param? nodes) - empty-query-map - (reduce merge-query-map empty-query-map nodes))) - -(defn- transform-param - "Converts a `PARAM` parse leaf to a query map that includes the SQL snippet to replace the `{{param}}` value and the - param itself for the prepared statement" - [param-key->value] - (fn [token] - (let [val (get param-key->value (keyword token) (NoValue.))] - (if (no-value? val) - (map->Param {:param-key token, :sql-value val, :prepared-statement-args []}) - (let [{:keys [replacement-snippet prepared-statement-args]} (->replacement-snippet-info val)] - (map->Param {:param-key token - :sql-value replacement-snippet - :prepared-statement-args prepared-statement-args})))))) - -(defn- parse-transform-map - "Instaparse returns things like [:SQL token token token...]. This map will be used when crawling the parse tree from - the bottom up. When encountering the a `:PARAM` node, it will invoke the included function, invoking the function - with each item in the list as arguments " - [param-key->value] - {:SQL (transform-sql param-key->value) - :OPTIONAL transform-optional - :PARAM (transform-param param-key->value)}) +(defn- quoted-re-pattern [s] + (-> s Pattern/quote re-pattern)) + +(defn- split-delimited-string + "Interesting parts of the SQL string (vs. parts that are just passed through) are delimited, + i.e. {{something}}. This function takes a `delimited-begin` and `delimited-end` regex and uses that to separate the + string. Returns a map with the prefix (the string leading up to the first `delimited-begin`) and `:delimited-strings` as + a seq of maps where `:delimited-body` is what's in-between the delimited marks (i.e. foo in {{foo}} and then a + suffix, which is the characters after the trailing delimiter but before the next occurrence of the `delimited-end`." + [delimited-begin delimited-end s] + (let [begin-pattern (quoted-re-pattern delimited-begin) + end-pattern (quoted-re-pattern delimited-end) + [prefix & segmented-strings] (str/split s begin-pattern)] + (when-let [^String msg (and (seq segmented-strings) + (not-every? #(str/index-of % delimited-end) segmented-strings) + (tru "Found ''{0}'' with no terminating ''{1}'' in query ''{2}''" + delimited-begin delimited-end s))] + (throw (IllegalArgumentException. msg))) + {:prefix prefix + :delimited-strings (for [segmented-string segmented-strings + :let [[token-str & rest-of-segment] (str/split segmented-string end-pattern)]] + {:delimited-body token-str + :suffix (apply str rest-of-segment)})})) + +(defn- token->param + "Given a `token` and `param-key->value` return a `Param`. If no parameter value is found, return a `NoValue` param" + [token param-key->value] + (let [val (get param-key->value (keyword token) (NoValue.)) + {:keys [replacement-snippet, + prepared-statement-args]} (->replacement-snippet-info val)] + (map->Param (merge {:param-key token} + (if (no-value? val) + {:sql-value val, :prepared-statement-args []} + {:sql-value replacement-snippet + :prepared-statement-args prepared-statement-args}))))) + +(defn- parse-params + "Parse `s` for any parameters. Returns a seq of strings and `Param` instances" + [s param-key->value] + (let [{:keys [prefix delimited-strings]} (split-delimited-string "{{" "}}" s)] + (cons prefix + (mapcat (fn [{:keys [delimited-body suffix]}] + [(-> delimited-body + str/trim + (token->param param-key->value)) + suffix]) + delimited-strings)))) + +(defn- parse-params-and-throw + "Same as `parse-params` but will throw an exception if there are any `NoValue` parameters" + [s param-key->value] + (let [results (parse-params s param-key->value)] + (if-let [{:keys [param-key]} (m/find-first no-value-param? results)] + (throw (ex-info (tru "Unable to substitute ''{0}'': param not specified.\nFound: {1}" + (name param-key) (pr-str (map name (keys param-key->value)))) + {:status-code 400})) + results))) + +(defn- parse-optional + "Attempts to parse `s`. Parses any optional clauses or parameters found, returns a query map." + [s param-key->value] + (let [{:keys [prefix delimited-strings]} (split-delimited-string "[[" "]]" s)] + (reduce merge-query-map empty-query-map + (apply concat (parse-params-and-throw prefix param-key->value) + (for [{:keys [delimited-body suffix]} delimited-strings + :let [optional-clause (parse-params delimited-body param-key->value)]] + (if (some no-value-param? optional-clause) + (parse-params-and-throw suffix param-key->value) + (concat optional-clause (parse-params-and-throw suffix param-key->value)))))))) + +(defn- parse-template [sql param-key->value] + (-> sql + (parse-optional param-key->value) + (update :query str/trim))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | PUTTING IT ALL TOGETHER | @@ -489,10 +515,8 @@ (s/defn ^:private expand-query-params [{sql :query, :as native}, param-key->value :- ParamValues] (merge native - (-> (parse-transform-map param-key->value) - (insta/transform (insta/parse sql-template-parser sql)) - ;; `prepare-sql-param-for-driver` can't be lazy as it needs `*driver*` to be bound - (update :params #(mapv prepare-sql-param-for-driver %))))) + ;; `prepare-sql-param-for-driver` can't be lazy as it needs `*driver*` to be bound + (update (parse-template sql param-key->value) :params #(mapv prepare-sql-param-for-driver %)))) (defn- ensure-driver "Depending on where the query came from (the user, permissions check etc) there might not be an driver associated to diff --git a/src/metabase/related.clj b/src/metabase/related.clj new file mode 100644 index 0000000000000000000000000000000000000000..f63807928135496f19756794d21b8b95d0f11858 --- /dev/null +++ b/src/metabase/related.clj @@ -0,0 +1,304 @@ +(ns metabase.related + "Related entities recommendations." + (:require [clojure.set :as set] + [clojure.string :as str] + [medley.core :as m] + [metabase.api.common :as api] + [metabase.models + [card :refer [Card]] + [collection :refer [Collection]] + [dashboard :refer [Dashboard]] + [dashboard-card :refer [DashboardCard]] + [field :refer [Field]] + [interface :as mi] + [metric :refer [Metric]] + [query :refer [Query]] + [segment :refer [Segment]] + [table :refer [Table]]] + [metabase.query-processor.util :as qp.util] + [schema.core :as s] + [toucan.db :as db])) + +(def ^:private ^Long max-best-matches 3) +(def ^:private ^Long max-serendipity-matches 2) +(def ^:private ^Long max-matches (+ max-best-matches + max-serendipity-matches)) + +(def ^:private ContextBearingForm + [(s/one (s/constrained (s/cond-pre s/Str s/Keyword) + (comp #{:field-id :metric :segment :fk->} + qp.util/normalize-token)) + "head") + s/Any]) + +(defn- collect-context-bearing-forms + [form] + (into #{} + (comp (remove (s/checker ContextBearingForm)) + (map #(update % 0 qp.util/normalize-token))) + (tree-seq sequential? identity form))) + +(defmulti + ^{:doc "Return the relevant parts of a given entity's definition. + Relevant parts are those that carry semantic meaning, and especially + context-bearing forms." + :arglists '([entity])} + definition type) + +(defmethod definition (type Card) + [card] + (-> card + :dataset_query + :query + ((juxt :breakout :aggregation :expressions :fields)))) + +(defmethod definition (type Metric) + [metric] + (-> metric :definition ((juxt :aggregation :filter)))) + +(defmethod definition (type Segment) + [segment] + (-> segment :definition :filter)) + +(defmethod definition (type Field) + [field] + [[:field-id (:id field)]]) + +(defn similarity + "How similar are entities `a` and `b` based on a structural comparison of their + definition (MBQL). + For the purposes of finding related entites we are only interested in + context-bearing subforms (field, segment, and metric references). We also + don't care about generalizations (less context-bearing forms) and refinements + (more context-bearing forms), so we just check if the less specifc form is a + subset of the more specific one." + [a b] + (let [context-a (-> a definition collect-context-bearing-forms) + context-b (-> b definition collect-context-bearing-forms)] + (/ (count (set/intersection context-a context-b)) + (max (min (count context-a) (count context-b)) 1)))) + +(defn- rank-by-similarity + [reference entities] + (->> entities + (remove #{reference}) + (map #(assoc % :similarity (similarity reference %))) + (sort-by :similarity >))) + +(defn- interesting-mix + "Create an interesting mix of matches. The idea is to have a balanced mix + between close (best) matches and more diverse matches to cover a wider field + of intents." + [matches] + (let [[best rest] (split-at max-best-matches matches)] + (concat best (->> rest shuffle (take max-serendipity-matches))))) + +(def ^:private ^{:arglists '([entities])} filter-visible + (partial filter (fn [{:keys [archived visibility_type] :as entity}] + (and (or (nil? visibility_type) + (= (name visibility_type) "normal")) + (not archived) + (mi/can-read? entity))))) + +(defn- metrics-for-table + [table] + (filter-visible (db/select Metric + :table_id (:id table) + :is_active true))) + +(defn- segments-for-table + [table] + (filter-visible (db/select Segment + :table_id (:id table) + :is_active true))) + +(defn- linking-to + [table] + (->> (db/select-field :fk_target_field_id Field + :table_id (:id table) + :fk_target_field_id [:not= nil]) + (map (comp Table :table_id Field)) + distinct + filter-visible + (take max-matches))) + +(defn- linked-from + [table] + (if-let [fields (not-empty (db/select-field :id Field :table_id (:id table)))] + (->> (db/select-field :table_id Field + :fk_target_field_id [:in fields]) + (map Table) + filter-visible + (take max-matches)) + [])) + +(defn- cards-sharing-dashboard + [card] + (if-let [dashboards (not-empty (db/select-field :dashboard_id DashboardCard + :card_id (:id card)))] + (->> (db/select-field :card_id DashboardCard + :dashboard_id [:in dashboards] + :card_id [:not= (:id card)]) + (map Card) + filter-visible + (take max-matches)) + [])) + +(defn- similar-questions + [card] + (->> (db/select Card + :table_id (:table_id card) + :archived false) + filter-visible + (rank-by-similarity card) + (filter (comp pos? :similarity)))) + +(defn- canonical-metric + [card] + (->> (db/select Metric + :table_id (:table_id card) + :is_active true) + filter-visible + (m/find-first (comp #{(-> card :dataset_query :query :aggregation)} + :aggregation + :definition)))) + +(defn- recently-modified-dashboards + [] + (->> (db/select-field :model_id 'Revision + :model "Dashboard" + :user_id api/*current-user-id* + {:order-by [[:timestamp :desc]]}) + (map Dashboard) + filter-visible + (take max-serendipity-matches))) + +(defn- recommended-dashboards + [cards] + (let [recent (recently-modified-dashboards) + card->dashboards (->> (apply db/select [DashboardCard :card_id :dashboard_id] + (cond-> {} + (not-empty cards) + (assoc :card_id [:in (map :id cards)]) + + (not-empty recent) + (assoc :dashboard_id [:not-in recent]))) + (group-by :card_id)) + best (->> cards + (mapcat (comp card->dashboards :id)) + distinct + (map Dashboard) + filter-visible + (take max-best-matches))] + (concat best recent))) + +(defn- recommended-collections + [cards] + (->> cards + (m/distinct-by :collection_id) + interesting-mix + (keep (comp Collection :collection_id)) + filter-visible)) + +(defmulti + ^{:doc "Return related entities." + :arglists '([entity])} + related type) + +(defmethod related (type Card) + [card] + (let [table (Table (:table_id card)) + similar-questions (similar-questions card)] + {:table table + :metrics (->> table + metrics-for-table + (rank-by-similarity card) + interesting-mix) + :segments (->> table + segments-for-table + (rank-by-similarity card) + interesting-mix) + :dashboard-mates (cards-sharing-dashboard card) + :similar-questions (interesting-mix similar-questions) + :canonical-metric (canonical-metric card) + :dashboards (recommended-dashboards similar-questions) + :collections (recommended-collections similar-questions)})) + +(defmethod related (type Query) + [query] + (related (with-meta query {:type (type Card)}))) + +(defmethod related (type Metric) + [metric] + (let [table (Table (:table_id metric))] + {:table table + :metrics (->> table + metrics-for-table + (rank-by-similarity metric) + interesting-mix) + :segments (->> table + segments-for-table + (rank-by-similarity metric) + interesting-mix)})) + +(defmethod related (type Segment) + [segment] + (let [table (Table (:table_id segment))] + {:table table + :metrics (->> table + metrics-for-table + (rank-by-similarity segment) + interesting-mix) + :segments (->> table + segments-for-table + (rank-by-similarity segment) + interesting-mix) + :linked-from (linked-from table)})) + +(defmethod related (type Table) + [table] + (let [linking-to (linking-to table) + linked-from (linked-from table)] + {:segments (segments-for-table table) + :metrics (metrics-for-table table) + :linking-to linking-to + :linked-from linked-from + :tables (->> (db/select Table + :db_id (:db_id table) + :schema (:schema table) + :id [:not= (:id table)] + :visibility_type nil) + (remove (set (concat linking-to linked-from))) + filter-visible + interesting-mix)})) + +(defmethod related (type Field) + [field] + (let [table (Table (:table_id field))] + {:table table + :segments (->> table + segments-for-table + (rank-by-similarity field) + interesting-mix) + :metrics (->> table + metrics-for-table + (rank-by-similarity field) + (filter (comp pos? :similarity)) + interesting-mix) + :fields (->> (db/select Field + :table_id (:id table) + :id [:not= (:id field)] + :visibility_type "normal") + filter-visible + interesting-mix)})) + +(defmethod related (type Dashboard) + [dashboard] + (let [cards (map Card (db/select-field :card_id DashboardCard + :dashboard_id (:id dashboard)))] + {:cards (->> cards + (mapcat (comp similar-questions)) + (remove (set cards)) + distinct + filter-visible + interesting-mix)})) diff --git a/src/metabase/routes.clj b/src/metabase/routes.clj index 594935eefd7dff9d98f870e14550ab2e23adabe4..e1f76ef64c632ef0fde256d30ed11808a4db4665 100644 --- a/src/metabase/routes.clj +++ b/src/metabase/routes.clj @@ -37,8 +37,10 @@ (defn- fallback-localization [locale] - (json/generate-string {"headers" {"language" locale} - "translations" {"" {"Metabase" {"msgid" "Metabase"}}}})) + (json/generate-string {"headers" {"language" locale + "plural-forms" "nplurals=2; plural=(n != 1);"} + "translations" {"" {"Metabase" {"msgid" "Metabase" + "msgstr" ["Metabase"]}}}})) (defn- load-localization [] (if (and *locale* (not= (str *locale*) "en")) diff --git a/src/metabase/sync/analyze.clj b/src/metabase/sync/analyze.clj index 08d5b475daf55fc4c02f78797b9e73d06cb06511..9976fa930ea51e81dc54061f37739502f7e42ff9 100644 --- a/src/metabase/sync/analyze.clj +++ b/src/metabase/sync/analyze.clj @@ -11,7 +11,7 @@ [metabase.sync.analyze [classify :as classify] [fingerprint :as fingerprint] - [table-row-count :as table-row-count]] + #_[table-row-count :as table-row-count]] [metabase.util :as u] [schema.core :as s] [toucan.db :as db])) @@ -54,25 +54,42 @@ ;; newly re-fingerprinted Fields, because we'll know to skip the ones from last time since their value of ;; `last_analyzed` is not `nil`. +(s/defn ^:private update-last-analyzed! + [tables :- [i/TableInstance]] + (when-let [ids (seq (map u/get-id tables))] + ;; The WHERE portion of this query should match up with that of `classify/fields-to-classify` + (db/update-where! Field {:table_id [:in ids] + :fingerprint_version i/latest-fingerprint-version + :last_analyzed nil} + :last_analyzed (u/new-sql-timestamp)))) (s/defn ^:private update-fields-last-analyzed! "Update the `last_analyzed` date for all the recently re-fingerprinted/re-classified Fields in TABLE." [table :- i/TableInstance] - ;; The WHERE portion of this query should match up with that of `classify/fields-to-classify` - (db/update-where! Field {:table_id (u/get-id table) - :fingerprint_version i/latest-fingerprint-version - :last_analyzed nil} - :last_analyzed (u/new-sql-timestamp))) + (update-last-analyzed! [table])) +(s/defn ^:private update-fields-last-analyzed-for-db! + "Update the `last_analyzed` date for all the recently re-fingerprinted/re-classified Fields in TABLE." + [database :- i/DatabaseInstance + tables :- [i/TableInstance]] + ;; The WHERE portion of this query should match up with that of `classify/fields-to-classify` + (update-last-analyzed! tables)) (s/defn analyze-table! "Perform in-depth analysis for a TABLE." [table :- i/TableInstance] - (table-row-count/update-row-count! table) + ;; Table row count disabled for now because of performance issues + #_(table-row-count/update-row-count! table) (fingerprint/fingerprint-fields! table) (classify/classify-fields! table) + (classify/classify-table! table) (update-fields-last-analyzed! table)) +(defn- maybe-log-progress [progress-bar-fn] + (fn [step table] + (let [progress-bar-result (progress-bar-fn)] + (when progress-bar-result + (log/info (u/format-color 'blue "%s Analyzed %s %s" step progress-bar-result (sync-util/name-for-logging table))))))) (s/defn analyze-db! "Perform in-depth analysis on the data for all Tables in a given DATABASE. @@ -81,7 +98,9 @@ [database :- i/DatabaseInstance] (sync-util/sync-operation :analyze database (format "Analyze data for %s" (sync-util/name-for-logging database)) (let [tables (sync-util/db->sync-tables database)] - (sync-util/with-emoji-progress-bar [emoji-progress-bar (count tables)] - (doseq [table tables] - (analyze-table! table) - (log/info (u/format-color 'blue "%s Analyzed %s" (emoji-progress-bar) (sync-util/name-for-logging table)))))))) + (sync-util/with-emoji-progress-bar [emoji-progress-bar (inc (* 3 (count tables)))] + (let [log-progress-fn (maybe-log-progress emoji-progress-bar)] + (fingerprint/fingerprint-fields-for-db! database tables log-progress-fn) + (classify/classify-fields-for-db! database tables log-progress-fn) + (classify/classify-tables-for-db! database tables log-progress-fn) + (update-fields-last-analyzed-for-db! database tables)))))) diff --git a/src/metabase/sync/analyze/classifiers/category.clj b/src/metabase/sync/analyze/classifiers/category.clj index 1cd48a98de143a32f60cb5082ecd0a654cfc9a5a..7c7a49373808884c643bd73ea3899d7de7ee1a80 100644 --- a/src/metabase/sync/analyze/classifiers/category.clj +++ b/src/metabase/sync/analyze/classifiers/category.clj @@ -1,7 +1,19 @@ (ns metabase.sync.analyze.classifiers.category - "Classifier that determines whether a Field should be marked as a `:type/Category` based on the number of distinct values it has." + "Classifier that determines whether a Field should be marked as a `:type/Category` and/or as a `list` Field based on + the number of distinct values it has. + + As of Metabase v0.29, the Category now longer has any use inside of the Metabase backend; it is used + only for frontend purposes (e.g. deciding which widget to show). Previously, makring something as a Category meant + that its values should be cached and saved in a FieldValues object. With the changes in v0.29, this is instead + managed by a column called `has_field_values`. + + A value of `list` now means the values should be cached. Deciding whether a Field should be a `list` Field is still + determined by the cardinality of the Field, like Category status. Thus it is entirely possibly for a Field to be + both a Category and a `list` Field." (:require [clojure.tools.logging :as log] - [metabase.models.field-values :as field-values] + [metabase.models + [field :as field] + [field-values :as field-values]] [metabase.sync [interface :as i] [util :as sync-util]] @@ -9,22 +21,46 @@ [schema.core :as s])) -(s/defn ^:private cannot-be-category? :- s/Bool - [base-type :- su/FieldType] - (or (isa? base-type :type/DateTime) - (isa? base-type :type/Collection))) +(s/defn ^:private cannot-be-category-or-list? :- s/Bool + [base-type :- su/FieldType, special-type :- (s/maybe su/FieldType)] + (or (isa? base-type :type/DateTime) + (isa? base-type :type/Collection) + ;; Don't let IDs become list Fields (they already can't become categories, because they already have a special + ;; type). It just doesn't make sense to cache a sequence of numbers since they aren't inherently meaningful + (isa? special-type :type/PK) + (isa? special-type :type/FK))) + +(s/defn ^:private field-should-be-category? :- (s/maybe s/Bool) + [distinct-count :- s/Int, field :- su/Map] + ;; only mark a Field as a Category if it doesn't already have a special type + (when-not (:special_type field) + (when (<= distinct-count field-values/category-cardinality-threshold) + (log/debug (format "%s has %d distinct values. Since that is less than %d, we're marking it as a category." + (sync-util/name-for-logging field) + distinct-count + field-values/category-cardinality-threshold)) + true))) + +(s/defn ^:private field-should-be-auto-list? :- (s/maybe s/Bool) + "Based on `distinct-count`, should we mark this `field` as `has_field_values` = `auto-list`?" + [distinct-count :- s/Int, field :- {:has_field_values (s/maybe (apply s/enum field/has-field-values-options)) + s/Keyword s/Any}] + ;; only update has_field_values if it hasn't been set yet. If it's already been set then it was probably done so + ;; manually by an admin, and we don't want to stomp over their choices. + (when (nil? (:has_field_values field)) + (when (<= distinct-count field-values/auto-list-cardinality-threshold) + (log/debug (format "%s has %d distinct values. Since that is less than %d, it should have cached FieldValues." + (sync-util/name-for-logging field) + distinct-count + field-values/auto-list-cardinality-threshold)) + true))) -(s/defn infer-is-category :- (s/maybe i/FieldInstance) +(s/defn infer-is-category-or-list :- (s/maybe i/FieldInstance) "Classifier that attempts to determine whether FIELD ought to be marked as a Category based on its distinct count." [field :- i/FieldInstance, fingerprint :- (s/maybe i/Fingerprint)] - (when-not (:special_type field) - (when fingerprint - (when-not (cannot-be-category? (:base_type field)) - (when-let [distinct-count (get-in fingerprint [:global :distinct-count])] - (when (< distinct-count field-values/low-cardinality-threshold) - (log/debug (format "%s has %d distinct values. Since that is less than %d, we're marking it as a category." - (sync-util/name-for-logging field) - distinct-count - field-values/low-cardinality-threshold)) - (assoc field - :special_type :type/Category))))))) + (when fingerprint + (when-not (cannot-be-category-or-list? (:base_type field) (:special_type field)) + (when-let [distinct-count (get-in fingerprint [:global :distinct-count])] + (cond-> field + (field-should-be-category? distinct-count field) (assoc :special_type :type/Category) + (field-should-be-auto-list? distinct-count field) (assoc :has_field_values :auto-list)))))) diff --git a/src/metabase/sync/analyze/classifiers/name.clj b/src/metabase/sync/analyze/classifiers/name.clj index 35f28f2fc081971b941213b2418245fc56a8a1b5..f64dec67f68155fdb1196004e799fcc0d2c48ae1 100644 --- a/src/metabase/sync/analyze/classifiers/name.clj +++ b/src/metabase/sync/analyze/classifiers/name.clj @@ -5,7 +5,9 @@ [metabase [config :as config] [util :as u]] - [metabase.models.field :refer [Field]] + [metabase.models + [field :refer [Field]] + [database :refer [Database]]] [metabase.sync [interface :as i] [util :as sync-util]] @@ -15,8 +17,12 @@ (def ^:private bool-or-int-type #{:type/Boolean :type/Integer}) (def ^:private float-type #{:type/Float}) +(def ^:private int-type #{:type/Integer}) (def ^:private int-or-text-type #{:type/Integer :type/Text}) (def ^:private text-type #{:type/Text}) +(def ^:private timestamp-type #{:type/DateTime}) +(def ^:private number-type #{:type/Number}) + (def ^:private pattern+base-types+special-type "Tuples of `[name-pattern set-of-valid-base-types special-type]`. @@ -24,42 +30,71 @@ * Convert field name to lowercase before matching against a pattern * Consider a nil set-of-valid-base-types to mean \"match any base type\"" - [[#"^.*_lat$" float-type :type/Latitude] - [#"^.*_lon$" float-type :type/Longitude] - [#"^.*_lng$" float-type :type/Longitude] - [#"^.*_long$" float-type :type/Longitude] - [#"^.*_longitude$" float-type :type/Longitude] - [#"^.*_rating$" int-or-text-type :type/Category] - [#"^.*_type$" int-or-text-type :type/Category] - [#"^.*_url$" text-type :type/URL] - [#"^_latitude$" float-type :type/Latitude] - [#"^active$" bool-or-int-type :type/Category] - [#"^city$" text-type :type/City] - [#"^country$" text-type :type/Country] - [#"^countryCode$" text-type :type/Country] - [#"^currency$" int-or-text-type :type/Category] - [#"^first_name$" text-type :type/Name] - [#"^full_name$" text-type :type/Name] - [#"^gender$" int-or-text-type :type/Category] - [#"^last_name$" text-type :type/Name] - [#"^lat$" float-type :type/Latitude] - [#"^latitude$" float-type :type/Latitude] - [#"^lon$" float-type :type/Longitude] - [#"^lng$" float-type :type/Longitude] - [#"^long$" float-type :type/Longitude] - [#"^longitude$" float-type :type/Longitude] - [#"^name$" text-type :type/Name] - [#"^postalCode$" int-or-text-type :type/ZipCode] - [#"^postal_code$" int-or-text-type :type/ZipCode] - [#"^rating$" int-or-text-type :type/Category] - [#"^role$" int-or-text-type :type/Category] - [#"^sex$" int-or-text-type :type/Category] - [#"^state$" text-type :type/State] - [#"^status$" int-or-text-type :type/Category] - [#"^type$" int-or-text-type :type/Category] - [#"^url$" text-type :type/URL] - [#"^zip_code$" int-or-text-type :type/ZipCode] - [#"^zipcode$" int-or-text-type :type/ZipCode]]) + [[#"^.*_lat$" float-type :type/Latitude] + [#"^.*_lon$" float-type :type/Longitude] + [#"^.*_lng$" float-type :type/Longitude] + [#"^.*_long$" float-type :type/Longitude] + [#"^.*_longitude$" float-type :type/Longitude] + [#"^.*_type$" int-or-text-type :type/Category] + [#"^.*_url$" text-type :type/URL] + [#"^_latitude$" float-type :type/Latitude] + [#"^active$" bool-or-int-type :type/Category] + [#"^city$" text-type :type/City] + [#"^country" text-type :type/Country] + [#"^currency$" int-or-text-type :type/Category] + [#"^first(?:_?)name$" text-type :type/Name] + [#"^full(?:_?)name$" text-type :type/Name] + [#"^gender$" int-or-text-type :type/Category] + [#"^last(?:_?)name$" text-type :type/Name] + [#"^lat$" float-type :type/Latitude] + [#"^latitude$" float-type :type/Latitude] + [#"^lon$" float-type :type/Longitude] + [#"^lng$" float-type :type/Longitude] + [#"^long$" float-type :type/Longitude] + [#"^longitude$" float-type :type/Longitude] + [#"^name$" text-type :type/Name] + [#"^postal(?:_?)code$" int-or-text-type :type/ZipCode] + [#"^role$" int-or-text-type :type/Category] + [#"^sex$" int-or-text-type :type/Category] + [#"^state$" text-type :type/State] + [#"^status$" int-or-text-type :type/Category] + [#"^type$" int-or-text-type :type/Category] + [#"^url$" text-type :type/URL] + [#"^zip(?:_?)code$" int-or-text-type :type/ZipCode] + [#"discount" number-type :type/Discount] + [#"income" number-type :type/Income] + [#"amount" number-type :type/Income] + [#"^total" number-type :type/Income] + [#"_total$" number-type :type/Income] + [#"quantity" int-type :type/Quantity] + [#"count$" int-type :type/Quantity] + [#"number" int-type :type/Quantity] + [#"^num_" int-type :type/Quantity] + [#"join" timestamp-type :type/JoinTimestamp] + [#"create" timestamp-type :type/CreationTimestamp] + [#"source" int-or-text-type :type/Source] + [#"channel" int-or-text-type :type/Source] + [#"share" float-type :type/Share] + [#"percent" float-type :type/Share] + [#"rate$" float-type :type/Share] + [#"margin" number-type :type/GrossMargin] + [#"cost" number-type :type/Cost] + [#"duration" number-type :type/Duration] + [#"author" int-or-text-type :type/Author] + [#"creator" int-or-text-type :type/Author] + [#"created(?:_?)by" int-or-text-type :type/Author] + [#"owner" int-or-text-type :type/Owner] + [#"company" int-or-text-type :type/Company] + [#"vendor" int-or-text-type :type/Company] + [#"subscription" int-or-text-type :type/Subscription] + [#"score" number-type :type/Score] + [#"rating" number-type :type/Score] + [#"stars" number-type :type/Score] + [#"description" text-type :type/Description] + [#"title" text-type :type/Title] + [#"comment" text-type :type/Comment] + [#"birthda(?:te|y)" timestamp-type :type/Birthdate] + [#"(?:te|y)(?:_?)or(?:_?)birth" timestamp-type :type/Birthdate]]) ;; Check that all the pattern tuples are valid (when-not config/is-prod? @@ -75,7 +110,7 @@ (or (when (= "id" (str/lower-case field-name)) :type/PK) (some (fn [[name-pattern valid-base-types special-type]] (when (and (some (partial isa? base-type) valid-base-types) - (re-matches name-pattern (str/lower-case field-name))) + (re-find name-pattern (str/lower-case field-name))) special-type)) pattern+base-types+special-type))) @@ -87,3 +122,42 @@ (sync-util/name-for-logging field) inferred-special-type)) (assoc field :special_type inferred-special-type))) + +(defn- prefix-or-postfix + [s] + (re-pattern (format "(?:^%s)|(?:%ss?$)" s s))) + +(def ^:private entity-types-patterns + [[(prefix-or-postfix "order") :entity/TransactionTable] + [(prefix-or-postfix "transaction") :entity/TransactionTable] + [(prefix-or-postfix "sale") :entity/TransactionTable] + [(prefix-or-postfix "product") :entity/ProductTable] + [(prefix-or-postfix "user") :entity/UserTable] + [(prefix-or-postfix "account") :entity/UserTable] + [(prefix-or-postfix "people") :entity/UserTable] + [(prefix-or-postfix "person") :entity/UserTable] + [(prefix-or-postfix "employee") :entity/UserTable] + [(prefix-or-postfix "event") :entity/EventTable] + [(prefix-or-postfix "checkin") :entity/EventTable] + [(prefix-or-postfix "log") :entity/EventTable] + [(prefix-or-postfix "subscription") :entity/SubscriptionTable] + [(prefix-or-postfix "company") :entity/CompanyTable] + [(prefix-or-postfix "companies") :entity/CompanyTable] + [(prefix-or-postfix "vendor") :entity/CompanyTable]]) + +(s/defn infer-entity-type :- i/TableInstance + "Classifer that infers the special type of a TABLE based on its name." + [table :- i/TableInstance] + (let [table-name (-> table :name str/lower-case)] + (assoc table :entity_type (or (some (fn [[pattern type]] + (when (re-find pattern table-name) + type)) + entity-types-patterns) + (case (-> table + :db_id + Database + :engine) + :googleanalytics :entity/GoogleAnalyticsTable + :druid :entity/EventTable + nil) + :entity/GenericTable)))) diff --git a/src/metabase/sync/analyze/classify.clj b/src/metabase/sync/analyze/classify.clj index 5f070504a435798e91d020245c1b9e888fb306c9..a1abb6d1af2a3bc756c47c9184d99da0551f9459 100644 --- a/src/metabase/sync/analyze/classify.clj +++ b/src/metabase/sync/analyze/classify.clj @@ -1,24 +1,26 @@ (ns metabase.sync.analyze.classify "Analysis sub-step that takes a fingerprint for a Field and infers and saves appropriate information like special - type. Each 'classifier' takes the information available to it and decides whether or not to run. - We currently have the following classifiers: - - 1. `name`: Looks at the name of a Field and infers a special type if possible - 2. `no-preview-display`: Looks at average length of text Field recorded in fingerprint and decides whether or not - we should hide this Field - 3. `category`: Looks at the number of distinct values of Field and determines whether it can be a Category - 4. `text-fingerprint`: Looks at percentages recorded in a text Fields' TextFingerprint and infers a special type - if possible - - All classifier functions take two arguments, a `FieldInstance` and a possibly `nil` `Fingerprint`, and should - return the Field with any appropriate changes (such as a new special type). If no changes are appropriate, a - classifier may return nil. Error handling is handled by `run-classifiers` below, so individual classiers do not - need to handle errors themselves. - - In the future, we plan to add more classifiers, including ML ones that run offline." + type. Each 'classifier' takes the information available to it and decides whether or not to run. We currently have + the following classifiers: + + 1. `name`: Looks at the name of a Field and infers a special type if possible + 2. `no-preview-display`: Looks at average length of text Field recorded in fingerprint and decides whether or not we + should hide this Field + 3. `category`: Looks at the number of distinct values of Field and determines whether it can be a Category + 4. `text-fingerprint`: Looks at percentages recorded in a text Fields' TextFingerprint and infers a special type if + possible + + All classifier functions take two arguments, a `FieldInstance` and a possibly `nil` `Fingerprint`, and should return + the Field with any appropriate changes (such as a new special type). If no changes are appropriate, a classifier may + return nil. Error handling is handled by `run-classifiers` below, so individual classiers do not need to handle + errors themselves. + + In the future, we plan to add more classifiers, including ML ones that run offline." (:require [clojure.data :as data] [clojure.tools.logging :as log] - [metabase.models.field :refer [Field]] + [metabase.models + [field :refer [Field]] + [table :refer [Table]]] [metabase.sync [interface :as i] [util :as sync-util]] @@ -37,41 +39,49 @@ (def ^:private values-that-can-be-set "Columns of Field that classifiers are allowed to set." - #{:special_type :preview_display}) - -(s/defn ^:private save-field-updates! - "Save the updates in UPDATED-FIELD." - [original-field :- i/FieldInstance, updated-field :- i/FieldInstance] - (let [[_ values-to-set] (data/diff original-field updated-field)] - (log/debug (format "Based on classification, updating these values of %s: %s" - (sync-util/name-for-logging original-field) - values-to-set)) + #{:special_type :preview_display :has_field_values :entity_type}) + +(def ^:private FieldOrTableInstance (s/either i/FieldInstance i/TableInstance)) + +(s/defn ^:private save-model-updates! + "Save the updates in `updated-model` (can be either a `Field` or `Table`)." + [original-model :- FieldOrTableInstance, updated-model :- FieldOrTableInstance] + (assert (= (type original-model) (type updated-model))) + (let [[_ values-to-set] (data/diff original-model updated-model)] + (when (seq values-to-set) + (log/debug (format "Based on classification, updating these values of %s: %s" + (sync-util/name-for-logging original-model) + values-to-set))) ;; Check that we're not trying to set anything that we're not allowed to (doseq [k (keys values-to-set)] (when-not (contains? values-that-can-be-set k) (throw (Exception. (format "Classifiers are not allowed to set the value of %s." k))))) - ;; cool, now we should be ok to update the Field - (db/update! Field (u/get-id original-field) - values-to-set))) - + ;; cool, now we should be ok to update the model + (when values-to-set + (db/update! (if (instance? (type Field) original-model) + Field + Table) + (u/get-id original-model) + values-to-set)))) (def ^:private classifiers "Various classifier functions available. These should all take two args, a `FieldInstance` and a possibly `nil` - `Fingerprint`, and return `FieldInstance` with any inferred property changes, or `nil` if none could be inferred. - Order is important!" + `Fingerprint`, and return `FieldInstance` with any inferred property changes, or `nil` if none could be inferred. + Order is important!" [name/infer-special-type - category/infer-is-category + category/infer-is-category-or-list no-preview-display/infer-no-preview-display text-fingerprint/infer-special-type]) -(s/defn ^:private run-classifiers :- i/FieldInstance +(s/defn run-classifiers :- i/FieldInstance "Run all the available `classifiers` against FIELD and FINGERPRINT, and return the resulting FIELD with changes - decided upon by the classifiers." + decided upon by the classifiers." [field :- i/FieldInstance, fingerprint :- (s/maybe i/Fingerprint)] (loop [field field, [classifier & more] classifiers] (if-not classifier field - (recur (or (sync-util/with-error-handling (format "Error running classifier on %s" (sync-util/name-for-logging field)) + (recur (or (sync-util/with-error-handling (format "Error running classifier on %s" + (sync-util/name-for-logging field)) (classifier field fingerprint)) field) more)))) @@ -86,7 +96,7 @@ (sync-util/with-error-handling (format "Error classifying %s" (sync-util/name-for-logging field)) (let [updated-field (run-classifiers field fingerprint)] (when-not (= field updated-field) - (save-field-updates! field updated-field)))))) + (save-model-updates! field updated-field)))))) ;;; +------------------------------------------------------------------------------------------------------------------+ @@ -94,8 +104,8 @@ ;;; +------------------------------------------------------------------------------------------------------------------+ (s/defn ^:private fields-to-classify :- (s/maybe [i/FieldInstance]) - "Return a sequences of Fields belonging to TABLE for which we should attempt to determine special type. - This should include Fields that have the latest fingerprint, but have not yet *completed* analysis." + "Return a sequences of Fields belonging to TABLE for which we should attempt to determine special type. This should + include Fields that have the latest fingerprint, but have not yet *completed* analysis." [table :- i/TableInstance] (seq (db/select Field :table_id (u/get-id table) @@ -103,10 +113,33 @@ :last_analyzed nil))) (s/defn classify-fields! - "Run various classifiers on the appropriate FIELDS in a TABLE that have not been previously analyzed. - These do things like inferring (and setting) the special types and preview display status for Fields - belonging to TABLE." + "Run various classifiers on the appropriate FIELDS in a TABLE that have not been previously analyzed. These do things + like inferring (and setting) the special types and preview display status for Fields belonging to TABLE." [table :- i/TableInstance] (when-let [fields (fields-to-classify table)] (doseq [field fields] (classify! field)))) + +(s/defn ^:always-validate classify-table! + "Run various classifiers on the TABLE. These do things like inferring (and + setting) entitiy type of TABLE." + [table :- i/TableInstance] + (save-model-updates! table (name/infer-entity-type table))) + +(s/defn classify-tables-for-db! + "Classify all tables found in a given database" + [database :- i/DatabaseInstance + tables :- [i/TableInstance] + log-progress-fn] + (doseq [table tables] + (classify-table! table) + (log-progress-fn "clasify-tables" table))) + +(s/defn classify-fields-for-db! + "Classify all fields found in a given database" + [database :- i/DatabaseInstance + tables :- [i/TableInstance] + log-progress-fn] + (doseq [table tables] + (classify-fields! table) + (log-progress-fn "classify-fields" table))) diff --git a/src/metabase/sync/analyze/fingerprint.clj b/src/metabase/sync/analyze/fingerprint.clj index d184f38c2a3cc42f2d01ad04e44e8854ced4eff5..8349cfed779d20f8205b9b200bb0da4b887d0ca5 100644 --- a/src/metabase/sync/analyze/fingerprint.clj +++ b/src/metabase/sync/analyze/fingerprint.clj @@ -9,6 +9,7 @@ [interface :as i] [util :as sync-util]] [metabase.sync.analyze.fingerprint + [datetime :as datetime] [global :as global] [number :as number] [sample :as sample] @@ -22,8 +23,9 @@ "Return type-specific fingerprint info for FIELD AND. a FieldSample of Values if it has an elligible base type" [field :- i/FieldInstance, values :- i/FieldSample] (condp #(isa? %2 %1) (:base_type field) - :type/Text {:type/Text (text/text-fingerprint values)} - :type/Number {:type/Number (number/number-fingerprint values)} + :type/Text {:type/Text (text/text-fingerprint values)} + :type/Number {:type/Number (number/number-fingerprint values)} + :type/DateTime {:type/DateTime (datetime/datetime-fingerprint values)} nil)) (s/defn ^:private fingerprint :- i/Fingerprint @@ -154,3 +156,12 @@ [table :- i/TableInstance] (when-let [fields (fields-to-fingerprint table)] (fingerprint-table! table fields))) + +(s/defn fingerprint-fields-for-db! + "Invokes `fingerprint-fields!` on every table in `database`" + [database :- i/DatabaseInstance + tables :- [i/TableInstance] + log-progress-fn] + (doseq [table tables] + (fingerprint-fields! table) + (log-progress-fn "fingerprint-fields" table))) diff --git a/src/metabase/sync/analyze/fingerprint/datetime.clj b/src/metabase/sync/analyze/fingerprint/datetime.clj new file mode 100644 index 0000000000000000000000000000000000000000..7c0bcded6443422c12427fdaaefd2417cb9168cc --- /dev/null +++ b/src/metabase/sync/analyze/fingerprint/datetime.clj @@ -0,0 +1,21 @@ +(ns metabase.sync.analyze.fingerprint.datetime + "Logic for generating a `DateTimeFingerprint` from a sequence of values for a `:type/DateTime` Field." + (:require [clj-time + [coerce :as t.coerce] + [core :as t]] + [medley.core :as m] + [metabase.sync.interface :as i] + [metabase.util :as u] + [redux.core :as redux] + [schema.core :as s])) + +(s/defn datetime-fingerprint :- i/DateTimeFingerprint + "Generate a fingerprint containing information about values that belong to a `DateTime` Field." + [values :- i/FieldSample] + (transduce (map u/str->date-time) + (redux/post-complete + (redux/fuse {:earliest t/min-date + :latest t/max-date}) + (partial m/map-vals str)) + [(t.coerce/from-long Long/MAX_VALUE) (t.coerce/from-long 0)] + values)) diff --git a/src/metabase/sync/analyze/fingerprint/global.clj b/src/metabase/sync/analyze/fingerprint/global.clj index 24c0ed7bb91b10a0e88992cd2dbbc8f65018bcb3..81b1252df06bfc7e80203e58002f429da09d94d3 100644 --- a/src/metabase/sync/analyze/fingerprint/global.clj +++ b/src/metabase/sync/analyze/fingerprint/global.clj @@ -7,7 +7,7 @@ "Generate a fingerprint of global information for Fields of all types." [values :- i/FieldSample] ;; TODO - this logic isn't as nice as the old logic that actually called the DB - ;; We used to do (queries/field-distinct-count field field-values/low-cardinality-threshold) + ;; We used to do (queries/field-distinct-count field field-values/auto-list-cardinality-threshold) ;; Consider whether we are so married to the idea of only generating fingerprints from samples that we ;; are ok with inaccurate counts like the one we'll surely be getting here {:distinct-count (count (distinct values))}) diff --git a/src/metabase/sync/field_values.clj b/src/metabase/sync/field_values.clj index ce85aab842a09dc874a098d44da075e852d1b2f9..f894bc7f350772475460941078f116e84fc238b0 100644 --- a/src/metabase/sync/field_values.clj +++ b/src/metabase/sync/field_values.clj @@ -13,8 +13,8 @@ (s/defn ^:private clear-field-values-for-field! [field :- i/FieldInstance] (when (db/exists? FieldValues :field_id (u/get-id field)) - (log/debug (format "Based on type info, %s should no longer have field values.\n" (sync-util/name-for-logging field)) - (format "(base type: %s, special type: %s, visibility type: %s)\n" (:base_type field) (:special_type field) (:visibility_type field)) + (log/debug (format "Based on cardinality and/or type information, %s should no longer have field values.\n" + (sync-util/name-for-logging field)) "Deleting FieldValues...") (db/delete! FieldValues :field_id (u/get-id field)))) diff --git a/src/metabase/sync/interface.clj b/src/metabase/sync/interface.clj index f1a9e9065c274bbcd4b0a1138be6c3b8a3ee6cb1..83effd8093191d1c1579046708a48f150623f3e0 100644 --- a/src/metabase/sync/interface.clj +++ b/src/metabase/sync/interface.clj @@ -108,11 +108,17 @@ (s/optional-key :percent-email) Percent (s/optional-key :average-length) (s/constrained Double #(>= % 0) "Valid number greater than or equal to zero")}) +(def DateTimeFingerprint + "Schema for fingerprint information for Fields deriving from `:type/DateTime`." + {(s/optional-key :earliest) s/Str + (s/optional-key :latest) s/Str}) + (def TypeSpecificFingerprint "Schema for type-specific fingerprint information." (s/constrained - {(s/optional-key :type/Number) NumberFingerprint - (s/optional-key :type/Text) TextFingerprint} + {(s/optional-key :type/Number) NumberFingerprint + (s/optional-key :type/Text) TextFingerprint + (s/optional-key :type/DateTime) DateTimeFingerprint} (fn [m] (= 1 (count (keys m)))) "Type-specific fingerprint with exactly one key")) @@ -151,7 +157,8 @@ (def fingerprint-version->types-that-should-be-re-fingerprinted "Map of fingerprint version to the set of Field base types that need to be upgraded to this version the next time we do analysis. The highest-numbered entry is considered the latest version of fingerprints." - {1 #{:type/*}}) + {1 #{:type/*} + 2 #{:type/DateTime}}) (def latest-fingerprint-version "The newest (highest-numbered) version of our Field fingerprints." diff --git a/src/metabase/sync/util.clj b/src/metabase/sync/util.clj index 4113937bc3aa01c34f507ed54299b17b7a9758ff..1c374603b37d1bf1e04f797b6069aa28aa31e105 100644 --- a/src/metabase/sync/util.clj +++ b/src/metabase/sync/util.clj @@ -188,14 +188,16 @@ (emoji-progress-bar 10 40) -> \"[************······································] 😒 25%" - [completed total] + [completed total log-every-n] (let [percent-done (float (/ completed total)) filleds (int (* percent-done emoji-meter-width)) blanks (- emoji-meter-width filleds)] - (str "[" - (str/join (repeat filleds "*")) - (str/join (repeat blanks "·")) - (format "] %s %3.0f%%" (u/emoji (percent-done->emoji percent-done)) (* percent-done 100.0))))) + (when (or (zero? (mod completed log-every-n)) + (= completed total)) + (str "[" + (str/join (repeat filleds "*")) + (str/join (repeat blanks "·")) + (format "] %s %3.0f%%" (u/emoji (percent-done->emoji percent-done)) (* percent-done 100.0)))))) (defmacro with-emoji-progress-bar "Run BODY with access to a function that makes using our amazing emoji-progress-bar easy like Sunday morning. @@ -209,7 +211,8 @@ [[emoji-progress-fn-binding total-count] & body] `(let [finished-count# (atom 0) total-count# ~total-count - ~emoji-progress-fn-binding (fn [] (emoji-progress-bar (swap! finished-count# inc) total-count#))] + log-every-n# (Math/ceil (/ total-count# 10)) + ~emoji-progress-fn-binding (fn [] (emoji-progress-bar (swap! finished-count# inc) total-count# log-every-n#))] ~@body)) diff --git a/src/metabase/task/cleanup_temporary_computation_job_results.clj b/src/metabase/task/cleanup_temporary_computation_job_results.clj index b19bf3ab9d448478086a26ad7bcba6134496792f..afc2a0c80cbd951d18ec1a63e697365c64277045 100644 --- a/src/metabase/task/cleanup_temporary_computation_job_results.clj +++ b/src/metabase/task/cleanup_temporary_computation_job_results.clj @@ -14,7 +14,7 @@ (defn- cleanup-temporary-results! [] - (db/delete! 'ComputationJobResult + (db/simple-delete! 'ComputationJobResult :created_at [:< (-> (t/now) (t/minus temporary-result-lifetime) t.coerce/to-sql-time)] diff --git a/src/metabase/task/follow_up_emails.clj b/src/metabase/task/follow_up_emails.clj index 5f5add22bef8db9d4bfb3e203a8a53c1a3e19ac3..ada44798a2342282d9cde478d30476c70e203e79 100644 --- a/src/metabase/task/follow_up_emails.clj +++ b/src/metabase/task/follow_up_emails.clj @@ -28,6 +28,7 @@ (defonce ^:private follow-up-emails-trigger (atom nil)) (setting/defsetting ^:private follow-up-email-sent + ;; No need to i18n this as it's not user facing "Have we sent a follow up email to the instance admin?" :type :boolean :default false diff --git a/src/metabase/types.clj b/src/metabase/types.clj index 13aeb6d36dde2a6167d734753b281175e40e5e62..1f3c3187d435183d570b9d0f0a9cf3e8895b08d8 100644 --- a/src/metabase/types.clj +++ b/src/metabase/types.clj @@ -10,6 +10,19 @@ (derive :type/Dictionary :type/Collection) (derive :type/Array :type/Collection) + +;;; Table (entitiy) Types + +(derive :entity/GenericTable :entity/*) +(derive :entity/UserTable :entity/GenericTable) +(derive :entity/CompanyTable :entity/GenericTable) +(derive :entity/TransactionTable :entity/GenericTable) +(derive :entity/ProductTable :entity/GenericTable) +(derive :entity/SubscriptionTable :entity/GenericTable) +(derive :entity/EventTable :entity/GenericTable) +(derive :entity/GoogleAnalyticsTable :entity/GenericTable) + + ;;; Numeric Types (derive :type/Number :type/*) @@ -17,14 +30,24 @@ (derive :type/Integer :type/Number) (derive :type/BigInteger :type/Integer) (derive :type/ZipCode :type/Integer) +(derive :type/Quantity :type/Integer) (derive :type/Float :type/Number) (derive :type/Decimal :type/Float) +(derive :type/Share :type/Float) + +(derive :type/Income :type/Number) +(derive :type/Discount :type/Number) +(derive :type/Price :type/Number) +(derive :type/GrossMargin :type/Number) +(derive :type/Cost :type/Number) (derive :type/Coordinate :type/Float) (derive :type/Latitude :type/Coordinate) (derive :type/Longitude :type/Coordinate) +(derive :type/Score :type/Number) +(derive :type/Duration :type/Number) ;;; Text Types @@ -41,9 +64,12 @@ (derive :type/City :type/Text) (derive :type/State :type/Text) (derive :type/Country :type/Text) +(derive :type/ZipCode :type/Text) (derive :type/Name :type/Text) +(derive :type/Title :type/Text) (derive :type/Description :type/Text) +(derive :type/Comment :type/Text) (derive :type/SerializedJSON :type/Text) (derive :type/SerializedJSON :type/Collection) @@ -62,6 +88,10 @@ (derive :type/UNIXTimestampSeconds :type/UNIXTimestamp) (derive :type/UNIXTimestampMilliseconds :type/UNIXTimestamp) +(derive :type/CreationTimestamp :type/DateTime) +(derive :type/JoinTimestamp :type/DateTime) +(derive :type/Birthdate :type/Date) + ;;; Other @@ -95,17 +125,32 @@ (derive :type/Category :type/Special) +(derive :type/Name :type/Category) +(derive :type/Title :type/Category) + (derive :type/City :type/Category) (derive :type/State :type/Category) (derive :type/Country :type/Category) -(derive :type/Name :type/Category) +(derive :type/User :type/*) +(derive :type/Author :type/User) +(derive :type/Owner :type/User) + +(derive :type/Product :type/Category) +(derive :type/Company :type/Category) +(derive :type/Subscription :type/Category) + +(derive :type/Source :type/Category) + +(derive :type/Boolean :type/Category) +(derive :type/Enum :type/Category) ;;; ---------------------------------------------------- Util Fns ---------------------------------------------------- (defn types->parents - "Return a map of various types to their parent types. This is intended for export to the frontend as part of - `MetabaseBootstrap` so it can build its own implementation of `isa?`." - [] - (into {} (for [t (descendants :type/*)] - {t (parents t)}))) + "Return a map of various types to their parent types. + This is intended for export to the frontend as part of `MetabaseBootstrap` so it can build its own implementation of `isa?`." + ([] (types->parents :type/*)) + ([root] + (into {} (for [t (descendants root)] + {t (parents t)})))) diff --git a/src/metabase/util.clj b/src/metabase/util.clj index 73278ead925f982096cf0660de583026a1ca4de6..05aa6728af088b8fb37f5c4ca338b974795eefda 100644 --- a/src/metabase/util.clj +++ b/src/metabase/util.clj @@ -817,7 +817,7 @@ v (select-nested-keys v nested-keys))}))) -(defn base-64-string? +(defn base64-string? "Is S a Base-64 encoded string?" ^Boolean [s] (boolean (when (string? s) @@ -870,12 +870,21 @@ (Math/log 10)))))) (defn update-when - "Like clojure.core/update but does not create a new key if it does not exist." + "Like clojure.core/update but does not create a new key if it does not exist. + Useful when you don't want to create cruft." [m k f & args] (if (contains? m k) (apply update m k f args) m)) +(defn update-in-when + "Like clojure.core/update-in but does not create new keys if they do not exist. + Useful when you don't want to create cruft." + [m k f & args] + (if (not= ::not-found (get-in m k ::not-found)) + (apply update-in m k f args) + m)) + (defn- str->date-time-with-formatters "Attempt to parse `DATE-STR` using `FORMATTERS`. First successful parse is returned, or nil" @@ -927,3 +936,10 @@ (some-> (str->date-time-with-formatters ordered-time-parsers date-str tz) coerce/to-long Time.))) + +(defn index-of + "Return index of the first element in `coll` for which `pred` reutrns true." + [pred coll] + (first (keep-indexed (fn [i x] + (when (pred x) i)) + coll))) diff --git a/src/metabase/util/embed.clj b/src/metabase/util/embed.clj index e471d19c87809da9607613f13d0bec862f9782ef..d38571bd20f9ae644853635fae04ccec71a3c375 100644 --- a/src/metabase/util/embed.clj +++ b/src/metabase/util/embed.clj @@ -9,6 +9,7 @@ [hiccup.core :refer [html]] [metabase.models.setting :as setting] [metabase.public-settings :as public-settings] + [puppetlabs.i18n.core :refer [tru]] [ring.util.codec :as codec])) ;;; ------------------------------------------------------------ PUBLIC LINKS UTIL FNS ------------------------------------------------------------ @@ -53,7 +54,7 @@ ;;; ------------------------------------------------------------ EMBEDDING UTIL FNS ------------------------------------------------------------ (setting/defsetting ^:private embedding-secret-key - "Secret key used to sign JSON Web Tokens for requests to `/api/embed` endpoints." + (tru "Secret key used to sign JSON Web Tokens for requests to `/api/embed` endpoints.") :setter (fn [new-value] (when (seq new-value) (assert (re-matches #"[0-9a-f]{64}" new-value) diff --git a/src/metabase/util/encryption.clj b/src/metabase/util/encryption.clj index 854a8e04e9357ffbb56db7f2eb2fb67c2042cc2e..fd920c98e479c8e84c9a472acf04d4d3cc8d0d9b 100644 --- a/src/metabase/util/encryption.clj +++ b/src/metabase/util/encryption.clj @@ -73,7 +73,7 @@ (try (decrypt secret-key s) (catch Throwable e - (if (u/base-64-string? s) + (if (u/base64-string? s) ;; if we can't decrypt `s`, but it *is* encrypted, log an error message and return `nil` (log/error "Cannot decrypt encrypted details. Have you changed or forgot to set MB_ENCRYPTION_SECRET_KEY?" (.getMessage e)) ;; otherwise return S without decrypting. It's probably not decrypted in the first place diff --git a/src/metabase/util/honeysql_extensions.clj b/src/metabase/util/honeysql_extensions.clj index bc636d97ca0145deab44bccd8ff7179602552e55..67c33ed4799daa706c6602e1dc218b192146f16c 100644 --- a/src/metabase/util/honeysql_extensions.clj +++ b/src/metabase/util/honeysql_extensions.clj @@ -9,7 +9,8 @@ (alter-meta! #'honeysql.core/format assoc :style/indent 1) (alter-meta! #'honeysql.core/call assoc :style/indent 1) -;; for some reason the metadata on these helper functions is wrong which causes Eastwood to fail, see https://github.com/jkk/honeysql/issues/123 +;; for some reason the metadata on these helper functions is wrong which causes Eastwood to fail, see +;; https://github.com/jkk/honeysql/issues/123 (alter-meta! #'honeysql.helpers/merge-left-join assoc :arglists '([m & clauses]) :style/indent 1) @@ -45,27 +46,35 @@ (defmethod hformat/fn-handler "extract" [_ unit expr] (str "extract(" (name unit) " from " (hformat/to-sql expr) ")")) +;; register the function "distinct-count" with HoneySQL +;; (hsql/format :%distinct-count.x) -> "count(distinct x)" +(defmethod hformat/fn-handler "distinct-count" [_ field] + (str "count(distinct " (hformat/to-sql field) ")")) -;; HoneySQL 0.7.0+ parameterizes numbers to fix issues with NaN and infinity -- see https://github.com/jkk/honeysql/pull/122. -;; However, this broke some of Metabase's behavior, specifically queries with calculated columns with numeric literals -- -;; some SQL databases can't recognize that a calculated field in a SELECT clause and a GROUP BY clause is the same thing if the calculation involves parameters. -;; Go ahead an use the old behavior so we can keep our HoneySQL dependency up to date. + +;; HoneySQL 0.7.0+ parameterizes numbers to fix issues with NaN and infinity -- see +;; https://github.com/jkk/honeysql/pull/122. However, this broke some of Metabase's behavior, specifically queries +;; with calculated columns with numeric literals -- some SQL databases can't recognize that a calculated field in a +;; SELECT clause and a GROUP BY clause is the same thing if the calculation involves parameters. Go ahead an use the +;; old behavior so we can keep our HoneySQL dependency up to date. (extend-protocol honeysql.format/ToSql java.lang.Number (to-sql [x] (str x))) -;; HoneySQL automatically assumes that dots within keywords are used to separate schema / table / field / etc. -;; To handle weird situations where people actually put dots *within* a single identifier we'll replace those dots with lozenges, -;; let HoneySQL do its thing, then switch them back at the last second +;; HoneySQL automatically assumes that dots within keywords are used to separate schema / table / field / etc. To +;; handle weird situations where people actually put dots *within* a single identifier we'll replace those dots with +;; lozenges, let HoneySQL do its thing, then switch them back at the last second ;; -;; TODO - Maybe instead of this lozengey hackiness it would make more sense just to add a new "identifier" record type that implements `ToSql` in a more intelligent way +;; TODO - Maybe instead of this lozengey hackiness it would make more sense just to add a new "identifier" record type +;; that implements `ToSql` in a more intelligent way (defn escape-dots "Replace dots in a string with WHITE MEDIUM LOZENGES (⬨)." ^String [s] (s/replace (name s) #"\." "⬨")) (defn qualify-and-escape-dots - "Combine several NAME-COMPONENTS into a single Keyword, and escape dots in each name by replacing them with WHITE MEDIUM LOZENGES (⬨). + "Combine several NAME-COMPONENTS into a single Keyword, and escape dots in each name by replacing them with WHITE + MEDIUM LOZENGES (⬨). (qualify-and-escape-dots :ab.c :d) -> :ab⬨c.d" ^clojure.lang.Keyword [& name-components] diff --git a/src/metabase/util/schema.clj b/src/metabase/util/schema.clj index 83b551e227e3a6da0cefad44c39ba2f4366b46a9..d4f9b70728afdd09b1c1b206cbbbbb56625682cf 100644 --- a/src/metabase/util/schema.clj +++ b/src/metabase/util/schema.clj @@ -5,6 +5,7 @@ [medley.core :as m] [metabase.util :as u] [metabase.util.password :as password] + [puppetlabs.i18n.core :refer [tru]] [schema.core :as s])) ;; always validate all schemas in s/defn function declarations. See @@ -48,11 +49,11 @@ These are used as a fallback by API param validation if no value for `:api-error-message` is present." [existing-schema] (cond - (= existing-schema s/Int) "value must be an integer." - (= existing-schema s/Str) "value must be a string." - (= existing-schema s/Bool) "value must be a boolean." - (instance? java.util.regex.Pattern existing-schema) (format "value must be a string that matches the regex `%s`." - existing-schema))) + (= existing-schema s/Int) (tru "value must be an integer.") + (= existing-schema s/Str) (tru "value must be a string.") + (= existing-schema s/Bool) (tru "value must be a boolean.") + (instance? java.util.regex.Pattern existing-schema) (tru "value must be a string that matches the regex `{0}`." + existing-schema))) (defn api-error-message "Extract the API error messages attached to a schema, if any. @@ -68,24 +69,24 @@ ;; "value may be nil, or if non-nil, value must be ..." (when (instance? schema.core.Maybe schema) (when-let [message (api-error-message (:schema schema))] - (str "value may be nil, or if non-nil, " message))) + (tru "value may be nil, or if non-nil, {0}" message))) ;; we can do something similar for enum schemas which are also likely to be defined inline (when (instance? schema.core.EnumSchema schema) - (format "value must be one of: %s." (str/join ", " (for [v (sort (:vs schema))] - (str "`" v "`"))))) + (tru "value must be one of: {0}." (str/join ", " (for [v (sort (:vs schema))] + (str "`" v "`"))))) ;; For cond-pre schemas we'll generate something like ;; 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'). (when (instance? schema.core.CondPre schema) - (str "value must satisfy one of the following requirements: " + (str (tru "value must satisfy one of the following requirements: ") (str/join " " (for [[i child-schema] (m/indexed (:schemas schema))] (format "%d) %s" (inc i) (api-error-message child-schema)))))) ;; do the same for sequences of a schema (when (vector? schema) - (str "value must be an array." (when (= (count schema) 1) - (when-let [message (:api-error-message (first schema))] - (str " Each " message))))))) + (str (tru "value must be an array.") (when (= (count schema) 1) + (when-let [message (:api-error-message (first schema))] + (str " " (tru "Each {0}" message)))))))) (defn non-empty @@ -93,7 +94,7 @@ (i.e., it must satisfy `seq`)." [schema] (with-api-error-message (s/constrained schema seq "Non-empty") - (str (api-error-message schema) " The array cannot be empty."))) + (str (api-error-message schema) " " (tru "The array cannot be empty.")))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | USEFUL SCHEMAS | @@ -102,57 +103,62 @@ (def NonBlankString "Schema for a string that cannot be blank." (with-api-error-message (s/constrained s/Str (complement str/blank?) "Non-blank string") - "value must be a non-blank string.")) + (tru "value must be a non-blank string."))) ;; TODO - rename this to `PositiveInt`? (def IntGreaterThanZero "Schema representing an integer than must also be greater than zero." (with-api-error-message - (s/constrained s/Int (partial < 0) "Integer greater than zero") - "value must be an integer greater than zero.")) + (s/constrained s/Int (partial < 0) (tru "Integer greater than zero")) + (tru "value must be an integer greater than zero."))) (def KeywordOrString "Schema for something that can be either a `Keyword` or a `String`." - (s/named (s/cond-pre s/Keyword s/Str) "Keyword or string")) + (s/named (s/cond-pre s/Keyword s/Str) (tru "Keyword or string"))) (def FieldType "Schema for a valid Field type (does it derive from `:type/*`)?" - (with-api-error-message (s/pred (u/rpartial isa? :type/*) "Valid field type") - "value must be a valid field type.")) + (with-api-error-message (s/pred (u/rpartial isa? :type/*) (tru "Valid field type")) + (tru "value must be a valid field type."))) (def FieldTypeKeywordOrString "Like `FieldType` (e.g. a valid derivative of `:type/*`) but allows either a keyword or a string. This is useful especially for validating API input or objects coming out of the DB as it is unlikely those values will be encoded as keywords at that point." - (with-api-error-message (s/pred #(isa? (keyword %) :type/*) "Valid field type (keyword or string)") - "value must be a valid field type (keyword or string).")) + (with-api-error-message (s/pred #(isa? (keyword %) :type/*) (tru "Valid field type (keyword or string)")) + (tru "value must be a valid field type (keyword or string)."))) + +(def EntityTypeKeywordOrString + "Validates entity type derivatives of `:entity/*`. Allows strings or keywords" + (with-api-error-message (s/pred #(isa? (keyword %) :entity/*) (tru "Valid entity type (keyword or string)")) + (tru "value must be a valid entity type (keyword or string)."))) (def Map "Schema for a valid map." - (with-api-error-message (s/pred map? "Valid map") - "value must be a map.")) + (with-api-error-message (s/pred map? (tru "Valid map")) + (tru "value must be a map."))) (def Email "Schema for a valid email string." - (with-api-error-message (s/constrained s/Str u/email? "Valid email address") - "value must be a valid email address.")) + (with-api-error-message (s/constrained s/Str u/email? (tru "Valid email address")) + (tru "value must be a valid email address."))) (def ComplexPassword "Schema for a valid password of sufficient complexity." (with-api-error-message (s/constrained s/Str password/is-complex?) - "Insufficient password strength")) + (tru "Insufficient password strength"))) (def IntString "Schema for a string that can be parsed as an integer. Something that adheres to this schema is guaranteed to to work with `Integer/parseInt`." (with-api-error-message (s/constrained s/Str #(u/ignore-exceptions (Integer/parseInt %))) - "value must be a valid integer.")) + (tru "value must be a valid integer."))) (def IntStringGreaterThanZero "Schema for a string that can be parsed as an integer, and is greater than zero. Something that adheres to this schema is guaranteed to to work with `Integer/parseInt`." (with-api-error-message (s/constrained s/Str #(u/ignore-exceptions (< 0 (Integer/parseInt %)))) - "value must be a valid integer greater than zero.")) + (tru "value must be a valid integer greater than zero."))) (defn- boolean-string? ^Boolean [s] (boolean (when (string? s) @@ -163,14 +169,14 @@ "Schema for a string that is a valid representation of a boolean (either `true` or `false`). Something that adheres to this schema is guaranteed to to work with `Boolean/parseBoolean`." (with-api-error-message (s/constrained s/Str boolean-string?) - "value must be a valid boolean string ('true' or 'false').")) + (tru "value must be a valid boolean string (''true'' or ''false'')."))) (def JSONString "Schema for a string that is valid serialized JSON." (with-api-error-message (s/constrained s/Str #(u/ignore-exceptions (json/parse-string %))) - "value must be a valid JSON string.")) + (tru "value must be a valid JSON string."))) (def EmbeddingParams "Schema for a valid map of embedding params." (with-api-error-message (s/maybe {s/Keyword (s/enum "disabled" "enabled" "locked")}) - "value must be a valid embedding params map.")) + (tru "value must be a valid embedding params map."))) diff --git a/src/metabase/util/stats.clj b/src/metabase/util/stats.clj index 4edb73068ab9dc47521e2fa0220fb97e7f3a6d94..c41b2eb278d2bbcddb35290e1e46dc44c95c90e3 100644 --- a/src/metabase/util/stats.clj +++ b/src/metabase/util/stats.clj @@ -102,9 +102,19 @@ (frequencies (map k many-maps))) (defn- histogram - "Bin some frequencies using a passed in BINNING-FN." - [binning-fn many-maps k] - (frequencies (map binning-fn (vals (value-frequencies many-maps k))))) + "Bin some frequencies using a passed in `binning-fn`. + + ;; Generate histogram for values of :a; `1` appears 3 times and `2` and `3` both appear once + (histogram bin-micro-number [{:a 1} {:a 1} {:a 1} {:a 2} {:a 3}] :a) + ;; -> {\"3+\" 1, \"1\" 2} + + ;; (or if you already have the counts) + (histogram bin-micro-number [3 1 1]) + ;; -> {\"3+\" 1, \"1\" 2}" + ([binning-fn counts] + (frequencies (map binning-fn counts))) + ([binning-fn many-maps k] + (histogram binning-fn (vals (value-frequencies many-maps k))))) (def ^:private micro-histogram "Return a histogram for micro numbers." @@ -212,19 +222,78 @@ :with_locked_params (contains? embedding-params-vals "locked") :with_disabled_params (contains? embedding-params-vals "disabled")})))})) +(defn- db-frequencies + "Fetch the frequencies of a given `column` with a normal SQL `SELECT COUNT(*) ... GROUP BY` query. This is way more + efficient than fetching every single row and counting them in Clojure-land! + + (db-frequencies Database :engine) + ;; -> {\"h2\" 2, \"postgres\" 1, ...} + + ;; include `WHERE` conditions or other arbitrary HoneySQL + (db-frequencies Database :engine {:where [:= :is_sample false]}) + ;; -> {\"h2\" 1, \"postgres\" 1, ...} + + ;; Generate a histogram: + (micro-histogram (vals (db-frequencies Database :engine))) + ;; -> {\"2\" 1, \"1\" 1, ...} + + ;; Include `WHERE` clause that includes conditions for a Table related by an FK relationship: + ;; (Number of Tables per DB engine) + (db-frequencies Table (db/qualify Database :engine) + {:left-join [Database [:= (db/qualify Database :id) + (db/qualify Table :db_id)]]}) + ;; -> {\"googleanalytics\" 4, \"postgres\" 48, \"h2\" 9}" + {:style/indent 2} + [model column & [additonal-honeysql]] + (into {} (for [{:keys [k count]} (db/select [model [column :k] [:%count.* :count]] + (merge {:group-by [column]} + additonal-honeysql))] + [k count]))) + +(defn- num-notifications-with-xls-or-csv-cards + "Return the number of Notifications that satisfy `where-conditions` that have at least one PulseCard with `include_xls` or + `include_csv`. + + ;; Pulses only (filter out Alerts) + (num-notifications-with-xls-or-csv-cards [:= :alert_condition nil])" + [& where-conditions] + ;; :%distinct-count is a custom fn we registered in `metabase.util.honeysql-extensions`! + (-> (db/query {:select [[:%distinct-count.pulse.id :count]] + :from [:pulse] + :left-join [:pulse_card [:= :pulse.id :pulse_card.pulse_id]] + :where (cons + :and + (cons + [:or [:= :pulse_card.include_csv true] + [:= :pulse_card.include_xls true]] + where-conditions))}) + first + :count)) + (defn- pulse-metrics - "Get mes based on pulses + "Get metrics based on pulses TODO: characterize by non-user account emails, # emails" [] - (let [pulses (db/select [Pulse :creator_id]) - pulse-cards (db/select [PulseCard :card_id :pulse_id]) - pulse-channels (db/select [PulseChannel :channel_type :schedule_type])] - {:pulses (count pulses) - :pulse_types (frequencies (map :channel_type pulse-channels)) - :pulse_schedules (frequencies (map :schedule_type pulse-channels)) - :num_pulses_per_user (medium-histogram pulses :creator_id) - :num_pulses_per_card (medium-histogram pulse-cards :card_id) - :num_cards_per_pulses (medium-histogram pulse-cards :pulse_id)})) + (let [pulse-conditions {:left-join [Pulse [:= :pulse.id :pulse_id]], :where [:= :pulse.alert_condition nil]}] + {:pulses (db/count Pulse :alert_condition nil) + ;; "Table Cards" are Cards that include a Table you can download + :with_table_cards (num-notifications-with-xls-or-csv-cards [:= :alert_condition nil]) + :pulse_types (db-frequencies PulseChannel :channel_type pulse-conditions) + :pulse_schedules (db-frequencies PulseChannel :schedule_type pulse-conditions) + :num_pulses_per_user (medium-histogram (vals (db-frequencies Pulse :creator_id (dissoc pulse-conditions :left-join)))) + :num_pulses_per_card (medium-histogram (vals (db-frequencies PulseCard :card_id pulse-conditions))) + :num_cards_per_pulses (medium-histogram (vals (db-frequencies PulseCard :pulse_id pulse-conditions)))})) + +(defn- alert-metrics [] + (let [alert-conditions {:left-join [Pulse [:= :pulse.id :pulse_id]], :where [:not= (db/qualify Pulse :alert_condition) nil]}] + {:alerts (db/count Pulse :alert_condition [:not= nil]) + :with_table_cards (num-notifications-with-xls-or-csv-cards [:not= :alert_condition nil]) + :first_time_only (db/count Pulse :alert_condition [:not= nil], :alert_first_only true) + :above_goal (db/count Pulse :alert_condition [:not= nil], :alert_above_goal true) + :alert_types (db-frequencies PulseChannel :channel_type alert-conditions) + :num_alerts_per_user (medium-histogram (vals (db-frequencies Pulse :creator_id (dissoc alert-conditions :left-join)))) + :num_alerts_per_card (medium-histogram (vals (db-frequencies PulseCard :card_id alert-conditions))) + :num_cards_per_alerts (medium-histogram (vals (db-frequencies PulseCard :pulse_id alert-conditions)))})) (defn- label-metrics @@ -284,33 +353,10 @@ ;;; Execution Metrics -;; Because the QueryExecution table can number in the millions of rows, it isn't safe to pull the entire thing into memory; -;; instead, we'll fetch rows of QueryExecutions in chunks, building the summary as we go - -(def ^:private ^:const executions-chunk-size - "Number of QueryExecutions to fetch per chunk. This should be a good tradeoff between not being too large (which could - cause us to run out of memory) and not being too small (which would make calculating this summary excessively slow)." - 5000) - -;; fetch chunks by ID, e.g. 1-5000, 5001-10000, etc. - -(defn- executions-chunk - "Fetch the chunk of QueryExecutions whose ID is greater than STARTING-ID." - [starting-id] - (db/select [QueryExecution :id :executor_id :running_time :error] - :id [:> starting-id] - {:order-by [:id], :limit executions-chunk-size})) - -(defn- executions-lazy-seq - "Return a lazy seq of all QueryExecutions." - ([] - (executions-lazy-seq 0)) - ([starting-id] - (when-let [chunk (seq (executions-chunk starting-id))] - (lazy-cat chunk (executions-lazy-seq (:id (last chunk))))))) - (defn summarize-executions - "Summarize EXECUTIONS, by incrementing approriate counts in a summary map." + "Summarize `executions`, by incrementing approriate counts in a summary map." + ([] + (summarize-executions (db/select-reducible [QueryExecution :executor_id :running_time :error]))) ([executions] (reduce summarize-executions {:executions 0, :by_status {}, :num_per_user {}, :num_by_latency {}} executions)) ([summary execution] @@ -330,8 +376,7 @@ (defn- execution-metrics "Get metrics based on QueryExecutions." [] - (-> (executions-lazy-seq) - summarize-executions + (-> (summarize-executions) (update :num_per_user summarize-executions-per-user))) @@ -344,6 +389,7 @@ {:average_entry_size (int (or length 0)) :num_queries_cached (bin-small-number count)})) + ;;; System Metrics (defn- bytes->megabytes [b] @@ -381,6 +427,7 @@ :label (label-metrics) :metric (metric-metrics) :pulse (pulse-metrics) + :alert (alert-metrics) :question (question-metrics) :segment (segment-metrics) :system (system-metrics) diff --git a/test/metabase/api/alert_test.clj b/test/metabase/api/alert_test.clj index a7294ee676e2f23389afe2485bf9ba22c92777e3..3e5acc644d91565d0c2ea6b0797002ab9d0158c8 100644 --- a/test/metabase/api/alert_test.clj +++ b/test/metabase/api/alert_test.clj @@ -110,7 +110,7 @@ ~@body))) (defn- rasta-new-alert-email [body-map] - (et/email-to :rasta {:subject "You setup an alert", + (et/email-to :rasta {:subject "You set up an alert", :body (merge {"https://metabase.com/testmb" true, "My question" true} body-map)})) @@ -185,7 +185,7 @@ (assoc :creator (user-details :crowberto)) (assoc-in [:card :include_csv] true) (update-in [:channels 0] merge {:schedule_hour 12, :schedule_type "daily", :recipients (set (map recipient-details [:rasta :crowberto]))})) - (merge (et/email-to :crowberto {:subject "You setup an alert", + (merge (et/email-to :crowberto {:subject "You set up an alert", :body {"https://metabase.com/testmb" true, "My question" true "now getting alerts" false diff --git a/test/metabase/api/automagic_dashboards_test.clj b/test/metabase/api/automagic_dashboards_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..ad5214376633975cfe7b84215e7346911803991b --- /dev/null +++ b/test/metabase/api/automagic_dashboards_test.clj @@ -0,0 +1,111 @@ +(ns metabase.api.automagic-dashboards-test + (:require [expectations :refer :all] + [metabase.api.common :as api] + [metabase.automagic-dashboards.core :as magic] + [metabase.models + [card :refer [Card]] + [metric :refer [Metric]] + [segment :refer [Segment]] + [user :as user]] + [metabase.test.data :as data] + [metabase.test.data.users :as test-users] + [metabase.test.util :as tu] + [toucan.util.test :as tt])) + +(defmacro with-rasta + "Execute body with rasta as the current user." + [& body] + `(binding [api/*current-user-id* (test-users/user->id :rasta) + api/*current-user-permissions-set* (-> :rasta + test-users/user->id + user/permissions-set + atom)] + ~@body)) + +(defmacro ^:private with-dashboard-cleanup + [& body] + `(tu/with-model-cleanup [(quote ~'Card) (quote ~'Dashboard) (quote ~'Collection) + (quote ~'DashboardCard)] + ~@body)) + +(defn- api-call + [template & args] + (with-rasta + (with-dashboard-cleanup + (some? ((test-users/user->client :rasta) :get 200 (apply format (str "automagic-dashboards/" template) args)))))) + +(expect (api-call "table/%s" (data/id :venues))) +(expect (api-call "table/%s/rule/example/indepth" (data/id :venues))) + + +(expect + (tt/with-temp* [Metric [{metric-id :id} {:table_id (data/id :venues) + :definition {:query {:aggregation ["count"]}}}]] + (api-call "metric/%s" metric-id))) + + +(expect + (tt/with-temp* [Segment [{segment-id :id} {:table_id (data/id :venues) + :definition {:filter [:> [:field-id-id (data/id :venues :price)] 10]}}]] + (api-call "segment/%s" segment-id))) + +(expect + (tt/with-temp* [Segment [{segment-id :id} {:table_id (data/id :venues) + :definition {:filter [:> [:field-id (data/id :venues :price)] 10]}}]] + (api-call "segment/%s/rule/example/indepth" segment-id))) + + +(expect (api-call "field/%s" (data/id :venues :price))) + + +(expect + (tt/with-temp* [Card [{card-id :id} {:table_id (data/id :venues) + :dataset_query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] + :source_table (data/id :venues)} + :type :query + :database (data/id)}}]] + (api-call "question/%s" card-id))) + +(expect + (tt/with-temp* [Card [{card-id :id} {:table_id (data/id :venues) + :dataset_query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] + :source_table (data/id :venues)} + :type :query + :database (data/id)}}]] + (api-call "question/%s/cell/%s" card-id (->> [:> [:field-id (data/id :venues :price)] 5] + (#'magic/encode-base64-json))))) + +(expect + (tt/with-temp* [Card [{card-id :id} {:table_id (data/id :venues) + :dataset_query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] + :source_table (data/id :venues)} + :type :query + :database (data/id)}}]] + (api-call "question/%s/cell/%s/rule/example/indepth" card-id + (->> [:> [:field-id (data/id :venues :price)] 5] + (#'magic/encode-base64-json))))) + + +(expect (api-call "adhoc/%s" (->> {:query {:filter [:> [:field-id (data/id :venues :price)] 10] + :source_table (data/id :venues)} + :type :query + :database (data/id)} + (#'magic/encode-base64-json)))) + +(expect (api-call "adhoc/%s/cell/%s" + (->> {:query {:filter [:> [:field-id (data/id :venues :price)] 10] + :source_table (data/id :venues)} + :type :query + :database (data/id)} + (#'magic/encode-base64-json)) + (->> [:> [:field-id (data/id :venues :price)] 5] + (#'magic/encode-base64-json)))) + +(expect (api-call "adhoc/%s/cell/%s/rule/example/indepth" + (->> {:query {:filter [:> [:field-id (data/id :venues :price)] 10] + :source_table (data/id :venues)} + :type :query + :database (data/id)} + (#'magic/encode-base64-json)) + (->> [:> [:field-id (data/id :venues :price)] 5] + (#'magic/encode-base64-json)))) diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index 055f44e5ea9a6a5c1cb31fe42c8c12eab14f1b6b..5ddc3f6333f9696acc0dcfe3448c0813f2820145 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -556,17 +556,11 @@ (Pulse pulse-id)])) ;; Adding an additional breakout will cause the alert to be removed -(tt/expect-with-temp [Database - [{database-id :id}] - - Table - [{table-id :id} {:db_id database-id}] - - Card +(tt/expect-with-temp [Card [card {:display :line :visualization_settings {:graph.goal_value 10} :dataset_query (assoc-in - (mbql-count-query database-id table-id) + (mbql-count-query (data/id) (data/id :checkins)) [:query :breakout] [["datetime-field" (data/id :checkins :date) "hour"]])}] @@ -592,9 +586,9 @@ (et/with-fake-inbox (et/with-expected-messages 1 ((user->client :crowberto) :put 200 (str "card/" (u/get-id card)) - {:dataset_query (assoc-in (mbql-count-query database-id table-id) + {:dataset_query (assoc-in (mbql-count-query (data/id) (data/id :checkins)) [:query :breakout] [["datetime-field" (data/id :checkins :date) "hour"] - ["datetime-field" (data/id :checkins :date) "second"]])})) + ["datetime-field" (data/id :checkins :date) "minute"]])})) [(et/regex-email-bodies #"the question was edited by Crowberto Corv") (Pulse pulse-id)])) @@ -693,10 +687,10 @@ (defn- do-with-temp-native-card {:style/indent 0} [f] (tt/with-temp* [Database [{database-id :id} {:details (:details (Database (id))), :engine :h2}] - Table [{table-id :id} {:db_id database-id, :name "CATEGORIES"}] - Card [card {:dataset_query {:database database-id - :type :native - :native {:query "SELECT COUNT(*) FROM CATEGORIES;"}}}]] + Table [{table-id :id} {:db_id database-id, :name "CATEGORIES"}] + Card [card {:dataset_query {:database database-id + :type :native + :native {:query "SELECT COUNT(*) FROM CATEGORIES;"}}}]] ;; delete all permissions for this DB (perms/delete-related-permissions! (perms-group/all-users) (perms/object-path database-id)) (f database-id card))) @@ -1072,3 +1066,9 @@ (tt/with-temp Card [card {:enable_embedding true}] (for [card ((user->client :crowberto) :get 200 "card/embeddable")] (m/map-vals boolean (select-keys card [:name :id])))))) + +;; Test related/recommended entities +(expect + #{:table :metrics :segments :dashboard-mates :similar-questions :canonical-metric :dashboards :collections} + (tt/with-temp* [Card [{card-id :id}]] + (-> ((user->client :crowberto) :get 200 (format "card/%s/related" card-id)) keys set))) diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj index 788c6bd7e03ab9fa42aaad86ebe2b79465df447f..673caea0dd7516ae6745fa9b21606852aaefae2a 100644 --- a/test/metabase/api/dashboard_test.clj +++ b/test/metabase/api/dashboard_test.clj @@ -133,7 +133,7 @@ :display "table" :query_type nil :dataset_query {} - :read_permissions [] + :read_permissions nil :visualization_settings {} :query_average_duration nil :in_public_dashboard false @@ -620,3 +620,9 @@ [116 69 -44 77 100 8 -40 -67 25 -4 27 -21 111 98 -45 85 83 -27 -39 8 63 -25 -88 74 32 -10 -2 35 102 -72 -104 111] 666 [-84 -2 87 22 -4 105 68 48 -113 93 -29 52 3 102 123 -70 -123 36 31 76 -16 87 70 116 -93 109 -88 108 125 -36 -43 73] 777 [90 127 103 -71 -76 -36 41 -107 -7 -13 -83 -87 28 86 -94 110 74 -86 110 -54 -128 124 102 -73 -127 88 77 -36 62 5 -84 -100] 888})) + +;; Test related/recommended entities +(expect + #{:cards} + (tt/with-temp* [Dashboard [{dashboard-id :id}]] + (-> ((user->client :crowberto) :get 200 (format "dashboard/%s/related" dashboard-id)) keys set))) diff --git a/test/metabase/api/database_test.clj b/test/metabase/api/database_test.clj index 829d15f48be7a848598412328f5cf44d894685c2..53a4b7eb5d13edd36f64dfc9fe3708c47126edab 100644 --- a/test/metabase/api/database_test.clj +++ b/test/metabase/api/database_test.clj @@ -15,7 +15,7 @@ [field-values :as field-values] [sync-metadata :as sync-metadata]] [metabase.test - [data :as data :refer :all] + [data :as data] [util :as tu :refer [match-$]]] [metabase.test.data [datasets :as datasets] @@ -72,9 +72,9 @@ :timezone nil}) (defn- db-details - "Return default column values for a database (either the test database, via `(db)`, or optionally passed in)." + "Return default column values for a database (either the test database, via `(data/db)`, or optionally passed in)." ([] - (db-details (db))) + (db-details (data/db))) ([db] (merge default-db-details (match-$ db @@ -102,12 +102,12 @@ ;; regular users *should not* see DB details (expect (add-schedules (dissoc (db-details) :details)) - ((user->client :rasta) :get 200 (format "database/%d" (id)))) + ((user->client :rasta) :get 200 (format "database/%d" (data/id)))) ;; superusers *should* see DB details (expect (add-schedules (db-details)) - ((user->client :crowberto) :get 200 (format "database/%d" (id)))) + ((user->client :crowberto) :get 200 (format "database/%d" (data/id)))) ;; ## POST /api/database ;; Check that we can create a Database @@ -149,7 +149,7 @@ (def ^:private default-table-details {:description nil :entity_name nil - :entity_type nil + :entity_type "entity/GenericTable" :caveats nil :points_of_interest nil :visibility_type nil @@ -157,22 +157,23 @@ :show_in_getting_started false}) (defn- table-details [table] - (merge default-table-details - (match-$ table - {:description $ - :entity_type $ - :visibility_type $ - :schema $ - :name $ - :display_name $ - :rows $ - :updated_at $ - :entity_name $ - :active $ - :id $ - :db_id $ - :raw_table_id $ - :created_at $}))) + (-> default-table-details + (merge (match-$ table + {:description $ + :entity_type $ + :visibility_type $ + :schema $ + :name $ + :display_name $ + :rows $ + :updated_at $ + :entity_name $ + :active $ + :id $ + :db_id $ + :raw_table_id $ + :created_at $})) + (update :entity_type (comp (partial str "entity/") name)))) ;; TODO - this is a test code smell, each test should clean up after itself and this step shouldn't be neccessary. One day we should be able to remove this! @@ -184,7 +185,7 @@ (let [ids-to-skip (into (set skip) (for [engine datasets/all-valid-engines :let [id (datasets/when-testing-engine engine - (:id (get-or-create-test-data-db! (driver/engine->driver engine))))] + (:id (data/get-or-create-test-data-db! (driver/engine->driver engine))))] :when id] id))] (when-let [dbs (seq (db/select [Database :name :engine :id] :id [:not-in ids-to-skip]))] @@ -203,7 +204,7 @@ (set (filter identity (conj (for [engine datasets/all-valid-engines] (datasets/when-testing-engine engine (merge default-db-details - (match-$ (get-or-create-test-data-db! (driver/engine->driver engine)) + (match-$ (data/get-or-create-test-data-db! (driver/engine->driver engine)) {:created_at $ :engine (name $engine) :id $ @@ -243,7 +244,7 @@ :features (map name (driver/features (driver/engine->driver :postgres)))})) (filter identity (for [engine datasets/all-valid-engines] (datasets/when-testing-engine engine - (let [database (get-or-create-test-data-db! (driver/engine->driver engine))] + (let [database (data/get-or-create-test-data-db! (driver/engine->driver engine))] (merge default-db-details (match-$ database {:created_at $ @@ -286,7 +287,7 @@ ;; ## GET /api/database/:id/metadata (expect (merge default-db-details - (match-$ (db) + (match-$ (data/db) {:created_at $ :engine "h2" :id $ @@ -295,21 +296,21 @@ :timezone $ :features (mapv name (driver/features (driver/engine->driver :h2))) :tables [(merge default-table-details - (match-$ (Table (id :categories)) + (match-$ (Table (data/id :categories)) {:schema "PUBLIC" :name "CATEGORIES" :display_name "Categories" - :fields [(assoc (field-details (Field (id :categories :id))) - :table_id (id :categories) + :fields [(assoc (field-details (Field (data/id :categories :id))) + :table_id (data/id :categories) :special_type "type/PK" :name "ID" :display_name "ID" :database_type "BIGINT" :base_type "type/BigInteger" :visibility_type "normal" - :has_field_values "search") - (assoc (field-details (Field (id :categories :name))) - :table_id (id :categories) + :has_field_values "none") + (assoc (field-details (Field (data/id :categories :name))) + :table_id (data/id :categories) :special_type "type/Name" :name "NAME" :display_name "Name" @@ -319,13 +320,13 @@ :has_field_values "list")] :segments [] :metrics [] - :rows 75 + :rows nil :updated_at $ - :id (id :categories) + :id (data/id :categories) :raw_table_id $ - :db_id (id) + :db_id (data/id) :created_at $}))]})) - (let [resp ((user->client :rasta) :get 200 (format "database/%d/metadata" (id)))] + (let [resp ((user->client :rasta) :get 200 (format "database/%d/metadata" (data/id)))] (assoc resp :tables (filter #(= "CATEGORIES" (:name %)) (:tables resp))))) @@ -334,18 +335,18 @@ (expect [["USERS" "Table"] ["USER_ID" "CHECKINS :type/Integer :type/FK"]] - ((user->client :rasta) :get 200 (format "database/%d/autocomplete_suggestions" (id)) :prefix "u")) + ((user->client :rasta) :get 200 (format "database/%d/autocomplete_suggestions" (data/id)) :prefix "u")) (expect [["CATEGORIES" "Table"] ["CHECKINS" "Table"] ["CATEGORY_ID" "VENUES :type/Integer :type/FK"]] - ((user->client :rasta) :get 200 (format "database/%d/autocomplete_suggestions" (id)) :prefix "c")) + ((user->client :rasta) :get 200 (format "database/%d/autocomplete_suggestions" (data/id)) :prefix "c")) (expect [["CATEGORIES" "Table"] ["CATEGORY_ID" "VENUES :type/Integer :type/FK"]] - ((user->client :rasta) :get 200 (format "database/%d/autocomplete_suggestions" (id)) :prefix "cat")) + ((user->client :rasta) :get 200 (format "database/%d/autocomplete_suggestions" (data/id)) :prefix "cat")) ;;; GET /api/database?include_cards=true @@ -459,18 +460,18 @@ Card [_ (assoc (card-with-mbql-query "Cum Count Card" :source-table (data/id :checkins) :aggregation [[:cum-count]] - :breakout [[:datetime-field [:field-id (data/id :checkins :date) :month]]]) + :breakout [[:datetime-field [:field-id (data/id :checkins :date)] :month]]) :result_metadata [{:name "num_toucans"}])]] (saved-questions-virtual-db (virtual-table-for-card ok-card)) (fetch-virtual-database)) -;; cum sum using old-style single aggregation syntax +;; cum count using old-style single aggregation syntax (tt/expect-with-temp [Card [ok-card (ok-mbql-card)] Card [_ (assoc (card-with-mbql-query "Cum Sum Card" :source-table (data/id :checkins) - :aggregation [:cum-sum] - :breakout [[:datetime-field [:field-id (data/id :checkins :date) :month]]]) + :aggregation [:cum-count] + :breakout [[:datetime-field [:field-id (data/id :checkins :date)] :month]]) :result_metadata [{:name "num_toucans"}])]] (saved-questions-virtual-db (virtual-table-for-card ok-card)) diff --git a/test/metabase/api/dataset_test.clj b/test/metabase/api/dataset_test.clj index e4915aaf7245d672bac6676e8b6da67f05307cde..bb42648738dc820e0b7d62af5b41d22ef78d6842 100644 --- a/test/metabase/api/dataset_test.clj +++ b/test/metabase/api/dataset_test.clj @@ -8,16 +8,17 @@ [dk.ative.docjure.spreadsheet :as spreadsheet] [expectations :refer :all] [medley.core :as m] - [metabase.api.dataset :refer [default-query-constraints]] [metabase.models [database :refer [Database]] [query-execution :refer [QueryExecution]]] + [metabase.query-processor :as qp] [metabase.query-processor.middleware.expand :as ql] [metabase.sync :as sync] [metabase.test [data :refer :all] [util :as tu]] [metabase.test.data + [datasets :refer [expect-with-engine]] [dataset-definitions :as defs] [users :refer :all]] [toucan.db :as db])) @@ -75,7 +76,7 @@ (ql/aggregation (ql/count)))) (assoc :type "query") (assoc-in [:query :aggregation] [{:aggregation-type "count", :custom-name nil}]) - (assoc :constraints default-query-constraints)) + (assoc :constraints qp/default-query-constraints)) :started_at true :running_time true :average_execution_time nil} @@ -113,7 +114,7 @@ :json_query {:database (id) :type "native" :native {:query "foobar"} - :constraints default-query-constraints} + :constraints qp/default-query-constraints} :started_at true :running_time true} ;; QueryExecution entry in the DB @@ -195,6 +196,18 @@ (query checkins))))] (take 5 (parse-and-sort-csv result))))) +;; SQLite doesn't return proper date objects but strings, they just pass through the qp untouched +(expect-with-engine :sqlite + [["1" "2014-04-07" "5" "12"] + ["2" "2014-09-18" "1" "31"] + ["3" "2014-09-15" "8" "56"] + ["4" "2014-03-11" "5" "4"] + ["5" "2013-05-05" "3" "49"]] + (let [result ((user->client :rasta) :post 200 "dataset/csv" :query + (json/generate-string (wrap-inner-query + (query checkins))))] + (take 5 (parse-and-sort-csv result)))) + ;; DateTime fields are untouched when exported (expect [["1" "Plato Yeshua" "2014-04-01T08:30:00.000Z"] diff --git a/test/metabase/api/email_test.clj b/test/metabase/api/email_test.clj index adb7f6fe1f0d83a0d50b084be5daeb3314287594..c3951a725cee41196326d4a9d52d04b411c34d33 100644 --- a/test/metabase/api/email_test.clj +++ b/test/metabase/api/email_test.clj @@ -12,21 +12,39 @@ :email-smtp-password (setting/get :email-smtp-password) :email-from-address (setting/get :email-from-address)}) -;; /api/email/test - sends a test email to the given user -(expect +(def ^:private default-email-settings {:email-smtp-host "foobar" :email-smtp-port "789" :email-smtp-security "tls" :email-smtp-username "munchkin" :email-smtp-password "gobble gobble" - :email-from-address "eating@hungry.com"} - (let [orig-settings (email-settings) - _ ((user->client :crowberto) :put 200 "email" {:email-smtp-host "foobar" - :email-smtp-port "789" - :email-smtp-security "tls" - :email-smtp-username "munchkin" - :email-smtp-password "gobble gobble" - :email-from-address "eating@hungry.com"}) - new-settings (email-settings) - _ (setting/set-many! orig-settings)] - new-settings)) + :email-from-address "eating@hungry.com"}) + +;; PUT /api/email - check updating email settings +(expect + default-email-settings + (let [orig-settings (email-settings)] + (try + ((user->client :crowberto) :put 200 "email" default-email-settings) + (email-settings) + (finally + (setting/set-many! orig-settings))))) + +;; DELETE /api/email - check clearing email settings +(expect + [default-email-settings + {:email-smtp-host nil, + :email-smtp-port nil, + :email-smtp-security "none", + :email-smtp-username nil, + :email-smtp-password nil, + :email-from-address "notifications@metabase.com"}] + (let [orig-settings (email-settings)] + (try + ((user->client :crowberto) :put 200 "email" default-email-settings) + (let [new-email-settings (email-settings)] + ((user->client :crowberto) :delete 204 "email") + [new-email-settings + (email-settings)]) + (finally + (setting/set-many! orig-settings))))) diff --git a/test/metabase/api/embed_test.clj b/test/metabase/api/embed_test.clj index 74cfc365e5a9362608538b0fc1441e29e163fb7e..57dc48532ad914a333b3566dbd4e45584158dcbc 100644 --- a/test/metabase/api/embed_test.clj +++ b/test/metabase/api/embed_test.clj @@ -19,6 +19,7 @@ [metabase.test [data :as data] [util :as tu]] + [toucan.db :as db] [toucan.util.test :as tt]) (:import java.io.ByteArrayInputStream)) @@ -67,7 +68,7 @@ :source "aggregation", :extra_info {}, :id nil, :target nil, :display_name "count", :base_type "type/Integer", :remapped_from nil, :remapped_to nil}] :rows [[100]]} - :json_query {:parameters []} + :json_query {:parameters nil} :status "completed"}) ([results-format] (case results-format @@ -93,10 +94,11 @@ :visualization_settings {} :dataset_query {:type "query"} :parameters () - :param_values nil}) + :param_values nil + :param_fields nil}) (def successful-dashboard-info - {:description nil, :parameters (), :ordered_cards (), :param_values nil}) + {:description nil, :parameters (), :ordered_cards (), :param_values nil, :param_fields nil}) ;;; ------------------------------------------- GET /api/embed/card/:token ------------------------------------------- @@ -487,3 +489,275 @@ :card_id (u/get-id series-card) :position 0}] (:status (http/client :get 200 (str (dashcard-url (assoc dashcard :card_id (u/get-id series-card))))))))))) + + +;;; ------------------------------- GET /api/embed/card/:token/field/:field-id/values -------------------------------- + +(defn- field-values-url [card-or-dashboard field-or-id] + (str + "embed/" + (condp instance? card-or-dashboard + (class Card) (str "card/" (card-token card-or-dashboard)) + (class Dashboard) (str "dashboard/" (dash-token card-or-dashboard))) + "/field/" + (u/get-id field-or-id) + "/values")) + +(defn- do-with-embedding-enabled-and-temp-card-referencing {:style/indent 2} [table-kw field-kw f] + (with-embedding-enabled-and-new-secret-key + (tt/with-temp Card [card (assoc (public-test/mbql-card-referencing table-kw field-kw) + :enable_embedding true)] + (f card)))) + +(defmacro ^:private with-embedding-enabled-and-temp-card-referencing + {:style/indent 3} + [table-kw field-kw [card-binding] & body] + `(do-with-embedding-enabled-and-temp-card-referencing ~table-kw ~field-kw + (fn [~(or card-binding '_)] + ~@body))) + +;; should be able to fetch values for a Field referenced by a public Card +(expect + {:values [["20th Century Cafe"] + ["25°"] + ["33 Taps"] + ["800 Degrees Neapolitan Pizzeria"] + ["BCD Tofu House"]] + :field_id (data/id :venues :name)} + (with-embedding-enabled-and-temp-card-referencing :venues :name [card] + (-> (http/client :get 200 (field-values-url card (data/id :venues :name))) + (update :values (partial take 5))))) + +;; but for Fields that are not referenced we should get an Exception +(expect + "Not found." + (with-embedding-enabled-and-temp-card-referencing :venues :name [card] + (http/client :get 400 (field-values-url card (data/id :venues :price))))) + +;; Endpoint should fail if embedding is disabled +(expect + "Embedding is not enabled." + (with-embedding-enabled-and-temp-card-referencing :venues :name [card] + (tu/with-temporary-setting-values [enable-embedding false] + (http/client :get 400 (field-values-url card (data/id :venues :name)))))) + +(expect + "Embedding is not enabled for this object." + (with-embedding-enabled-and-temp-card-referencing :venues :name [card] + (db/update! Card (u/get-id card) :enable_embedding false) + (http/client :get 400 (field-values-url card (data/id :venues :name))))) + + +;;; ----------------------------- GET /api/embed/dashboard/:token/field/:field-id/values ----------------------------- + +(defn- do-with-embedding-enabled-and-temp-dashcard-referencing {:style/indent 2} [table-kw field-kw f] + (with-embedding-enabled-and-new-secret-key + (tt/with-temp* [Dashboard [dashboard {:enable_embedding true}] + Card [card (public-test/mbql-card-referencing table-kw field-kw)] + DashboardCard [dashcard {:dashboard_id (u/get-id dashboard) + :card_id (u/get-id card) + :parameter_mappings [{:card_id (u/get-id card) + :target [:dimension + [:field-id + (data/id table-kw field-kw)]]}]}]] + (f dashboard card dashcard)))) + + +(defmacro ^:private with-embedding-enabled-and-temp-dashcard-referencing + {:style/indent 3} + [table-kw field-kw [dash-binding card-binding dashcard-binding] & body] + `(do-with-embedding-enabled-and-temp-dashcard-referencing ~table-kw ~field-kw + (fn [~(or dash-binding '_) ~(or card-binding '_) ~(or dashcard-binding '_)] + ~@body))) + +;; should be able to use it when everything is g2g +(expect + {:values [["20th Century Cafe"] + ["25°"] + ["33 Taps"] + ["800 Degrees Neapolitan Pizzeria"] + ["BCD Tofu House"]] + :field_id (data/id :venues :name)} + (with-embedding-enabled-and-temp-dashcard-referencing :venues :name [dashboard] + (-> (http/client :get 200 (field-values-url dashboard (data/id :venues :name))) + (update :values (partial take 5))))) + +;; shound NOT be able to use the endpoint with a Field not referenced by the Dashboard +(expect + "Not found." + (with-embedding-enabled-and-temp-dashcard-referencing :venues :name [dashboard] + (http/client :get 400 (field-values-url dashboard (data/id :venues :price))))) + +;; Endpoint should fail if embedding is disabled +(expect + "Embedding is not enabled." + (with-embedding-enabled-and-temp-dashcard-referencing :venues :name [dashboard] + (tu/with-temporary-setting-values [enable-embedding false] + (http/client :get 400 (field-values-url dashboard (data/id :venues :name)))))) + +;; Endpoint should fail if embedding is disabled for the Dashboard +(expect + "Embedding is not enabled for this object." + (with-embedding-enabled-and-temp-dashcard-referencing :venues :name [dashboard] + (db/update! Dashboard (u/get-id dashboard) :enable_embedding false) + (http/client :get 400 (field-values-url dashboard (data/id :venues :name))))) + + +;;; ----------------------- GET /api/embed/card/:token/field/:field-id/search/:search-field-id ----------------------- + +(defn- field-search-url [card-or-dashboard field-or-id search-field-or-id] + (str "embed/" + (condp instance? card-or-dashboard + (class Card) (str "card/" (card-token card-or-dashboard)) + (class Dashboard) (str "dashboard/" (dash-token card-or-dashboard))) + "/field/" (u/get-id field-or-id) + "/search/" (u/get-id search-field-or-id))) + +(expect + [[93 "33 Taps"]] + (with-embedding-enabled-and-temp-card-referencing :venues :id [card] + (http/client :get 200 (field-search-url card (data/id :venues :id) (data/id :venues :name)) + :value "33 T"))) + +;; if search field isn't allowed to be used with the other Field endpoint should return exception +(expect + "Invalid Request." + (with-embedding-enabled-and-temp-card-referencing :venues :id [card] + (http/client :get 400 (field-search-url card (data/id :venues :id) (data/id :venues :price)) + :value "33 T"))) + +;; Endpoint should fail if embedding is disabled +(expect + "Embedding is not enabled." + (with-embedding-enabled-and-temp-card-referencing :venues :id [card] + (tu/with-temporary-setting-values [enable-embedding false] + (http/client :get 400 (field-search-url card (data/id :venues :id) (data/id :venues :name)) + :value "33 T")))) + +;; Endpoint should fail if embedding is disabled for the Card +(expect + "Embedding is not enabled for this object." + (with-embedding-enabled-and-temp-card-referencing :venues :id [card] + (db/update! Card (u/get-id card) :enable_embedding false) + (http/client :get 400 (field-search-url card (data/id :venues :id) (data/id :venues :name)) + :value "33 T"))) + + +;;; -------------------- GET /api/embed/dashboard/:token/field/:field-id/search/:search-field-id --------------------- + +(expect + [[93 "33 Taps"]] + (with-embedding-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (http/client :get (field-search-url dashboard (data/id :venues :id) (data/id :venues :name)) + :value "33 T"))) + +;; if search field isn't allowed to be used with the other Field endpoint should return exception +(expect + "Invalid Request." + (with-embedding-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (http/client :get 400 (field-search-url dashboard (data/id :venues :id) (data/id :venues :price)) + :value "33 T"))) + +;; Endpoint should fail if embedding is disabled +(expect + "Embedding is not enabled." + (with-embedding-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (tu/with-temporary-setting-values [enable-embedding false] + (http/client :get 400 (field-search-url dashboard (data/id :venues :name) (data/id :venues :name)) + :value "33 T")))) + +;; Endpoint should fail if embedding is disabled for the Dashboard +(expect + "Embedding is not enabled for this object." + (with-embedding-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (db/update! Dashboard (u/get-id dashboard) :enable_embedding false) + (http/client :get 400 (field-search-url dashboard (data/id :venues :name) (data/id :venues :name)) + :value "33 T"))) + + +;;; ----------------------- GET /api/embed/card/:token/field/:field-id/remapping/:remapped-id ------------------------ + +(defn- field-remapping-url [card-or-dashboard field-or-id remapped-field-or-id] + (str "embed/" + (condp instance? card-or-dashboard + (class Card) (str "card/" (card-token card-or-dashboard)) + (class Dashboard) (str "dashboard/" (dash-token card-or-dashboard))) + "/field/" (u/get-id field-or-id) + "/remapping/" (u/get-id remapped-field-or-id))) + +;; we should be able to use the API endpoint and get the same results we get by calling the function above directly +(expect + [10 "Fred 62"] + (with-embedding-enabled-and-temp-card-referencing :venues :id [card] + (http/client :get 200 (field-remapping-url card (data/id :venues :id) (data/id :venues :name)) + :value "10"))) + +;; shouldn't work if Card doesn't reference the Field in question +(expect + "Not found." + (with-embedding-enabled-and-temp-card-referencing :venues :price [card] + (http/client :get 400 (field-remapping-url card (data/id :venues :id) (data/id :venues :name)) + :value "10"))) + +;; ...or if the remapping Field isn't allowed to be used with the other Field +(expect + "Invalid Request." + (with-embedding-enabled-and-temp-card-referencing :venues :id [card] + (http/client :get 400 (field-remapping-url card (data/id :venues :id) (data/id :venues :price)) + :value "10"))) + +;; ...or if embedding is disabled +(expect + "Embedding is not enabled." + (with-embedding-enabled-and-temp-card-referencing :venues :id [card] + (tu/with-temporary-setting-values [enable-embedding false] + (http/client :get 400 (field-remapping-url card (data/id :venues :id) (data/id :venues :name)) + :value "10")))) + +;; ...or if embedding is disabled for the Card +(expect + "Embedding is not enabled for this object." + (with-embedding-enabled-and-temp-card-referencing :venues :id [card] + (db/update! Card (u/get-id card) :enable_embedding false) + (http/client :get 400 (field-remapping-url card (data/id :venues :id) (data/id :venues :name)) + :value "10"))) + + +;;; --------------------- GET /api/embed/dashboard/:token/field/:field-id/remapping/:remapped-id --------------------- + +;; we should be able to use the API endpoint and get the same results we get by calling the function above directly +(expect + [10 "Fred 62"] + (with-embedding-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (http/client :get 200 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :name)) + :value "10"))) + +;; shouldn't work if Card doesn't reference the Field in question +(expect + "Not found." + (with-embedding-enabled-and-temp-dashcard-referencing :venues :price [dashboard] + (http/client :get 400 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :name)) + :value "10"))) + +;; ...or if the remapping Field isn't allowed to be used with the other Field +(expect + "Invalid Request." + (with-embedding-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (http/client :get 400 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :price)) + :value "10"))) + +;; ...or if embedding is disabled +(expect + "Embedding is not enabled." + (with-embedding-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (tu/with-temporary-setting-values [enable-embedding false] + (http/client :get 400 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :name)) + :value "10")))) + +;; ...or if embedding is disabled for the Dashboard +(expect + "Embedding is not enabled for this object." + (with-embedding-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (db/update! Dashboard (u/get-id dashboard) :enable_embedding false) + (http/client :get 400 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :name)) + :value "10"))) diff --git a/test/metabase/api/field_test.clj b/test/metabase/api/field_test.clj index 2658d5c229b49c2382e2d6cfe635af90ac133db3..8c7101f01068cc0ce286743cf10cb0668fa169d4 100644 --- a/test/metabase/api/field_test.clj +++ b/test/metabase/api/field_test.clj @@ -1,14 +1,19 @@ (ns metabase.api.field-test + "Tests for `/api/field` endpoints." (:require [expectations :refer :all] - [metabase.driver :as driver] + [metabase + [driver :as driver] + [query-processor-test :as qpt]] + [metabase.api.field :as field-api] [metabase.models [field :refer [Field]] [field-values :refer [FieldValues]] [table :refer [Table]]] [metabase.test - [data :refer :all] + [data :as data] [util :as tu]] - [metabase.test.data.users :refer :all] + [metabase.test.data.users :refer [user->client]] + [metabase.timeseries-query-processor-test.util :as tqpt] [ring.util.codec :as codec] [toucan [db :as db] @@ -17,11 +22,8 @@ ;; Helper Fns -(def ^:private default-field-values - {:id true, :created_at true, :updated_at true, :field_id true}) - (defn- db-details [] - (tu/match-$ (db) + (tu/match-$ (data/db) {:created_at $ :engine "h2" :caveats nil @@ -41,26 +43,26 @@ ;; ## GET /api/field/:id (expect - (tu/match-$ (Field (id :users :name)) + (tu/match-$ (Field (data/id :users :name)) {:description nil - :table_id (id :users) + :table_id (data/id :users) :raw_column_id $ :fingerprint $ :fingerprint_version $ - :table (tu/match-$ (Table (id :users)) + :table (tu/match-$ (Table (data/id :users)) {:description nil - :entity_type nil + :entity_type "entity/UserTable" :visibility_type nil :db (db-details) :schema "PUBLIC" :name "USERS" :display_name "Users" - :rows 15 + :rows nil :updated_at $ :entity_name nil :active true - :id (id :users) - :db_id (id) + :id (data/id :users) + :db_id (data/id) :caveats nil :points_of_interest nil :show_in_getting_started false @@ -74,7 +76,7 @@ :updated_at $ :last_analyzed $ :active true - :id (id :users :name) + :id (data/id :users :name) :visibility_type "normal" :position 0 :preview_display true @@ -83,14 +85,16 @@ :base_type "type/Text" :has_field_values "list" :fk_target_field_id nil - :parent_id nil}) - ((user->client :rasta) :get 200 (format "field/%d" (id :users :name)))) + :parent_id nil + :dimensions [] + :name_field nil}) + ((user->client :rasta) :get 200 (format "field/%d" (data/id :users :name)))) ;; ## GET /api/field/:id/summary (expect [["count" 75] ; why doesn't this come back as a dictionary ? ["distincts" 75]] - ((user->client :rasta) :get 200 (format "field/%d/summary" (id :categories :name)))) + ((user->client :rasta) :get 200 (format "field/%d/summary" (data/id :categories :name)))) ;; ## PUT /api/field/:id @@ -158,47 +162,42 @@ (defn- field->field-values "Fetch the `FieldValues` object that corresponds to a given `Field`." [table-kw field-kw] - (FieldValues :field_id (id table-kw field-kw))) + (FieldValues :field_id (data/id table-kw field-kw))) (defn- field-values-id [table-key field-key] (:id (field->field-values table-key field-key))) ;; ## GET /api/field/:id/values -;; Should return something useful for a field that has special_type :type/Category +;; Should return something useful for a field whose `has_field_values` is `list` (expect - (merge default-field-values {:values (mapv vector [1 2 3 4])}) + {:values [[1] [2] [3] [4]], :field_id (data/id :venues :price)} (do ;; clear out existing human_readable_values in case they're set (db/update! FieldValues (field-values-id :venues :price) :human_readable_values nil) ;; now update the values via the API - (tu/boolean-ids-and-timestamps ((user->client :rasta) :get 200 (format "field/%d/values" (id :venues :price)))))) + ((user->client :rasta) :get 200 (format "field/%d/values" (data/id :venues :price))))) -;; Should return nothing for a field whose special_type is *not* :type/Category +;; Should return nothing for a field whose `has_field_values` is not `list` (expect - {:values []} - ((user->client :rasta) :get 200 (format "field/%d/values" (id :venues :id)))) + {:values [], :field_id (data/id :venues :id)} + ((user->client :rasta) :get 200 (format "field/%d/values" (data/id :venues :id)))) ;; Sensisitive fields do not have field values and should return empty (expect - {:values []} - ((user->client :rasta) :get 200 (format "field/%d/values" (id :users :password)))) - -(defn- num->$ [num-seq] - (mapv (fn [idx] - (vector idx (apply str (repeat idx \$)))) - num-seq)) + {:values [], :field_id (data/id :users :password)} + ((user->client :rasta) :get 200 (format "field/%d/values" (data/id :users :password)))) -(def category-field {:name "Field Test" :base_type :type/Integer :special_type :type/Category}) +(def ^:private list-field {:name "Field Test", :base_type :type/Integer, :has_field_values "list"}) ;; ## POST /api/field/:id/values ;; Human readable values are optional (expect - [(merge default-field-values {:values (map vector (range 5 10))}) + [{:values [[5] [6] [7] [8] [9]], :field_id true} {:status "success"} - (merge default-field-values {:values (map vector (range 1 5))})] - (tt/with-temp* [Field [{field-id :id} category-field] + {:values [[1] [2] [3] [4]], :field_id true}] + (tt/with-temp* [Field [{field-id :id} list-field] FieldValues [{field-value-id :id} {:values (range 5 10), :field_id field-id}]] (mapv tu/boolean-ids-and-timestamps [((user->client :crowberto) :get 200 (format "field/%d/values" field-id)) @@ -208,60 +207,60 @@ ;; Existing field values can be updated (with their human readable values) (expect - [(merge default-field-values {:values (map vector (range 1 5))}) + [{:values [[1] [2] [3] [4]], :field_id true} {:status "success"} - (merge default-field-values {:values (num->$ (range 1 5))})] - (tt/with-temp* [Field [{field-id :id} category-field] + {:values [[1 "$"] [2 "$$"] [3 "$$$"] [4 "$$$$"]], :field_id true}] + (tt/with-temp* [Field [{field-id :id} list-field] FieldValues [{field-value-id :id} {:values (range 1 5), :field_id field-id}]] (mapv tu/boolean-ids-and-timestamps [((user->client :crowberto) :get 200 (format "field/%d/values" field-id)) ((user->client :crowberto) :post 200 (format "field/%d/values" field-id) - {:values (num->$ (range 1 5))}) + {:values [[1 "$"] [2 "$$"] [3 "$$$"] [4 "$$$$"]]}) ((user->client :crowberto) :get 200 (format "field/%d/values" field-id))]))) ;; Field values are created when not present (expect - [(merge default-field-values {:values []}) + [{:values [], :field_id true} {:status "success"} - (merge default-field-values {:values (num->$ (range 1 5))})] - (tt/with-temp* [Field [{field-id :id} category-field]] + {:values [[1 "$"] [2 "$$"] [3 "$$$"] [4 "$$$$"]], :field_id true}] + (tt/with-temp* [Field [{field-id :id} list-field]] (mapv tu/boolean-ids-and-timestamps [((user->client :crowberto) :get 200 (format "field/%d/values" field-id)) ((user->client :crowberto) :post 200 (format "field/%d/values" field-id) - {:values (num->$ (range 1 5))}) + {:values [[1 "$"] [2 "$$"] [3 "$$$"] [4 "$$$$"]]}) ((user->client :crowberto) :get 200 (format "field/%d/values" field-id))]))) ;; Can unset values (expect - [(merge default-field-values {:values (mapv vector (range 1 5))}) + [{:values [[1] [2] [3] [4]], :field_id true} {:status "success"} - (merge default-field-values {:values []})] - (tt/with-temp* [Field [{field-id :id} category-field] + {:values [], :field_id true}] + (tt/with-temp* [Field [{field-id :id} list-field] FieldValues [{field-value-id :id} {:values (range 1 5), :field_id field-id}]] (mapv tu/boolean-ids-and-timestamps [((user->client :crowberto) :get 200 (format "field/%d/values" field-id)) ((user->client :crowberto) :post 200 (format "field/%d/values" field-id) - {:values []}) + {:values [], :field_id true}) ((user->client :crowberto) :get 200 (format "field/%d/values" field-id))]))) ;; Can unset just human readable values (expect - [(merge default-field-values {:values (num->$ (range 1 5))}) + [{:values [[1 "$"] [2 "$$"] [3 "$$$"] [4 "$$$$"]], :field_id true} {:status "success"} - (merge default-field-values {:values (mapv vector (range 1 5))})] - (tt/with-temp* [Field [{field-id :id} category-field] + {:values [[1] [2] [3] [4]], :field_id true}] + (tt/with-temp* [Field [{field-id :id} list-field] FieldValues [{field-value-id :id} {:values (range 1 5), :field_id field-id :human_readable_values ["$" "$$" "$$$" "$$$$"]}]] (mapv tu/boolean-ids-and-timestamps [((user->client :crowberto) :get 200 (format "field/%d/values" field-id)) ((user->client :crowberto) :post 200 (format "field/%d/values" field-id) - {:values (mapv vector (range 1 5))}) + {:values [[1] [2] [3] [4]]}) ((user->client :crowberto) :get 200 (format "field/%d/values" field-id))]))) ;; Should throw when human readable values are present but not for every value (expect "If remapped values are specified, they must be specified for all field values" - (tt/with-temp* [Field [{field-id :id} {:name "Field Test" :base_type :type/Integer :special_type :type/Category}]] + (tt/with-temp* [Field [{field-id :id} {:name "Field Test", :base_type :type/Integer, :has_field_values "list"}]] ((user->client :crowberto) :post 400 (format "field/%d/values" field-id) {:values [[1 "$"] [2 "$$"] [3] [4]]}))) @@ -272,32 +271,32 @@ (hydrate :dimensions) :dimensions)) -(defn dimension-post [field-id map-to-post] +(defn- create-dimension-via-API! {:style/indent 1} [field-id map-to-post] ((user->client :crowberto) :post 200 (format "field/%d/dimension" field-id) map-to-post)) ;; test that we can do basic field update work, including unsetting some fields such as special-type (expect [[] - {:id true - :created_at true - :updated_at true - :type :internal - :name "some dimension name" + {:id true + :created_at true + :updated_at true + :type :internal + :name "some dimension name" :human_readable_field_id false - :field_id true} - {:id true - :created_at true - :updated_at true - :type :internal - :name "different dimension name" + :field_id true} + {:id true + :created_at true + :updated_at true + :type :internal + :name "different dimension name" :human_readable_field_id false - :field_id true} + :field_id true} true] (tt/with-temp* [Field [{field-id :id} {:name "Field Test"}]] (let [before-creation (dimension-for-field field-id) - _ (dimension-post field-id {:name "some dimension name", :type "internal"}) + _ (create-dimension-via-API! field-id {:name "some dimension name", :type "internal"}) new-dim (dimension-for-field field-id) - _ (dimension-post field-id {:name "different dimension name", :type "internal"}) + _ (create-dimension-via-API! field-id {:name "different dimension name", :type "internal"}) updated-dim (dimension-for-field field-id)] [before-creation (tu/boolean-ids-and-timestamps new-dim) @@ -312,17 +311,18 @@ ;; test that we can do basic field update work, including unsetting some fields such as special-type (expect [[] - {:id true - :created_at true - :updated_at true - :type :external - :name "some dimension name" + {:id true + :created_at true + :updated_at true + :type :external + :name "some dimension name" :human_readable_field_id true - :field_id true}] + :field_id true}] (tt/with-temp* [Field [{field-id-1 :id} {:name "Field Test 1"}] Field [{field-id-2 :id} {:name "Field Test 2"}]] (let [before-creation (dimension-for-field field-id-1) - _ (dimension-post field-id-1 {:name "some dimension name", :type "external" :human_readable_field_id field-id-2}) + _ (create-dimension-via-API! field-id-1 + {:name "some dimension name", :type "external" :human_readable_field_id field-id-2}) new-dim (dimension-for-field field-id-1)] [before-creation (tu/boolean-ids-and-timestamps new-dim)]))) @@ -331,27 +331,28 @@ (expect clojure.lang.ExceptionInfo (tt/with-temp* [Field [{field-id-1 :id} {:name "Field Test 1"}]] - (dimension-post field-id-1 {:name "some dimension name", :type "external"}))) + (create-dimension-via-API! field-id-1 {:name "some dimension name", :type "external"}))) -;; Non-admin users can't update dimensions +;; Non-admin users can't update dimension, :field_id trues (expect "You don't have permissions to do that." (tt/with-temp* [Field [{field-id :id} {:name "Field Test 1"}]] - ((user->client :rasta) :post 403 (format "field/%d/dimension" field-id) {:name "some dimension name", :type "external"}))) + ((user->client :rasta) :post 403 (format "field/%d/dimension" field-id) + {:name "some dimension name", :type "external"}))) ;; Ensure we can delete a dimension (expect - [{:id true - :created_at true - :updated_at true - :type :internal - :name "some dimension name" + [{:id true + :created_at true + :updated_at true + :type :internal + :name "some dimension name" :human_readable_field_id false - :field_id true} + :field_id true} []] (tt/with-temp* [Field [{field-id :id} {:name "Field Test"}]] - (dimension-post field-id {:name "some dimension name", :type "internal"}) + (create-dimension-via-API! field-id {:name "some dimension name", :type "internal"}) (let [new-dim (dimension-for-field field-id)] ((user->client :crowberto) :delete 204 (format "field/%d/dimension" field-id)) @@ -366,20 +367,21 @@ ;; When an FK field gets it's special_type removed, we should clear the external dimension (expect - [{:id true - :created_at true - :updated_at true - :type :external - :name "fk-remove-dimension" + [{:id true + :created_at true + :updated_at true + :type :external + :name "fk-remove-dimension" :human_readable_field_id true - :field_id true} + :field_id true} []] - (tt/with-temp* [Field [{field-id-1 :id} {:name "Field Test 1" + (tt/with-temp* [Field [{field-id-1 :id} {:name "Field Test 1" :special_type :type/FK}] Field [{field-id-2 :id} {:name "Field Test 2"}]] - - (dimension-post field-id-1 {:name "fk-remove-dimension", :type "external" :human_readable_field_id field-id-2}) - + ;; create the Dimension + (create-dimension-via-API! field-id-1 + {:name "fk-remove-dimension", :type "external" :human_readable_field_id field-id-2}) + ;; not remove the special type (!) TODO (let [new-dim (dimension-for-field field-id-1) _ ((user->client :crowberto) :put 200 (format "field/%d" field-id-1) {:special_type nil}) dim-after-update (dimension-for-field field-id-1)] @@ -388,38 +390,40 @@ ;; The dimension should stay as long as the FK didn't change (expect - (repeat 2 {:id true - :created_at true - :updated_at true - :type :external - :name "fk-remove-dimension" + (repeat 2 {:id true + :created_at true + :updated_at true + :type :external + :name "fk-remove-dimension" :human_readable_field_id true - :field_id true}) - (tt/with-temp* [Field [{field-id-1 :id} {:name "Field Test 1" + :field_id true}) + (tt/with-temp* [Field [{field-id-1 :id} {:name "Field Test 1" :special_type :type/FK}] Field [{field-id-2 :id} {:name "Field Test 2"}]] - - (dimension-post field-id-1 {:name "fk-remove-dimension", :type "external" :human_readable_field_id field-id-2}) - + ;; create the Dimension + (create-dimension-via-API! field-id-1 + {:name "fk-remove-dimension", :type "external" :human_readable_field_id field-id-2}) + ;; now change something unrelated: description (let [new-dim (dimension-for-field field-id-1) - _ ((user->client :crowberto) :put 200 (format "field/%d" field-id-1) {:description "something diffrent"}) + _ ((user->client :crowberto) :put 200 (format "field/%d" field-id-1) + {:description "something diffrent"}) dim-after-update (dimension-for-field field-id-1)] [(tu/boolean-ids-and-timestamps new-dim) (tu/boolean-ids-and-timestamps dim-after-update)]))) ;; When removing the FK special type, the fk_target_field_id should be cleared as well (expect - [{:name "Field Test 2", - :display_name "Field Test 2", - :description nil, - :visibility_type :normal, - :special_type :type/FK, + [{:name "Field Test 2" + :display_name "Field Test 2" + :description nil + :visibility_type :normal + :special_type :type/FK :fk_target_field_id true} - {:name "Field Test 2", - :display_name "Field Test 2", - :description nil, - :visibility_type :normal, - :special_type nil, + {:name "Field Test 2" + :display_name "Field Test 2" + :description nil + :visibility_type :normal + :special_type nil :fk_target_field_id false}] (tt/with-temp* [Field [{field-id-1 :id} {:name "Field Test 1"}] Field [{field-id-2 :id} {:name "Field Test 2" @@ -434,17 +438,17 @@ ;; Checking update of the fk_target_field_id (expect - [{:name "Field Test 3", - :display_name "Field Test 3", - :description nil, - :visibility_type :normal, - :special_type :type/FK, + [{:name "Field Test 3" + :display_name "Field Test 3" + :description nil + :visibility_type :normal + :special_type :type/FK :fk_target_field_id true} - {:name "Field Test 3", - :display_name "Field Test 3", - :description nil, - :visibility_type :normal, - :special_type :type/FK, + {:name "Field Test 3" + :display_name "Field Test 3" + :description nil + :visibility_type :normal + :special_type :type/FK :fk_target_field_id true} true] (tt/with-temp* [Field [{field-id-1 :id} {:name "Field Test 1"}] @@ -463,23 +467,23 @@ ;; Checking update of the fk_target_field_id along with an FK change (expect - [{:name "Field Test 2", - :display_name "Field Test 2", - :description nil, - :visibility_type :normal, + [{:name "Field Test 2" + :display_name "Field Test 2" + :description nil + :visibility_type :normal :special_type nil :fk_target_field_id false} - {:name "Field Test 2", - :display_name "Field Test 2", - :description nil, - :visibility_type :normal, - :special_type :type/FK, + {:name "Field Test 2" + :display_name "Field Test 2" + :description nil + :visibility_type :normal + :special_type :type/FK :fk_target_field_id true}] (tt/with-temp* [Field [{field-id-1 :id} {:name "Field Test 1"}] Field [{field-id-2 :id} {:name "Field Test 2"}]] (let [before-change (simple-field-details (Field field-id-2)) - _ ((user->client :crowberto) :put 200 (format "field/%d" field-id-2) {:special_type :type/FK + _ ((user->client :crowberto) :put 200 (format "field/%d" field-id-2) {:special_type :type/FK :fk_target_field_id field-id-1}) after-change (simple-field-details (Field field-id-2))] [(tu/boolean-ids-and-timestamps before-change) @@ -487,17 +491,17 @@ ;; Checking update of the fk_target_field_id and FK remain unchanged on updates of other fields (expect - [{:name "Field Test 2", - :display_name "Field Test 2", - :description nil, - :visibility_type :normal, + [{:name "Field Test 2" + :display_name "Field Test 2" + :description nil + :visibility_type :normal :special_type :type/FK :fk_target_field_id true} - {:name "Field Test 2", - :display_name "Field Test 2", - :description "foo", - :visibility_type :normal, - :special_type :type/FK, + {:name "Field Test 2" + :display_name "Field Test 2" + :description "foo" + :visibility_type :normal + :special_type :type/FK :fk_target_field_id true}] (tt/with-temp* [Field [{field-id-1 :id} {:name "Field Test 1"}] Field [{field-id-2 :id} {:name "Field Test 2" @@ -512,17 +516,17 @@ ;; Changing a remapped field's type to something that can't be remapped will clear the dimension (expect - [{:id true - :created_at true - :updated_at true - :type :internal - :name "some dimension name" + [{:id true + :created_at true + :updated_at true + :type :internal + :name "some dimension name" :human_readable_field_id false - :field_id true} + :field_id true} []] - (tt/with-temp* [Field [{field-id :id} {:name "Field Test" + (tt/with-temp* [Field [{field-id :id} {:name "Field Test" :base_type "type/Integer"}]] - (dimension-post field-id {:name "some dimension name", :type "internal"}) + (create-dimension-via-API! field-id {:name "some dimension name", :type "internal"}) (let [new-dim (dimension-for-field field-id)] ((user->client :crowberto) :put 200 (format "field/%d" field-id) {:special_type "type/Text"}) [(tu/boolean-ids-and-timestamps new-dim) @@ -530,17 +534,50 @@ ;; Change from supported type to supported type will leave the dimension (expect - (repeat 2 {:id true - :created_at true - :updated_at true - :type :internal - :name "some dimension name" + (repeat 2 {:id true + :created_at true + :updated_at true + :type :internal + :name "some dimension name" :human_readable_field_id false - :field_id true}) - (tt/with-temp* [Field [{field-id :id} {:name "Field Test" + :field_id true}) + (tt/with-temp* [Field [{field-id :id} {:name "Field Test" :base_type "type/Integer"}]] - (dimension-post field-id {:name "some dimension name", :type "internal"}) + (create-dimension-via-API! field-id {:name "some dimension name", :type "internal"}) (let [new-dim (dimension-for-field field-id)] - ((user->client :crowberto) :put 200 (format "field/%d" field-id) {:special_type "type/Category"}) + ((user->client :crowberto) :put 200 (format "field/%d" field-id) {:has_field_values "list"}) [(tu/boolean-ids-and-timestamps new-dim) (tu/boolean-ids-and-timestamps (dimension-for-field field-id))]))) + + +;; make sure `search-values` works on with our various drivers +(qpt/expect-with-non-timeseries-dbs + [[1 "Red Medicine"]] + (qpt/format-rows-by [int str] + (field-api/search-values (Field (data/id :venues :id)) + (Field (data/id :venues :name)) + "Red"))) + +(tqpt/expect-with-timeseries-dbs + [["139" "Red Medicine"] + ["375" "Red Medicine"] + ["72" "Red Medicine"]] + (field-api/search-values (Field (data/id :checkins :id)) + (Field (data/id :checkins :venue_name)) + "Red")) + +;; make sure it also works if you use the same Field twice +(qpt/expect-with-non-timeseries-dbs + [["Red Medicine" "Red Medicine"]] + (field-api/search-values (Field (data/id :venues :name)) + (Field (data/id :venues :name)) + "Red")) + +;; disabled for now because for some reason Druid itself is failing to run this query with an “Invalid type marker +;; byte 0x3c†error message. The query itself is fine so I suspect this might be an issue with Druid itself. Either +;; way, I can find very little information about it online. Try reenabling this test next time we upgrade Druid. +#_(tqpt/expect-with-timeseries-dbs + [["Red Medicine" "Red Medicine"]] + (field-api/search-values (Field (data/id :checkins :venue_name)) + (Field (data/id :checkins :venue_name)) + "Red")) diff --git a/test/metabase/api/metric_test.clj b/test/metabase/api/metric_test.clj index bf2fa44cd1ba2650e4218d655aa9ea5cc74db6a3..c2f20c81d1a0209f5458340fdaea4348bc1c6c0d 100644 --- a/test/metabase/api/metric_test.clj +++ b/test/metabase/api/metric_test.clj @@ -369,3 +369,9 @@ (assoc metric-2 :database_id (data/id))] :creator)) ((user->client :rasta) :get 200 "metric/")) + +;; Test related/recommended entities +(expect + #{:table :metrics :segments} + (tt/with-temp* [Metric [{metric-id :id}]] + (-> ((user->client :crowberto) :get 200 (format "metric/%s/related" metric-id)) keys set))) diff --git a/test/metabase/api/preview_embed_test.clj b/test/metabase/api/preview_embed_test.clj index 322278c8b19d45837cd336a6af6c26a0565643ee..7113c0816a9cfdc8d95cb2b7cb7dfd6aec38e04a 100644 --- a/test/metabase/api/preview_embed_test.clj +++ b/test/metabase/api/preview_embed_test.clj @@ -1,10 +1,15 @@ (ns metabase.api.preview-embed-test (:require [expectations :refer :all] [metabase.api.embed-test :as embed-test] - [metabase.models.dashboard :refer [Dashboard]] - [metabase.test.data :as data] - [metabase.test.data.users :as test-users] - [metabase.test.util :as tu] + [metabase.models + [card :refer [Card]] + [dashboard :refer [Dashboard]]] + [metabase.test + [data :as data] + [util :as tu]] + [metabase.test.data + [datasets :as datasets] + [users :as test-users]] [metabase.util :as u] [toucan.util.test :as tt])) @@ -338,21 +343,59 @@ (expect "completed" (embed-test/with-embedding-enabled-and-new-secret-key - (embed-test/with-temp-card [card {:dataset_query - {:database (data/id) - :type "native" - :native {:query (str "SELECT {{num_birds}} AS num_birds," - " {{2nd_date_seen}} AS 2nd_date_seen") - :template_tags {:equipment {:name "num_birds" - :display_name "Num Birds" - :type "number"} - :7_days_ending_on {:name "2nd_date_seen", - :display_name "Date Seen", - :type "date"}}}}}] - (-> (embed-test/with-temp-dashcard [dashcard {:dash {:enable_embedding true}}] - ((test-users/user->client :crowberto) :get 200 (str (dashcard-url dashcard - {:_embedding_params {:num_birds :locked - :2nd_date_seen :enabled} - :params {:num_birds 2}}) - "?2nd_date_seen=2018-02-14"))) - :status)))) + (-> (embed-test/with-temp-dashcard [dashcard {:dash {:enable_embedding true}}] + ((test-users/user->client :crowberto) :get 200 (str (dashcard-url dashcard + {:_embedding_params {:num_birds :locked + :2nd_date_seen :enabled} + :params {:num_birds 2}}) + "?2nd_date_seen=2018-02-14"))) + :status))) + +;; Make sure that editable params do not result in "Invalid Parameter" exceptions (#7212) +(expect + [[50]] + (embed-test/with-embedding-enabled-and-new-secret-key + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT {{num}} AS num" + :template_tags {:num {:name "num" + :display_name "Num" + :type "number" + :required true + :default "1"}}}}}] + (embed-test/with-temp-dashcard [dashcard {:dash {:parameters [{:name "Num" + :slug "num" + :id "537e37b4" + :type "category"}]} + :dashcard {:card_id (u/get-id card) + :parameter_mappings [{:card_id (u/get-id card) + :target [:variable + [:template-tag :num]] + :parameter_id "537e37b4"}]}}] + (-> ((test-users/user->client :crowberto) :get (str (dashcard-url dashcard {:_embedding_params {:num "enabled"}}) + "?num=50")) + :data + :rows))))) + +;; Make sure that ID params correctly get converted to numbers as needed (Postgres-specific)... +(datasets/expect-with-engine :postgres + [[1]] + (embed-test/with-embedding-enabled-and-new-secret-key + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues) + :aggregation [:count]}}}] + (embed-test/with-temp-dashcard [dashcard {:dash {:parameters [{:name "Venue ID" + :slug "venue_id" + :id "22486e00" + :type "id"}]} + :dashcard {:card_id (u/get-id card) + :parameter_mappings [{:parameter_id "22486e00" + :card_id (u/get-id card) + :target [:dimension + [:field-id + (data/id :venues :id)]]}]}}] + (-> ((test-users/user->client :crowberto) :get (str (dashcard-url dashcard {:_embedding_params {:venue_id "enabled"}}) + "?venue_id=1")) + :data + :rows))))) diff --git a/test/metabase/api/public_test.clj b/test/metabase/api/public_test.clj index 088808c9ef04d16a3bc822739e49e90d918209c0..e1dd4619b888739ff4804061f950e3d3b8a7f2e8 100644 --- a/test/metabase/api/public_test.clj +++ b/test/metabase/api/public_test.clj @@ -14,6 +14,8 @@ [dashboard :refer [Dashboard]] [dashboard-card :refer [DashboardCard]] [dashboard-card-series :refer [DashboardCardSeries]] + [dimension :refer [Dimension]] + [field :refer [Field]] [field-values :refer [FieldValues]]] [metabase.test [data :as data] @@ -45,13 +47,21 @@ ~@body)))) (defmacro ^:private with-temp-public-dashboard {:style/indent 1} [[binding & [dashboard]] & body] - `(let [dashboard-settings# (merge (shared-obj) ~dashboard)] + `(let [dashboard-settings# (merge + {:parameters [{:name "Venue ID" + :slug "venue_id" + :type "id" + :target [:dimension (data/id :venues :id)] + :default nil}]} + (shared-obj) + ~dashboard)] (tt/with-temp Dashboard [dashboard# dashboard-settings#] (let [~binding (assoc dashboard# :public_uuid (:public_uuid dashboard-settings#))] ~@body)))) -(defn- add-card-to-dashboard! [card dashboard] - (db/insert! DashboardCard :dashboard_id (u/get-id dashboard), :card_id (u/get-id card))) +(defn- add-card-to-dashboard! {:style/indent 2} [card dashboard & {:as kvs}] + (db/insert! DashboardCard (merge {:dashboard_id (u/get-id dashboard), :card_id (u/get-id card)} + kvs))) (defmacro ^:private with-temp-public-dashboard-and-card {:style/indent 1} @@ -90,7 +100,7 @@ ;; Check that we can fetch a PublicCard (expect - #{:dataset_query :description :display :id :name :visualization_settings :param_values} + #{:dataset_query :description :display :id :name :visualization_settings :param_values :param_fields} (tu/with-temporary-setting-values [enable-public-sharing true] (with-temp-public-card [{uuid :public_uuid}] (set (keys (http/client :get 200 (str "public/card/" uuid))))))) @@ -174,10 +184,11 @@ ;; Check that we can exec a PublicCard with `?parameters` (expect - [{:type "category", :value 2}] + [{:name "Venue ID", :slug "venue_id", :type "id", :value 2}] (tu/with-temporary-setting-values [enable-public-sharing true] (with-temp-public-card [{uuid :public_uuid}] - (get-in (http/client :get 200 (str "public/card/" uuid "/query"), :parameters (json/encode [{:type "category", :value 2}])) + (get-in (http/client :get 200 (str "public/card/" uuid "/query") + :parameters (json/encode [{:name "Venue ID", :slug "venue_id", :type "id", :value 2}])) [:json_query :parameters])))) ;; make sure CSV (etc.) downloads take editable params into account (#6407) @@ -211,8 +222,8 @@ (binding [http/*url-prefix* (str "http://localhost:" (config/config-str :mb-jetty-port) "/")] (http/client :get 200 (str "public/question/" uuid ".csv") :parameters (json/encode [{:type :date/quarter-year - :target [:dimension [:template-tag :date]] - :value "Q1-2014"}])))))) + :target [:dimension [:template-tag :date]] + :value "Q1-2014"}])))))) ;;; ---------------------------------------- GET /api/public/dashboard/:uuid ----------------------------------------- @@ -254,7 +265,7 @@ ;;; --------------------------------- GET /api/public/dashboard/:uuid/card/:card-id ---------------------------------- -(defn- dashcard-url-path [dash card] +(defn- dashcard-url [dash card] (str "public/dashboard/" (:public_uuid dash) "/card/" (u/get-id card))) @@ -263,14 +274,14 @@ "An error occurred." (tu/with-temporary-setting-values [enable-public-sharing false] (with-temp-public-dashboard-and-card [dash card] - (http/client :get 400 (dashcard-url-path dash card))))) + (http/client :get 400 (dashcard-url dash card))))) ;; Check that we get a 400 if PublicDashboard doesn't exist (expect "An error occurred." (tu/with-temporary-setting-values [enable-public-sharing true] (with-temp-public-dashboard-and-card [_ card] - (http/client :get 400 (dashcard-url-path {:public_uuid (UUID/randomUUID)} card))))) + (http/client :get 400 (dashcard-url {:public_uuid (UUID/randomUUID)} card))))) ;; Check that we get a 400 if PublicCard doesn't exist @@ -278,7 +289,7 @@ "An error occurred." (tu/with-temporary-setting-values [enable-public-sharing true] (with-temp-public-dashboard-and-card [dash _] - (http/client :get 400 (dashcard-url-path dash Integer/MAX_VALUE))))) + (http/client :get 400 (dashcard-url dash Integer/MAX_VALUE))))) ;; Check that we get a 400 if the Card does exist but it's not part of this Dashboard (expect @@ -286,7 +297,7 @@ (tu/with-temporary-setting-values [enable-public-sharing true] (with-temp-public-dashboard-and-card [dash _] (tt/with-temp Card [card] - (http/client :get 400 (dashcard-url-path dash card)))))) + (http/client :get 400 (dashcard-url dash card)))))) ;; Check that we *cannot* execute a PublicCard via a PublicDashboard if the Card has been archived (expect @@ -294,23 +305,55 @@ (tu/with-temporary-setting-values [enable-public-sharing true] (with-temp-public-dashboard-and-card [dash card] (db/update! Card (u/get-id card), :archived true) - (http/client :get 400 (dashcard-url-path dash card))))) + (http/client :get 400 (dashcard-url dash card))))) ;; Check that we can exec a PublicCard via a PublicDashboard (expect [[100]] (tu/with-temporary-setting-values [enable-public-sharing true] (with-temp-public-dashboard-and-card [dash card] - (qp-test/rows (http/client :get 200 (dashcard-url-path dash card)))))) + (qp-test/rows (http/client :get 200 (dashcard-url dash card)))))) ;; Check that we can exec a PublicCard via a PublicDashboard with `?parameters` (expect - [{:type "category", :value 2}] + [{:name "Venue ID" + :slug "venue_id" + :target ["dimension" (data/id :venues :id)] + :value [10] + :default nil + :type "id"}] (tu/with-temporary-setting-values [enable-public-sharing true] (with-temp-public-dashboard-and-card [dash card] - (get-in (http/client :get 200 (dashcard-url-path dash card), :parameters (json/encode [{:type "category", :value 2}])) + (get-in (http/client :get 200 (dashcard-url dash card) + :parameters (json/encode [{:name "Venue ID" + :slug :venue_id + :target [:dimension (data/id :venues :id)] + :value [10]}])) [:json_query :parameters])))) +;; Make sure params are validated: this should pass because venue_id *is* one of the Dashboard's :parameters +(expect + [[1]] + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-dashboard-and-card [dash card] + (-> (http/client :get 200 (dashcard-url dash card) + :parameters (json/encode [{:name "Venue ID" + :slug :venue_id + :target [:dimension (data/id :venues :id)] + :value [10]}])) + qp-test/rows)))) + +;; Make sure params are validated: this should fail because venue_name is *not* one of the Dashboard's :parameters +(expect + "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-dashboard-and-card [dash card] + (http/client :get 400 (dashcard-url dash card) + :parameters (json/encode [{:name "Venue Name" + :slug :venue_name + :target [:dimension (data/id :venues :name)] + :value ["PizzaHacker"]}]))))) + ;; Check that an additional Card series works as well (expect [[100]] @@ -321,7 +364,128 @@ :card_id (u/get-id card) :dashboard_id (u/get-id dash)) :card_id (u/get-id card-2)}] - (qp-test/rows (http/client :get 200 (dashcard-url-path dash card-2)))))))) + (qp-test/rows (http/client :get 200 (dashcard-url dash card-2)))))))) + +;; Make sure that parameters actually work correctly (#7212) +(expect + [[50]] + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT {{num}} AS num" + :template_tags {:num {:name "num" + :display_name "Num" + :type "number" + :required true + :default "1"}}}}}] + (with-temp-public-dashboard [dash {:parameters [{:name "Num" + :slug "num" + :id "537e37b4" + :type "category"}]}] + (add-card-to-dashboard! card dash + :parameter_mappings [{:card_id (u/get-id card) + :target [:variable + [:template-tag :num]] + :parameter_id "537e37b4"}]) + (-> ((test-users/user->client :crowberto) + :get (str (dashcard-url dash card) + "?parameters=" + (json/generate-string + [{:type :category + :target [:variable [:template-tag :num]] + :value "50"}]))) + :data + :rows))))) + +;; ...with MBQL Cards as well... +(expect + [[1]] + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues) + :aggregation [:count]}}}] + (with-temp-public-dashboard [dash {:parameters [{:name "Venue ID" + :slug "venue_id" + :id "22486e00" + :type "id"}]}] + (add-card-to-dashboard! card dash + :parameter_mappings [{:parameter_id "22486e00" + :card_id (u/get-id card) + :target [:dimension + [:field-id + (data/id :venues :id)]]}]) + (-> ((test-users/user->client :crowberto) + :get (str (dashcard-url dash card) + "?parameters=" + (json/generate-string + [{:type :id + :target [:dimension [:field-id (data/id :venues :id)]] + :value "50"}]))) + :data + :rows))))) + +;; ...and also for DateTime params +(expect + [[733]] + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :checkins) + :aggregation [:count]}}}] + (with-temp-public-dashboard [dash {:parameters [{:name "Date Filter" + :slug "date_filter" + :id "18a036ec" + :type "date/all-options"}]}] + (add-card-to-dashboard! card dash + :parameter_mappings [{:parameter_id "18a036ec" + :card_id (u/get-id card) + :target [:dimension + [:field-id + (data/id :checkins :date)]]}]) + (-> ((test-users/user->client :crowberto) + :get (str (dashcard-url dash card) + "?parameters=" + (json/generate-string + [{:type "date/all-options" + :target [:dimension [:field-id (data/id :checkins :date)]] + :value "~2015-01-01"}]))) + :data + :rows))))) + +;; make sure DimensionValue params also work if they have a default value, even if some is passed in for some reason +;; as part of the query (#7253) +;; If passed in as part of the query however make sure it doesn't override what's actually in the DB +(expect + [["Wow"]] + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT {{msg}} AS message" + :template_tags {:msg {:id "181da7c5" + :name "msg" + :display_name "Message" + :type "text" + :required true + :default "Wow"}}}}}] + (with-temp-public-dashboard [dash {:parameters [{:name "Message" + :slug "msg" + :id "181da7c5" + :type "category"}]}] + (add-card-to-dashboard! card dash + :parameter_mappings [{:card_id (u/get-id card) + :target [:variable [:template-tag :msg]] + :parameter_id "181da7c5"}]) + (-> ((test-users/user->client :crowberto) + :get (str (dashcard-url dash card) + "?parameters=" + (json/generate-string + [{:type :category + :target [:variable [:template-tag :msg]] + :value nil + :default "Hello"}]))) + :data + :rows))))) ;;; --------------------------- Check that parameter information comes back with Dashboard --------------------------- @@ -377,3 +541,432 @@ (add-price-param-to-dashboard! dash) (add-dimension-param-mapping-to-dashcard! dashcard card ["fk->" (data/id :checkins :venue_id) (data/id :venues :price)]) (GET-param-values dash))) + + +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | New FieldValues search endpoints | +;;; +----------------------------------------------------------------------------------------------------------------+ + +(defn- mbql-card-referencing-nothing [] + {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues)}}}) + +(defn mbql-card-referencing [table-kw field-kw] + {:dataset_query + {:database (data/id) + :type :query + :query {:source-table (data/id table-kw) + :filter [:= [:field-id (data/id table-kw field-kw)] "Krua Siri"]}}}) + +(defn- mbql-card-referencing-venue-name [] + (mbql-card-referencing :venues :name)) + +(defn- sql-card-referencing-venue-name [] + {:dataset_query + {:database (data/id) + :type :native + :native {:query "SELECT COUNT(*) FROM VENUES WHERE {{x}}" + :template_tags {:x {:name :x + :display_name "X" + :type :dimension + :dimension [:field-id (data/id :venues :name)]}}}}}) + + +;;; ------------------------------------------- card->referenced-field-ids ------------------------------------------- + +(expect + #{} + (tt/with-temp Card [card (mbql-card-referencing-nothing)] + (#'public-api/card->referenced-field-ids card))) + +;; It should pick up on Fields referenced in the MBQL query itself... +(expect + #{(data/id :venues :name)} + (tt/with-temp Card [card (mbql-card-referencing-venue-name)] + (#'public-api/card->referenced-field-ids card))) + +;; ...as well as template tag "implict" params for SQL queries +(expect + #{(data/id :venues :name)} + (tt/with-temp Card [card (sql-card-referencing-venue-name)] + (#'public-api/card->referenced-field-ids card))) + + +;;; --------------------------------------- check-field-is-referenced-by-card ---------------------------------------- + +;; Check that the check succeeds when Field is referenced +(expect + (tt/with-temp Card [card (mbql-card-referencing-venue-name)] + (#'public-api/check-field-is-referenced-by-card (data/id :venues :name) (u/get-id card)))) + +;; check that exception is thrown if the Field isn't referenced +(expect + Exception + (tt/with-temp Card [card (mbql-card-referencing-venue-name)] + (#'public-api/check-field-is-referenced-by-card (data/id :venues :category_id) (u/get-id card)))) + + +;;; ----------------------------------------- check-search-field-is-allowed ------------------------------------------ + +;; search field is allowed IF: +;; A) search-field is the same field as the other one +(expect + (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :id))) + +(expect + Exception + (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :category_id))) + +;; B) there's a Dimension that lists search field as the human_readable_field for the other field +(expect + (tt/with-temp Dimension [_ {:field_id (data/id :venues :id), :human_readable_field_id (data/id :venues :category_id)}] + (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :category_id)))) + +;; C) search-field is a Name Field belonging to the same table as the other field, which is a PK +(expect + (do ;tu/with-temp-vals-in-db Field (data/id :venues :name) {:special_type "type/Name"} + (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :name)))) + +;; not allowed if search field isn't a NAME +(expect + Exception + (tu/with-temp-vals-in-db Field (data/id :venues :name) {:special_type "type/Latitude"} + (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :name)))) + +;; not allowed if search field belongs to a different TABLE +(expect + Exception + (tu/with-temp-vals-in-db Field (data/id :categories :name) {:special_type "type/Name"} + (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :categories :name)))) + + +;;; ------------------------------------- check-field-is-referenced-by-dashboard ------------------------------------- + +(defn- dashcard-with-param-mapping-to-venue-id [dashboard card] + {:dashboard_id (u/get-id dashboard) + :card_id (u/get-id card) + :parameter_mappings [{:card_id (u/get-id card) + :target [:dimension [:field-id (data/id :venues :id)]]}]}) + +;; Field is "referenced" by Dashboard if it's one of the Dashboard's params... +(expect + (tt/with-temp* [Dashboard [dashboard] + Card [card] + DashboardCard [_ (dashcard-with-param-mapping-to-venue-id dashboard card)]] + (#'public-api/check-field-is-referenced-by-dashboard (data/id :venues :id) (u/get-id dashboard)))) + +(expect + Exception + (tt/with-temp* [Dashboard [dashboard] + Card [card] + DashboardCard [_ (dashcard-with-param-mapping-to-venue-id dashboard card)]] + (#'public-api/check-field-is-referenced-by-dashboard (data/id :venues :name) (u/get-id dashboard)))) + +;; ...*or* if it's a so-called "implicit" param (a Field Filter Template Tag (FFTT) in a SQL Card) +(expect + (tt/with-temp* [Dashboard [dashboard] + Card [card (sql-card-referencing-venue-name)] + DashboardCard [_ {:dashboard_id (u/get-id dashboard), :card_id (u/get-id card)}]] + (#'public-api/check-field-is-referenced-by-dashboard (data/id :venues :name) (u/get-id dashboard)))) + +(expect + Exception + (tt/with-temp* [Dashboard [dashboard] + Card [card (sql-card-referencing-venue-name)] + DashboardCard [_ {:dashboard_id (u/get-id dashboard), :card_id (u/get-id card)}]] + (#'public-api/check-field-is-referenced-by-dashboard (data/id :venues :id) (u/get-id dashboard)))) + + +;;; ------------------------------------------- card-and-field-id->values -------------------------------------------- + +;; We should be able to get values for a Field referenced by a Card +(expect + {:values [["20th Century Cafe"] + ["25°"] + ["33 Taps"] + ["800 Degrees Neapolitan Pizzeria"] + ["BCD Tofu House"]] + :field_id (data/id :venues :name)} + (tt/with-temp Card [card (mbql-card-referencing :venues :name)] + (-> (public-api/card-and-field-id->values (u/get-id card) (data/id :venues :name)) + (update :values (partial take 5))))) + +;; SQL param field references should work just as well as MBQL field referenced +(expect + {:values [["20th Century Cafe"] + ["25°"] + ["33 Taps"] + ["800 Degrees Neapolitan Pizzeria"] + ["BCD Tofu House"]] + :field_id (data/id :venues :name)} + (tt/with-temp Card [card (sql-card-referencing-venue-name)] + (-> (public-api/card-and-field-id->values (u/get-id card) (data/id :venues :name)) + (update :values (partial take 5))))) + +;; But if the Field is not referenced we should get an Exception +(expect + Exception + (tt/with-temp Card [card (mbql-card-referencing :venues :price)] + (public-api/card-and-field-id->values (u/get-id card) (data/id :venues :name)))) + + +;;; ------------------------------- GET /api/public/card/:uuid/field/:field-id/values -------------------------------- + +(defn- field-values-url [card-or-dashboard field-or-id] + (str "public/" + (condp instance? card-or-dashboard + (class Card) "card" + (class Dashboard) "dashboard") + "/" (or (:public_uuid card-or-dashboard) + (throw (Exception. (str "Missing public UUID: " card-or-dashboard)))) + "/field/" (u/get-id field-or-id) + "/values")) + +(defn- do-with-sharing-enabled-and-temp-card-referencing {:style/indent 2} [table-kw field-kw f] + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card (merge (shared-obj) (mbql-card-referencing table-kw field-kw))] + (f card)))) + +(defmacro ^:private with-sharing-enabled-and-temp-card-referencing + {:style/indent 3} + [table-kw field-kw [card-binding] & body] + `(do-with-sharing-enabled-and-temp-card-referencing ~table-kw ~field-kw + (fn [~card-binding] + ~@body))) + +;; should be able to fetch values for a Field referenced by a public Card +(expect + {:values [["20th Century Cafe"] + ["25°"] + ["33 Taps"] + ["800 Degrees Neapolitan Pizzeria"] + ["BCD Tofu House"]] + :field_id (data/id :venues :name)} + (with-sharing-enabled-and-temp-card-referencing :venues :name [card] + (-> (http/client :get 200 (field-values-url card (data/id :venues :name))) + (update :values (partial take 5))))) + +;; but for Fields that are not referenced we should get an Exception +(expect + "An error occurred." + (with-sharing-enabled-and-temp-card-referencing :venues :name [card] + (http/client :get 400 (field-values-url card (data/id :venues :price))))) + +;; Endpoint should fail if public sharing is disabled +(expect + "An error occurred." + (with-sharing-enabled-and-temp-card-referencing :venues :name [card] + (tu/with-temporary-setting-values [enable-public-sharing false] + (http/client :get 400 (field-values-url card (data/id :venues :name)))))) + + +;;; ----------------------------- GET /api/public/dashboard/:uuid/field/:field-id/values ----------------------------- + +(defn do-with-sharing-enabled-and-temp-dashcard-referencing {:style/indent 2} [table-kw field-kw f] + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp* [Dashboard [dashboard (shared-obj)] + Card [card (mbql-card-referencing table-kw field-kw)] + DashboardCard [dashcard {:dashboard_id (u/get-id dashboard) + :card_id (u/get-id card) + :parameter_mappings [{:card_id (u/get-id card) + :target [:dimension + [:field-id + (data/id table-kw field-kw)]]}]}]] + (f dashboard card dashcard)))) + +(defmacro with-sharing-enabled-and-temp-dashcard-referencing + {:style/indent 3} + [table-kw field-kw [dashboard-binding card-binding dashcard-binding] & body] + `(do-with-sharing-enabled-and-temp-dashcard-referencing ~table-kw ~field-kw + (fn [~(or dashboard-binding '_) ~(or card-binding '_) ~(or dashcard-binding '_)] + ~@body))) + +;; should be able to use it when everything is g2g +(expect + {:values [["20th Century Cafe"] + ["25°"] + ["33 Taps"] + ["800 Degrees Neapolitan Pizzeria"] + ["BCD Tofu House"]] + :field_id (data/id :venues :name)} + (with-sharing-enabled-and-temp-dashcard-referencing :venues :name [dashboard] + (-> (http/client :get 200 (field-values-url dashboard (data/id :venues :name))) + (update :values (partial take 5))))) + +;; shound NOT be able to use the endpoint with a Field not referenced by the Dashboard +(expect + "An error occurred." + (with-sharing-enabled-and-temp-dashcard-referencing :venues :name [dashboard] + (http/client :get 400 (field-values-url dashboard (data/id :venues :price))))) + +;; Endpoint should fail if public sharing is disabled +(expect + "An error occurred." + (with-sharing-enabled-and-temp-dashcard-referencing :venues :name [dashboard] + (tu/with-temporary-setting-values [enable-public-sharing false] + (http/client :get 400 (field-values-url dashboard (data/id :venues :name)))))) + + +;;; ----------------------------------------------- search-card-fields ----------------------------------------------- + +(expect + [[93 "33 Taps"]] + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (do ;tu/with-temp-vals-in-db Field (data/id :venues :name) {:special_type "type/Name"} + (public-api/search-card-fields (u/get-id card) (data/id :venues :id) (data/id :venues :name) "33 T" 10)))) + +;; shouldn't work if the search-field isn't allowed to be used in combination with the other Field +(expect + Exception + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (public-api/search-card-fields (u/get-id card) (data/id :venues :id) (data/id :venues :price) "33 T" 10))) + +;; shouldn't work if the field isn't referenced by CARD +(expect + Exception + (with-sharing-enabled-and-temp-card-referencing :venues :name [card] + (public-api/search-card-fields (u/get-id card) (data/id :venues :id) (data/id :venues :id) "33 T" 10))) + + +;;; ----------------------- GET /api/public/card/:uuid/field/:field-id/search/:search-field-id ----------------------- + +(defn- field-search-url [card-or-dashboard field-or-id search-field-or-id] + (str "public/" + (condp instance? card-or-dashboard + (class Card) "card" + (class Dashboard) "dashboard") + "/" (:public_uuid card-or-dashboard) + "/field/" (u/get-id field-or-id) + "/search/" (u/get-id search-field-or-id))) + +(expect + [[93 "33 Taps"]] + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (http/client :get 200 (field-search-url card (data/id :venues :id) (data/id :venues :name)) + :value "33 T"))) + +;; if search field isn't allowed to be used with the other Field endpoint should return exception +(expect + "An error occurred." + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (http/client :get 400 (field-search-url card (data/id :venues :id) (data/id :venues :price)) + :value "33 T"))) + +;; Endpoint should fail if public sharing is disabled +(expect + "An error occurred." + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (tu/with-temporary-setting-values [enable-public-sharing false] + (http/client :get 400 (field-search-url card (data/id :venues :id) (data/id :venues :name)) + :value "33 T")))) + + +;;; -------------------- GET /api/public/dashboard/:uuid/field/:field-id/search/:search-field-id --------------------- + +(expect + [[93 "33 Taps"]] + (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (http/client :get (field-search-url dashboard (data/id :venues :id) (data/id :venues :name)) + :value "33 T"))) + +;; if search field isn't allowed to be used with the other Field endpoint should return exception +(expect + "An error occurred." + (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (http/client :get 400 (field-search-url dashboard (data/id :venues :id) (data/id :venues :price)) + :value "33 T"))) + +;; Endpoint should fail if public sharing is disabled +(expect + "An error occurred." + (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (tu/with-temporary-setting-values [enable-public-sharing false] + (http/client :get 400 (field-search-url dashboard (data/id :venues :name) (data/id :venues :name)) + :value "33 T")))) + +;;; --------------------------------------------- field-remapped-values ---------------------------------------------- + +;; `field-remapped-values` should return remappings in the expected format when the combination of Fields is allowed. +;; It should parse the value string (it comes back from the API as a string since it is a query param) +(expect + [10 "Fred 62"] + (#'public-api/field-remapped-values (data/id :venues :id) (data/id :venues :name) "10")) + +;; if the Field isn't allowed +(expect + Exception + (#'public-api/field-remapped-values (data/id :venues :id) (data/id :venues :price) "10")) + + +;;; ----------------------- GET /api/public/card/:uuid/field/:field-id/remapping/:remapped-id ------------------------ + +(defn- field-remapping-url [card-or-dashboard field-or-id remapped-field-or-id] + (str "public/" + (condp instance? card-or-dashboard + (class Card) "card" + (class Dashboard) "dashboard") + "/" (:public_uuid card-or-dashboard) + "/field/" (u/get-id field-or-id) + "/remapping/" (u/get-id remapped-field-or-id))) + +;; we should be able to use the API endpoint and get the same results we get by calling the function above directly +(expect + [10 "Fred 62"] + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (http/client :get 200 (field-remapping-url card (data/id :venues :id) (data/id :venues :name)) + :value "10"))) + +;; shouldn't work if Card doesn't reference the Field in question +(expect + "An error occurred." + (with-sharing-enabled-and-temp-card-referencing :venues :price [card] + (http/client :get 400 (field-remapping-url card (data/id :venues :id) (data/id :venues :name)) + :value "10"))) + +;; ...or if the remapping Field isn't allowed to be used with the other Field +(expect + "An error occurred." + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (http/client :get 400 (field-remapping-url card (data/id :venues :id) (data/id :venues :price)) + :value "10"))) + +;; ...or if public sharing is disabled +(expect + "An error occurred." + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (tu/with-temporary-setting-values [enable-public-sharing false] + (http/client :get 400 (field-remapping-url card (data/id :venues :id) (data/id :venues :name)) + :value "10")))) + + +;;; --------------------- GET /api/public/dashboard/:uuid/field/:field-id/remapping/:remapped-id --------------------- + +;; we should be able to use the API endpoint and get the same results we get by calling the function above directly +(expect + [10 "Fred 62"] + (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (http/client :get 200 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :name)) + :value "10"))) + +;; shouldn't work if Card doesn't reference the Field in question +(expect + "An error occurred." + (with-sharing-enabled-and-temp-dashcard-referencing :venues :price [dashboard] + (http/client :get 400 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :name)) + :value "10"))) + +;; ...or if the remapping Field isn't allowed to be used with the other Field +(expect + "An error occurred." + (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (http/client :get 400 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :price)) + :value "10"))) + +;; ...or if public sharing is disabled +(expect + "An error occurred." + (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (tu/with-temporary-setting-values [enable-public-sharing false] + (http/client :get 400 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :name)) + :value "10")))) diff --git a/test/metabase/api/pulse_test.clj b/test/metabase/api/pulse_test.clj index cf6682a884376088052f52217fc634e61bfaa684..2b9e7f876845171c9b534d103771e90991ad5292 100644 --- a/test/metabase/api/pulse_test.clj +++ b/test/metabase/api/pulse_test.clj @@ -230,7 +230,7 @@ Table [{table-id :id} {:db_id database-id}] Card [card {:dataset_query {:database database-id :type "query" - :query {:source-table table-id, + :query {:source-table table-id :aggregation {:aggregation-type "count"}}}}] Pulse [pulse {:name "Daily Sad Toucans"}] PulseCard [pulse-card {:pulse_id (u/get-id pulse), :card_id (u/get-id card)}]] diff --git a/test/metabase/api/segment_test.clj b/test/metabase/api/segment_test.clj index 02aa9c063fc6f31467325f23f088167d71ac4532..05de078f0d1456451fd4aebeb57162ce927ef4ab 100644 --- a/test/metabase/api/segment_test.clj +++ b/test/metabase/api/segment_test.clj @@ -380,3 +380,9 @@ :revision_message "WOW HOW COOL" :definition {}}) true)) + +;; Test related/recommended entities +(expect + #{:table :metrics :segments :linked-from} + (tt/with-temp* [Segment [{segment-id :id}]] + (-> ((user->client :crowberto) :get 200 (format "segment/%s/related" segment-id)) keys set))) diff --git a/test/metabase/api/session_test.clj b/test/metabase/api/session_test.clj index 3412c041c24f61d11048b0072ab23c5b90f4bbc9..4a8c49e7fb8fd7f52e675037959a852f8bb8a5de 100644 --- a/test/metabase/api/session_test.clj +++ b/test/metabase/api/session_test.clj @@ -2,6 +2,7 @@ "Tests for /api/session" (:require [expectations :refer :all] [metabase + [email-test :as et] [http-client :refer :all] [public-settings :as public-settings] [util :as u]] @@ -68,16 +69,17 @@ ;; ## POST /api/session/forgot_password ;; Test that we can initiate password reset (expect - (let [reset-fields-set? (fn [] - (let [{:keys [reset_token reset_triggered]} (db/select-one [User :reset_token :reset_triggered], :id (user->id :rasta))] - (boolean (and reset_token reset_triggered))))] - ;; make sure user is starting with no values - (db/update! User (user->id :rasta), :reset_token nil, :reset_triggered nil) - (assert (not (reset-fields-set?))) - ;; issue reset request (token & timestamp should be saved) - ((user->client :rasta) :post 200 "session/forgot_password" {:email (:username (user->credentials :rasta))}) - ;; TODO - how can we test email sent here? - (reset-fields-set?))) + (et/with-fake-inbox + (let [reset-fields-set? (fn [] + (let [{:keys [reset_token reset_triggered]} (db/select-one [User :reset_token :reset_triggered], :id (user->id :rasta))] + (boolean (and reset_token reset_triggered))))] + ;; make sure user is starting with no values + (db/update! User (user->id :rasta), :reset_token nil, :reset_triggered nil) + (assert (not (reset-fields-set?))) + ;; issue reset request (token & timestamp should be saved) + ((user->client :rasta) :post 200 "session/forgot_password" {:email (:username (user->credentials :rasta))}) + ;; TODO - how can we test email sent here? + (reset-fields-set?)))) ;; Test that email is required (expect {:errors {:email "value must be a valid email address."}} @@ -94,39 +96,41 @@ (expect {:reset_token nil :reset_triggered nil} - (let [password {:old "password" - :new "whateverUP12!!"}] - (tt/with-temp User [{:keys [email id]} {:password (:old password), :reset_triggered (System/currentTimeMillis)}] - (let [token (u/prog1 (str id "_" (java.util.UUID/randomUUID)) - (db/update! User id, :reset_token <>)) - creds {:old {:password (:old password) - :username email} - :new {:password (:new password) - :username email}}] - ;; Check that creds work - (client :post 200 "session" (:old creds)) - - ;; Call reset password endpoint to change the PW - (client :post 200 "session/reset_password" {:token token - :password (:new password)}) - ;; Old creds should no longer work - (assert (= (client :post 400 "session" (:old creds)) - {:errors {:password "did not match stored password"}})) - ;; New creds *should* work - (client :post 200 "session" (:new creds)) - ;; Double check that reset token was cleared - (db/select-one [User :reset_token :reset_triggered], :id id))))) + (et/with-fake-inbox + (let [password {:old "password" + :new "whateverUP12!!"}] + (tt/with-temp User [{:keys [email id]} {:password (:old password), :reset_triggered (System/currentTimeMillis)}] + (let [token (u/prog1 (str id "_" (java.util.UUID/randomUUID)) + (db/update! User id, :reset_token <>)) + creds {:old {:password (:old password) + :username email} + :new {:password (:new password) + :username email}}] + ;; Check that creds work + (client :post 200 "session" (:old creds)) + + ;; Call reset password endpoint to change the PW + (client :post 200 "session/reset_password" {:token token + :password (:new password)}) + ;; Old creds should no longer work + (assert (= (client :post 400 "session" (:old creds)) + {:errors {:password "did not match stored password"}})) + ;; New creds *should* work + (client :post 200 "session" (:new creds)) + ;; Double check that reset token was cleared + (db/select-one [User :reset_token :reset_triggered], :id id)))))) ;; Check that password reset returns a valid session token (expect {:success true :session_id true} - (tt/with-temp User [{:keys [id]} {:reset_triggered (System/currentTimeMillis)}] - (let [token (u/prog1 (str id "_" (java.util.UUID/randomUUID)) - (db/update! User id, :reset_token <>))] - (-> (client :post 200 "session/reset_password" {:token token - :password "whateverUP12!!"}) - (update :session_id tu/is-uuid-string?))))) + (et/with-fake-inbox + (tt/with-temp User [{:keys [id]} {:reset_triggered (System/currentTimeMillis)}] + (let [token (u/prog1 (str id "_" (java.util.UUID/randomUUID)) + (db/update! User id, :reset_token <>))] + (-> (client :post 200 "session/reset_password" {:token token + :password "whateverUP12!!"}) + (update :session_id tu/is-uuid-string?)))))) ;; Test that token and password are required (expect {:errors {:token "value must be a non-blank string."}} @@ -217,11 +221,12 @@ ;; should totally work if the email domains match up (expect {:first_name "Rasta", :last_name "Toucan", :email "rasta@sf-toucannery.com"} - (tu/with-temporary-setting-values [google-auth-auto-create-accounts-domain "sf-toucannery.com" - admin-email "rasta@toucans.com"] - (select-keys (u/prog1 (#'session-api/google-auth-create-new-user! "Rasta" "Toucan" "rasta@sf-toucannery.com") - (db/delete! User :id (:id <>))) ; make sure we clean up after ourselves ! - [:first_name :last_name :email]))) + (et/with-fake-inbox + (tu/with-temporary-setting-values [google-auth-auto-create-accounts-domain "sf-toucannery.com" + admin-email "rasta@toucans.com"] + (select-keys (u/prog1 (#'session-api/google-auth-create-new-user! "Rasta" "Toucan" "rasta@sf-toucannery.com") + (db/delete! User :id (:id <>))) ; make sure we clean up after ourselves ! + [:first_name :last_name :email])))) ;;; tests for google-auth-fetch-or-create-user! @@ -248,10 +253,11 @@ ;; test that a user that doesn't exist with the *same* domain as the auto-create accounts domain means a new user gets ;; created (expect - (tu/with-temporary-setting-values [google-auth-auto-create-accounts-domain "sf-toucannery.com" - admin-email "rasta@toucans.com"] - (u/prog1 (is-session? (#'session-api/google-auth-fetch-or-create-user! "Rasta" "Toucan" "rasta@sf-toucannery.com")) - (db/delete! User :email "rasta@sf-toucannery.com")))) ; clean up after ourselves + (et/with-fake-inbox + (tu/with-temporary-setting-values [google-auth-auto-create-accounts-domain "sf-toucannery.com" + admin-email "rasta@toucans.com"] + (u/prog1 (is-session? (#'session-api/google-auth-fetch-or-create-user! "Rasta" "Toucan" "rasta@sf-toucannery.com")) + (db/delete! User :email "rasta@sf-toucannery.com"))))) ; clean up after ourselves ;;; ------------------------------------------- TESTS FOR LDAP AUTH STUFF -------------------------------------------- diff --git a/test/metabase/api/table_test.clj b/test/metabase/api/table_test.clj index 0443fca5722baf1e0b53e14d48e38e509a2a86a8..e10f897cc89320ce5a7c96de89cbe062e86f359d 100644 --- a/test/metabase/api/table_test.clj +++ b/test/metabase/api/table_test.clj @@ -9,7 +9,6 @@ [middleware :as middleware] [query-processor-test :as qpt] [sync :as sync] - [timeseries-query-processor-test :as timeseries-qp-test] [util :as u]] [metabase.api.table :as table-api] [metabase.models @@ -19,7 +18,6 @@ [permissions :as perms] [permissions-group :as perms-group] [table :as table :refer [Table]]] - [metabase.query-processor-test :as qpt] [metabase.test [data :as data] [util :as tu :refer [match-$]]] @@ -27,6 +25,7 @@ [dataset-definitions :as defs] [datasets :as datasets] [users :refer [user->client]]] + [metabase.timeseries-query-processor-test.util :as tqpt] [toucan [db :as db] [hydrate :as hydrate]] @@ -66,7 +65,7 @@ :caveats nil :points_of_interest nil :show_in_getting_started false - :entity_type nil + :entity_type "entity/GenericTable" :visibility_type nil :db (db-details) :entity_name nil @@ -114,25 +113,29 @@ (expect #{{:name (data/format-name "categories") :display_name "Categories" - :rows 75 - :id (data/id :categories)} + :rows 0 + :id (data/id :categories) + :entity_type "entity/GenericTable"} {:name (data/format-name "checkins") :display_name "Checkins" - :rows 1000 - :id (data/id :checkins)} + :rows 0 + :id (data/id :checkins) + :entity_type "entity/EventTable"} {:name (data/format-name "users") :display_name "Users" - :rows 15 - :id (data/id :users)} + :rows 0 + :id (data/id :users) + :entity_type "entity/UserTable"} {:name (data/format-name "venues") :display_name "Venues" - :rows 100 - :id (data/id :venues)}} + :rows 0 + :id (data/id :venues) + :entity_type "entity/GenericTable"}} (->> ((user->client :rasta) :get 200 "table") (filter #(= (:db_id %) (data/id))) ; prevent stray tables from affecting unit test results (map #(dissoc % - :raw_table_id :db :created_at :updated_at :schema :entity_name :description :entity_type - :visibility_type :caveats :points_of_interest :show_in_getting_started :db_id :active)) + :raw_table_id :db :created_at :updated_at :schema :entity_name :description :visibility_type + :caveats :points_of_interest :show_in_getting_started :db_id :active)) set)) @@ -143,7 +146,7 @@ {:schema "PUBLIC" :name "VENUES" :display_name "Venues" - :rows 100 + :rows nil :updated_at $ :pk_field (#'table/pk-field-id $$) :id (data/id :venues) @@ -183,7 +186,7 @@ :display_name "ID" :database_type "BIGINT" :base_type "type/BigInteger" - :has_field_values "search") + :has_field_values "none") (assoc (field-details (Field (data/id :categories :name))) :table_id (data/id :categories) :special_type "type/Name" @@ -194,7 +197,7 @@ :dimension_options [] :default_dimension_option nil :has_field_values "list")] - :rows 75 + :rows nil :updated_at $ :id (data/id :categories) :raw_table_id $ @@ -228,6 +231,7 @@ {:schema "PUBLIC" :name "USERS" :display_name "Users" + :entity_type "entity/UserTable" :fields [(assoc (field-details (Field (data/id :users :id))) :special_type "type/PK" :table_id (data/id :users) @@ -236,7 +240,7 @@ :database_type "BIGINT" :base_type "type/BigInteger" :visibility_type "normal" - :has_field_values "search") + :has_field_values "none") (assoc (field-details (Field (data/id :users :last_login))) :table_id (data/id :users) :name "LAST_LOGIN" @@ -246,7 +250,7 @@ :visibility_type "normal" :dimension_options (var-get #'table-api/datetime-dimension-indexes) :default_dimension_option (var-get #'table-api/date-default-index) - :has_field_values "search") + :has_field_values "none") (assoc (field-details (Field (data/id :users :name))) :special_type "type/Name" :table_id (data/id :users) @@ -267,7 +271,7 @@ :base_type "type/Text" :visibility_type "sensitive" :has_field_values "list")] - :rows 15 + :rows nil :updated_at $ :id (data/id :users) :raw_table_id $ @@ -282,6 +286,7 @@ {:schema "PUBLIC" :name "USERS" :display_name "Users" + :entity_type "entity/UserTable" :fields [(assoc (field-details (Field (data/id :users :id))) :table_id (data/id :users) :special_type "type/PK" @@ -289,7 +294,7 @@ :display_name "ID" :database_type "BIGINT" :base_type "type/BigInteger" - :has_field_values "search") + :has_field_values "none") (assoc (field-details (Field (data/id :users :last_login))) :table_id (data/id :users) :name "LAST_LOGIN" @@ -298,7 +303,7 @@ :base_type "type/DateTime" :dimension_options (var-get #'table-api/datetime-dimension-indexes) :default_dimension_option (var-get #'table-api/date-default-index) - :has_field_values "search") + :has_field_values "none") (assoc (field-details (Field (data/id :users :name))) :table_id (data/id :users) :special_type "type/Name" @@ -307,7 +312,7 @@ :database_type "VARCHAR" :base_type "type/Text" :has_field_values "list")] - :rows 15 + :rows nil :updated_at $ :id (data/id :users) :raw_table_id $ @@ -335,30 +340,31 @@ ;; ## PUT /api/table/:id -(tt/expect-with-temp [Table [table {:rows 15}]] +(tt/expect-with-temp [Table [table]] (merge (-> (table-defaults) (dissoc :segments :field_values :metrics) (assoc-in [:db :details] {:db "mem:test-data;USER=GUEST;PASSWORD=guest"})) (match-$ table {:description "What a nice table!" - :entity_type "person" + :entity_type nil :visibility_type "hidden" :schema $ :name $ - :rows 15 + :rows nil :display_name "Userz" :pk_field (#'table/pk-field-id $$) :id $ :raw_table_id $ :created_at $})) (do ((user->client :crowberto) :put 200 (format "table/%d" (:id table)) {:display_name "Userz" - :entity_type "person" :visibility_type "hidden" :description "What a nice table!"}) (dissoc ((user->client :crowberto) :get 200 (format "table/%d" (:id table))) :updated_at))) -(tt/expect-with-temp [Table [table {:rows 15}]] +;; see how many times sync-table! gets called when we call the PUT endpoint. It should happen when you switch from +;; hidden -> not hidden at the spots marked below, twice total +(tt/expect-with-temp [Table [table]] 2 (let [original-sync-table! sync/sync-table! called (atom 0) @@ -366,15 +372,14 @@ (with-redefs [sync/sync-table! (fn [& args] (swap! called inc) (apply original-sync-table! args))] ((user->client :crowberto) :put 200 (format "table/%d" (:id table)) {:display_name "Userz" - :entity_type "person" :visibility_type state :description "What a nice table!"})))] (do (test-fun "hidden") - (test-fun nil) + (test-fun nil) ; <- should get synced (test-fun "hidden") (test-fun "cruft") (test-fun "technical") - (test-fun nil) + (test-fun nil) ; <- should get synced again (test-fun "technical") @called))) @@ -400,7 +405,8 @@ {:schema "PUBLIC" :name "CHECKINS" :display_name "Checkins" - :rows 1000 + :entity_type "entity/EventTable" + :rows nil :updated_at $ :id $ :raw_table_id $ @@ -410,15 +416,16 @@ (assoc :table_id (data/id :users) :name "ID" :display_name "ID" - :database_type "BIGINT" :base_type "type/BigInteger" + :database_type "BIGINT" :special_type "type/PK" :table (merge (dissoc (table-defaults) :db :segments :field_values :metrics) (match-$ (Table (data/id :users)) {:schema "PUBLIC" :name "USERS" :display_name "Users" - :rows 15 + :entity_type "entity/UserTable" + :rows nil :updated_at $ :id $ :raw_table_id $ @@ -636,7 +643,7 @@ ;; Datetime binning options should showup whether the backend supports binning of numeric values or not (datasets/expect-with-engines #{:druid} (var-get #'table-api/datetime-dimension-indexes) - (timeseries-qp-test/with-flattened-dbdef + (tqpt/with-flattened-dbdef (let [response ((user->client :rasta) :get 200 (format "table/%d/query_metadata" (data/id :checkins)))] (dimension-options-for-field response "timestamp")))) @@ -645,8 +652,13 @@ (let [response ((user->client :rasta) :get 200 (format "table/%d/query_metadata" (data/id :checkins)))] (dimension-options-for-field response "date"))) -(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift} +(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift :sparksql} [] (data/with-db (data/get-or-create-database! defs/test-data-with-time) (let [response ((user->client :rasta) :get 200 (format "table/%d/query_metadata" (data/id :users)))] (dimension-options-for-field response "last_login_time")))) + +;; Test related/recommended entities +(expect + #{:metrics :segments :linked-from :linking-to :tables} + (-> ((user->client :crowberto) :get 200 (format "table/%s/related" (data/id :venues))) keys set)) diff --git a/test/metabase/api/user_test.clj b/test/metabase/api/user_test.clj index 8e82e371e6890feb60e58ffeb2798ab0fe10a31c..04f58fa3e8c73443d339ee49a2da72fe0b961eb9 100644 --- a/test/metabase/api/user_test.clj +++ b/test/metabase/api/user_test.clj @@ -2,6 +2,7 @@ "Tests for /api/user endpoints." (:require [expectations :refer :all] [metabase + [email-test :as et] [http-client :as http] [middleware :as middleware] [util :as u]] @@ -73,13 +74,14 @@ :common_name (str user-name " " user-name) :is_superuser false :is_qbnewb true} - (do ((user->client :crowberto) :post 200 "user" {:first_name user-name - :last_name user-name - :email email}) - (u/prog1 (db/select-one [User :email :first_name :last_name :is_superuser :is_qbnewb] - :email email) - ;; clean up after ourselves - (db/delete! User :email email))))) + (et/with-fake-inbox + ((user->client :crowberto) :post 200 "user" {:first_name user-name + :last_name user-name + :email email}) + (u/prog1 (db/select-one [User :email :first_name :last_name :is_superuser :is_qbnewb] + :email email) + ;; clean up after ourselves + (db/delete! User :email email))))) ;; Test that reactivating a disabled account works diff --git a/test/metabase/automagic_dashboards/core_test.clj b/test/metabase/automagic_dashboards/core_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..6b57bfd03aa0ac9bb14ee7eca4629e530fccbb08 --- /dev/null +++ b/test/metabase/automagic_dashboards/core_test.clj @@ -0,0 +1,305 @@ +(ns metabase.automagic-dashboards.core-test + (:require [expectations :refer :all] + [metabase.api.common :as api] + [metabase.automagic-dashboards + [core :refer :all :as magic] + [rules :as rules]] + [metabase.models + [card :refer [Card]] + [database :refer [Database]] + [field :as field :refer [Field]] + [metric :refer [Metric]] + [query :as query] + [table :refer [Table] :as table] + [user :as user]] + [metabase.test.data :as data] + [metabase.test.data.users :as test-users] + [metabase.test.util :as tu] + [toucan.db :as db] + [toucan.util.test :as tt])) + +(defmacro with-rasta + "Execute body with rasta as the current user." + [& body] + `(binding [api/*current-user-id* (test-users/user->id :rasta) + api/*current-user-permissions-set* (-> :rasta + test-users/user->id + user/permissions-set + atom)] + ~@body)) + + +(expect + [:field-id 1] + (->> (assoc (field/->FieldInstance) :id 1) + (#'magic/->reference :mbql))) + +(expect + [:fk-> 1 2] + (->> (assoc (field/->FieldInstance) :id 1 :fk_target_field_id 2) + (#'magic/->reference :mbql))) + +(expect + 42 + (->> 42 + (#'magic/->reference :mbql))) + + +(expect + [:entity/UserTable :entity/GenericTable :entity/*] + (->> (data/id :users) + Table + (#'magic/->root) + (#'magic/matching-rules (rules/get-rules ["table"])) + (map (comp first :applies_to)))) + +;; Test fallback to GenericTable +(expect + [:entity/GenericTable :entity/*] + (->> (-> (data/id :users) + Table + (assoc :entity_type nil) + (#'magic/->root)) + (#'magic/matching-rules (rules/get-rules ["table"])) + (map (comp first :applies_to)))) + + +(defn- collect-urls + [dashboard] + (->> dashboard + (tree-seq (some-fn sequential? map?) identity) + (keep (fn [form] + (when (map? form) + (:url form)))))) + +(defn- valid-urls? + [dashboard] + (->> dashboard + collect-urls + (every? (fn [url] + ((test-users/user->client :rasta) :get 200 (format "automagic-dashboards/%s" + (subs url 16))))))) + +(defn- valid-dashboard? + [dashboard] + (and (:name dashboard) + (-> dashboard :ordered_cards count pos?) + (valid-urls? dashboard))) + +(defmacro ^:private with-dashboard-cleanup + [& body] + `(tu/with-model-cleanup [(quote ~'Card) (quote ~'Dashboard) (quote ~'Collection) + (quote ~'DashboardCard)] + ~@body)) + +(expect + (with-rasta + (with-dashboard-cleanup + (->> (Table) (keep #(automagic-analysis % {})) (every? valid-dashboard?))))) + +(expect + (with-rasta + (with-dashboard-cleanup + (->> (Field) (keep #(automagic-analysis % {})) (every? valid-dashboard?))))) + +(expect + (tt/with-temp* [Metric [{metric-id :id} {:table_id (data/id :venues) + :definition {:query {:aggregation ["count"]}}}]] + (with-rasta + (with-dashboard-cleanup + (->> (Metric) (keep #(automagic-analysis % {})) (every? valid-dashboard?)))))) + +(expect + (tt/with-temp* [Card [{card-id :id} {:table_id (data/id :venues) + :dataset_query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] + :source_table (data/id :venues)} + :type :query + :database (data/id)}}]] + (with-rasta + (with-dashboard-cleanup + (-> card-id Card (automagic-analysis {}) valid-dashboard?))))) + +(expect + (tt/with-temp* [Card [{card-id :id} {:table_id nil + :dataset_query {:native {:query "select * from users"} + :type :native + :database (data/id)}}]] + (with-rasta + (with-dashboard-cleanup + (-> card-id Card (automagic-analysis {}) valid-dashboard?))))) + +(expect + (tt/with-temp* [Card [{source-id :id} {:table_id (data/id :venues) + :dataset_query {:query {:source_table (data/id :venues)} + :type :query + :database (data/id)}}] + Card [{card-id :id} {:table_id (data/id :venues) + :dataset_query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] + :source_table (str "card__" source-id)} + :type :query + :database -1337}}]] + (with-rasta + (with-dashboard-cleanup + (-> card-id Card (automagic-analysis {}) valid-dashboard?))))) + +(expect + (tt/with-temp* [Card [{source-id :id} {:table_id nil + :dataset_query {:native {:query "select * from users"} + :type :native + :database (data/id)}}] + Card [{card-id :id} {:table_id (data/id :venues) + :dataset_query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] + :source_table (str "card__" source-id)} + :type :query + :database -1337}}]] + (with-rasta + (with-dashboard-cleanup + (-> card-id Card (automagic-analysis {}) valid-dashboard?))))) + +(expect + (tt/with-temp* [Card [{card-id :id} {:table_id (data/id :venues) + :dataset_query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] + :source_table (data/id :venues)} + :type :query + :database (data/id)}}]] + (with-rasta + (with-dashboard-cleanup + (-> card-id + Card + (automagic-analysis {:cell-query [:= [:field-id (data/id :venues :category_id) 2]]}) + valid-dashboard?))))) + + +(expect + (tt/with-temp* [Card [{card-id :id} {:table_id (data/id :venues) + :dataset_query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] + :source_table (data/id :venues)} + :type :query + :database (data/id)}}]] + (with-rasta + (with-dashboard-cleanup + (-> card-id + Card + (automagic-analysis {:cell-query [:!= [:field-id (data/id :venues :category_id) 2]]}) + valid-dashboard?))))) + + +(expect + (with-rasta + (with-dashboard-cleanup + (let [q (query/adhoc-query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] + :source_table (data/id :venues)} + :type :query + :database (data/id)})] + (-> q (automagic-analysis {}) valid-dashboard?))))) + +(expect + (with-rasta + (with-dashboard-cleanup + (let [q (query/adhoc-query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] + :source_table (data/id :venues)} + :type :query + :database (data/id)})] + (-> q + (automagic-analysis {:cell-query [:= [:field-id (data/id :venues :category_id) 2]]}) + valid-dashboard?))))) + + +(expect + 3 + (with-rasta + (->> (Database (data/id)) candidate-tables first :tables count))) + +;; /candidates should work with unanalyzed tables +(expect + 1 + (tt/with-temp* [Database [{db-id :id}] + Table [{table-id :id} {:db_id db-id}] + Field [{} {:table_id table-id}] + Field [{} {:table_id table-id}]] + (with-rasta + (with-dashboard-cleanup + (count (candidate-tables (Database db-id))))))) + + +;; Identity +(expect + :d1 + (-> [{:d1 {:field_type [:type/Category] :score 100}}] + (#'magic/most-specific-definition) + first + key)) + +;; Base case: more ancestors +(expect + :d2 + (-> [{:d1 {:field_type [:type/Category] :score 100}} + {:d2 {:field_type [:type/State] :score 100}}] + (#'magic/most-specific-definition) + first + key)) + +;; Break ties based on the number of additional filters +(expect + :d3 + (-> [{:d1 {:field_type [:type/Category] :score 100}} + {:d2 {:field_type [:type/State] :score 100}} + {:d3 {:field_type [:type/State] + :named "foo" + :score 100}}] + (#'magic/most-specific-definition) + first + key)) + +;; Break ties on score +(expect + :d2 + (-> [{:d1 {:field_type [:type/Category] :score 100}} + {:d2 {:field_type [:type/State] :score 100}} + {:d3 {:field_type [:type/State] :score 90}}] + (#'magic/most-specific-definition) + first + key)) + +;; Number of additional filters has precedence over score +(expect + :d3 + (-> [{:d1 {:field_type [:type/Category] :score 100}} + {:d2 {:field_type [:type/State] :score 100}} + {:d3 {:field_type [:type/State] + :named "foo" + :score 0}}] + (#'magic/most-specific-definition) + first + key)) + + +(expect + :month + (#'magic/optimal-datetime-resolution + {:fingerprint {:type {:type/DateTime {:earliest "2015" + :latest "2017"}}}})) + +(expect + :day + (#'magic/optimal-datetime-resolution + {:fingerprint {:type {:type/DateTime {:earliest "2017-01-01" + :latest "2017-03-04"}}}})) + +(expect + :year + (#'magic/optimal-datetime-resolution + {:fingerprint {:type {:type/DateTime {:earliest "2005" + :latest "2017"}}}})) + +(expect + :hour + (#'magic/optimal-datetime-resolution + {:fingerprint {:type {:type/DateTime {:earliest "2017-01-01" + :latest "2017-01-02"}}}})) + +(expect + :minute + (#'magic/optimal-datetime-resolution + {:fingerprint {:type {:type/DateTime {:earliest "2017-01-01T00:00:00" + :latest "2017-01-01T00:02:00"}}}})) diff --git a/test/metabase/automagic_dashboards/populate_test.clj b/test/metabase/automagic_dashboards/populate_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..33a424ab65df74d95f5b7064197121501303b760 --- /dev/null +++ b/test/metabase/automagic_dashboards/populate_test.clj @@ -0,0 +1,3 @@ +(ns metabase.automagic-dashboards.populate-test + (:require [expectations :refer :all] + [metabase.automagic-dashboards.populate :as populate])) diff --git a/test/metabase/automagic_dashboards/rules_test.clj b/test/metabase/automagic_dashboards/rules_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..962e9428256c35bc4c9bd2df500253dec7eab0fd --- /dev/null +++ b/test/metabase/automagic_dashboards/rules_test.clj @@ -0,0 +1,36 @@ +(ns metabase.automagic-dashboards.rules-test + (:require [expectations :refer :all] + [metabase.automagic-dashboards.rules :refer :all :as rules])) + +(expect nil (#'rules/ensure-seq nil)) +(expect [nil] (#'rules/ensure-seq [nil])) +(expect [42] (#'rules/ensure-seq 42)) +(expect [42] (#'rules/ensure-seq [42])) + +(expect true (ga-dimension? "ga:foo")) +(expect false (ga-dimension? "foo")) + +(expect :foo (#'rules/->type :foo)) +(expect "ga:foo" (#'rules/->type "ga:foo")) +(expect :type/Foo (#'rules/->type "Foo")) + +;; This also tests that all the rules are valid (else there would be nils returned) +(expect (every? some? (get-rules ["table"]))) +(expect (every? some? (get-rules ["metrics"]))) +(expect (every? some? (get-rules ["fields"]))) + +(expect (some? (get-rules ["table" "GenericTable" "ByCountry"]))) + +(expect true (dimension-form? [:dimension "Foo"])) +(expect true (dimension-form? ["dimension" "Foo"])) +(expect true (dimension-form? ["DIMENSION" "Foo"])) +(expect false (dimension-form? 42)) +(expect false (dimension-form? [:baz :bar])) + +(expect + ["Foo" "Baz" "Bar"] + (#'rules/collect-dimensions + [{:metrics [{"Foo" {:metric [:sum [:dimension "Foo"]]}} + {"Foo" {:metric [:avg [:dimension "Foo"]]}} + {"Baz" {:metric [:sum ["dimension" "Baz"]]}}]} + [:dimension "Bar"]])) diff --git a/test/metabase/driver/crate_test.clj b/test/metabase/driver/crate_test.clj index 0b810f8c9133dd386286b3fe41dd03308bf50b0d..32998a647ff4224dbd3728f3f731fe52f5582461 100644 --- a/test/metabase/driver/crate_test.clj +++ b/test/metabase/driver/crate_test.clj @@ -1,6 +1,9 @@ (ns metabase.driver.crate-test (:require [metabase.test.data.datasets :refer [expect-with-engine]] - [metabase.test.util :as tu])) + [metabase.test.data :as data] + [metabase.test.data.dataset-definitions :as defs] + [metabase.test.util :as tu] + [metabase.sync :as sync])) (expect-with-engine :crate "UTC" diff --git a/test/metabase/driver/druid_test.clj b/test/metabase/driver/druid_test.clj index 9be935a17c4f1f4a6b5b2f54ce7561eaffff86f2..fe90345cf78be19a5e638c5dcf28923e2acdf09e 100644 --- a/test/metabase/driver/druid_test.clj +++ b/test/metabase/driver/druid_test.clj @@ -7,7 +7,6 @@ [driver :as driver] [query-processor :as qp] [query-processor-test :refer [rows rows+column-names]] - [timeseries-query-processor-test :as timeseries-qp-test] [util :as u]] [metabase.driver.druid :as druid] [metabase.models @@ -15,15 +14,12 @@ [metric :refer [Metric]] [table :refer [Table]]] [metabase.query-processor.middleware.expand :as ql] - [metabase.query-processor-test.query-cancellation-test :as cancel-test] [metabase.test [data :as data] [util :as tu]] - [metabase.test.data - [dataset-definitions :as defs] - [datasets :as datasets :refer [expect-with-engine]]] - [toucan.util.test :as tt]) - (:import metabase.driver.druid.DruidDriver)) + [metabase.test.data.datasets :as datasets :refer [expect-with-engine]] + [metabase.timeseries-query-processor-test.util :as tqpt] + [toucan.util.test :as tt])) ;;; table-rows-sample (datasets/expect-with-engine :druid @@ -82,7 +78,7 @@ (defn- process-native-query [query] (datasets/with-engine :druid - (timeseries-qp-test/with-flattened-dbdef + (tqpt/with-flattened-dbdef (-> (qp/process-query {:native {:query query} :type :native :database (data/id)}) @@ -131,7 +127,7 @@ ;;; +------------------------------------------------------------------------------------------------------------------------+ (defmacro ^:private druid-query {:style/indent 0} [& body] - `(timeseries-qp-test/with-flattened-dbdef + `(tqpt/with-flattened-dbdef (qp/process-query {:database (data/id) :type :query :query (data/query ~'checkins @@ -308,7 +304,7 @@ [["2" 1231.0] ["3" 346.0] ["4" 197.0]] - (timeseries-qp-test/with-flattened-dbdef + (tqpt/with-flattened-dbdef (tt/with-temp Metric [metric {:definition {:aggregation [:sum [:field-id (data/id :checkins :venue_price)]] :filter [:> [:field-id (data/id :checkins :venue_price)] 1]}}] (rows (qp/process-query diff --git a/test/metabase/driver/generic_sql/native_test.clj b/test/metabase/driver/generic_sql/native_test.clj index 63af244d78357736767c54b0e6d8042e29449e53..e9993877c5f0c0b516c00bd36f08541805e8e6ea 100644 --- a/test/metabase/driver/generic_sql/native_test.clj +++ b/test/metabase/driver/generic_sql/native_test.clj @@ -17,8 +17,8 @@ [99]] :columns ["ID"] :cols [(merge col-defaults {:name "ID", :display_name "ID", :base_type :type/Integer})] - :native_form {:query "SELECT ID FROM VENUES ORDER BY ID DESC LIMIT 2;", :params []}}} - (-> (qp/process-query {:native {:query "SELECT ID FROM VENUES ORDER BY ID DESC LIMIT 2;"} + :native_form {:query "SELECT ID FROM VENUES ORDER BY ID DESC LIMIT 2", :params []}}} + (-> (qp/process-query {:native {:query "SELECT ID FROM VENUES ORDER BY ID DESC LIMIT 2"} :type :native :database (id)}) (m/dissoc-in [:data :results_metadata]))) @@ -34,8 +34,8 @@ [{:name "ID", :display_name "ID", :base_type :type/Integer} {:name "NAME", :display_name "Name", :base_type :type/Text} {:name "CATEGORY_ID", :display_name "Category ID", :base_type :type/Integer}]) - :native_form {:query "SELECT ID, NAME, CATEGORY_ID FROM VENUES ORDER BY ID DESC LIMIT 2;", :params []}}} - (-> (qp/process-query {:native {:query "SELECT ID, NAME, CATEGORY_ID FROM VENUES ORDER BY ID DESC LIMIT 2;"} + :native_form {:query "SELECT ID, NAME, CATEGORY_ID FROM VENUES ORDER BY ID DESC LIMIT 2", :params []}}} + (-> (qp/process-query {:native {:query "SELECT ID, NAME, CATEGORY_ID FROM VENUES ORDER BY ID DESC LIMIT 2"} :type :native :database (id)}) (m/dissoc-in [:data :results_metadata]))) @@ -44,7 +44,7 @@ (expect {:status :failed :class java.lang.Exception :error "Column \"ZID\" not found"} - (dissoc (qp/process-query {:native {:query "SELECT ZID FROM CHECKINS LIMIT 2;"} ; make sure people know it's to be expected + (dissoc (qp/process-query {:native {:query "SELECT ZID FROM CHECKINS LIMIT 2"} ; make sure people know it's to be expected :type :native :database (id)}) :stacktrace @@ -58,5 +58,5 @@ (let [db (db/insert! Database, :name "Fake-H2-DB", :engine "h2", :details {:db "mem:fake-h2-db"})] (try (:error (qp/process-query {:database (:id db) :type :native - :native {:query "SELECT 1;"}})) + :native {:query "SELECT 1"}})) (finally (db/delete! Database :name "Fake-H2-DB"))))) diff --git a/test/metabase/driver/generic_sql_test.clj b/test/metabase/driver/generic_sql_test.clj index 301f7b1eef792c1695e0e5ab3e5caf451c074d5e..3380964fd84538be3076e4f016392b105a0d4755 100644 --- a/test/metabase/driver/generic_sql_test.clj +++ b/test/metabase/driver/generic_sql_test.clj @@ -18,7 +18,7 @@ (def ^:private generic-sql-engines (delay (set (for [engine datasets/all-valid-engines :let [driver (driver/engine->driver engine)] - :when (not (contains? #{:bigquery :presto} engine)) ; bigquery and presto don't use the generic sql implementations of things like `field-avg-length` + :when (not (contains? #{:bigquery :presto :sparksql} engine)) ; bigquery, presto and sparksql don't use the generic sql implementations of things like `field-avg-length` :when (extends? ISQLDriver (class driver))] (do (require (symbol (str "metabase.test.data." (name engine))) :reload) ; otherwise it gets all snippy if you try to do `lein test metabase.driver.generic-sql-test` engine))))) diff --git a/test/metabase/driver/googleanalytics_test.clj b/test/metabase/driver/googleanalytics_test.clj index e8201c18db6997d80bcd4902d8744b7009756918..ceb95d91e1766ec830b681523940747c963a5942 100644 --- a/test/metabase/driver/googleanalytics_test.clj +++ b/test/metabase/driver/googleanalytics_test.clj @@ -2,8 +2,16 @@ "Tests for the Google Analytics driver and query processor." (:require [expectations :refer :all] [metabase.driver.googleanalytics.query-processor :as qp] + [metabase.models + [card :refer [Card]] + [database :refer [Database]] + [field :refer [Field]] + [table :refer [Table]]] [metabase.query-processor.interface :as qpi] - [metabase.util :as u])) + [metabase.test.data.users :as users] + [metabase.util :as u] + [toucan.db :as db] + [toucan.util.test :as tt])) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | QUERY "TRANSFORMATION" | @@ -168,10 +176,44 @@ :unit :year :field (ga-date-field :year)})}}})) - - - ;; limit (expect (ga-query {:max-results 25}) (mbql->native {:query {:limit 25}})) + + +;;; ------------------------------------------------ Saving GA Cards ------------------------------------------------- + +;; Can we *save* a GA query that has two aggregations? + +(expect + 1 + (tt/with-temp* [Database [db {:engine :googleanalytics}] + Table [table {:db_id (u/get-id db)}] + Field [field {:table_id (u/get-id table)}]] + (->> ((users/user->client :crowberto) :post 200 "card" + {:name "Metabase Websites, Sessions and 1 Day Active Users, Grouped by Date (day)" + :display :table + :visualization_settings {} + :dataset_query {:database (u/get-id db) + :type :query + :query {:source_table (u/get-id table) + :aggregation [[:METRIC "ga:sessions"] + [:METRIC "ga:1dayUsers"]] + :breakout [[:datetime-field [:field-id (u/get-id field)] :day]]}} + :result_metadata [{:base_type :type/Date + :display_name :Date + :name "ga:date" + :description "The date of the session formatted as YYYYMMDD." + :unit :day} + {:base_type :type/Integer + :display_name "Ga:1day Users" + :name "ga:1dayUsers"} + {:base_type :type/Integer + :display_name "Ga:sessions" + :name "ga:sessions"}] + :metadata_checksum "VRyGLaFPj6T9RTIgMFvyAA=="}) + ;; just make sure the API call actually worked by checking that the created Card is actually successfully + ;; saved in the DB + u/get-id + (db/count Card :id)))) diff --git a/test/metabase/driver/mongo_test.clj b/test/metabase/driver/mongo_test.clj index 16a88f404fe05b65f28659fb5eac0e60e80bf073..4a346543db6a20e7ac834315ba7cdb6f819b8137 100644 --- a/test/metabase/driver/mongo_test.clj +++ b/test/metabase/driver/mongo_test.clj @@ -141,11 +141,11 @@ ;; Test that Tables got synced correctly, and row counts are correct (datasets/expect-with-engine :mongo - [{:rows 75, :active true, :name "categories"} - {:rows 1000, :active true, :name "checkins"} - {:rows 15, :active true, :name "users"} - {:rows 100, :active true, :name "venues"}] - (for [field (db/select [Table :name :active :rows] + [{:active true, :name "categories"} + {:active true, :name "checkins"} + {:active true, :name "users"} + {:active true, :name "venues"}] + (for [field (db/select [Table :name :active] :db_id (data/id) {:order-by [:name]})] (into {} field))) @@ -157,7 +157,7 @@ [{:special_type :type/PK, :base_type :type/Integer, :name "_id"} {:special_type nil, :base_type :type/DateTime, :name "date"} {:special_type :type/Category, :base_type :type/Integer, :name "user_id"} - {:special_type :type/Category, :base_type :type/Integer, :name "venue_id"}] + {:special_type nil, :base_type :type/Integer, :name "venue_id"}] [{:special_type :type/PK, :base_type :type/Integer, :name "_id"} {:special_type nil, :base_type :type/DateTime, :name "last_login"} {:special_type :type/Name, :base_type :type/Text, :name "name"} diff --git a/test/metabase/driver/mysql_test.clj b/test/metabase/driver/mysql_test.clj index bda9d3045e1c4eb47dc723dd03cf8382306acf83..7840327daf2e2e1e2645e0b9ea44b1ea7952bcf0 100644 --- a/test/metabase/driver/mysql_test.clj +++ b/test/metabase/driver/mysql_test.clj @@ -1,5 +1,6 @@ (ns metabase.driver.mysql-test - (:require [expectations :refer :all] + (:require [clj-time.core :as t] + [expectations :refer :all] [metabase [sync :as sync] [util :as u]] @@ -73,7 +74,7 @@ ;; if someone says specifies `tinyInt1isBit=false`, it should come back as a number instead (expect-with-engine :mysql - #{{:name "number-of-cans", :base_type :type/Integer, :special_type :type/Category} + #{{:name "number-of-cans", :base_type :type/Integer, :special_type :type/Quantity} {:name "id", :base_type :type/Integer, :special_type :type/PK} {:name "thing", :base_type :type/Text, :special_type :type/Category}} (data/with-temp-db [db tiny-int-ones] @@ -97,9 +98,18 @@ (with-redefs [metabase.driver/execute-query (constantly {:rows [["2018-01-08 23:00:00.008 CET"]]})] (tu/db-timezone-id))) -(expect (#'mysql/timezone-id->offset-str "US/Pacific") "-08:00") -(expect (#'mysql/timezone-id->offset-str "UTC") "+00:00") -(expect (#'mysql/timezone-id->offset-str "America/Los_Angeles") "-08:00") + +(def before-daylight-savings (u/str->date-time "2018-03-10 10:00:00")) +(def after-daylight-savings (u/str->date-time "2018-03-12 10:00:00")) + +(expect (#'mysql/timezone-id->offset-str "US/Pacific" before-daylight-savings) "-08:00") +(expect (#'mysql/timezone-id->offset-str "US/Pacific" after-daylight-savings) "-07:00") + +(expect (#'mysql/timezone-id->offset-str "UTC" before-daylight-savings) "+00:00") +(expect (#'mysql/timezone-id->offset-str "UTC" after-daylight-savings) "+00:00") + +(expect (#'mysql/timezone-id->offset-str "America/Los_Angeles" before-daylight-savings) "-08:00") +(expect (#'mysql/timezone-id->offset-str "America/Los_Angeles" after-daylight-savings) "-07:00") ;; make sure DateTime types generate appropriate SQL... ;; ...with no report-timezone set diff --git a/test/metabase/driver/postgres_test.clj b/test/metabase/driver/postgres_test.clj index 290140ecf7bf50fa0e68caaf83f712a7912e8049..f4f5c4ba33eb66794ff33bd779ed236c80ea6976 100644 --- a/test/metabase/driver/postgres_test.clj +++ b/test/metabase/driver/postgres_test.clj @@ -282,11 +282,12 @@ "UTC" (tu/db-timezone-id)) - ;; Make sure we're able to fingerprint TIME fields (#5911) (expect-with-engine :postgres - #{#metabase.models.field.FieldInstance{:name "start_time", :fingerprint {:global {:distinct-count 1}}} - #metabase.models.field.FieldInstance{:name "end_time", :fingerprint {:global {:distinct-count 1}}} + #{#metabase.models.field.FieldInstance{:name "start_time", :fingerprint {:global {:distinct-count 1} + :type {:type/DateTime {:earliest "1970-01-01T22:00:00.000Z", :latest "1970-01-01T22:00:00.000Z"}}}} + #metabase.models.field.FieldInstance{:name "end_time", :fingerprint {:global {:distinct-count 1} + :type {:type/DateTime {:earliest "1970-01-01T09:00:00.000Z", :latest "1970-01-01T09:00:00.000Z"}}}} #metabase.models.field.FieldInstance{:name "reason", :fingerprint {:global {:distinct-count 1} :type {:type/Text {:percent-json 0.0 :percent-url 0.0 diff --git a/test/metabase/driver/sparksql_test.clj b/test/metabase/driver/sparksql_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..a1532c9d82ee6fe30acf2c5e4fe713a8342ad5b8 --- /dev/null +++ b/test/metabase/driver/sparksql_test.clj @@ -0,0 +1,20 @@ +(ns metabase.driver.sparksql-test + (:require [expectations :refer :all] + [metabase.driver.sparksql :as sparksql])) + +;; Make sure our custom implementation of `apply-page` works the way we'd expect +(expect + {:select ["name" "id"] + :from [{:select [[:default.categories.name "name"] + [:default.categories.id "id"] + [{:s "row_number() OVER (ORDER BY `default`.`categories`.`id` ASC)"} :__rownum__]] + :from [:default.categories] + :order-by [[:default.categories.id :asc]]}] + :where [:> :__rownum__ 5] + :limit 5} + (#'sparksql/apply-page-using-row-number-for-offset + {:select [[:default.categories.name "name"] [:default.categories.id "id"]] + :from [:default.categories] + :order-by [[:default.categories.id :asc]]} + {:page {:page 2 + :items 5}})) diff --git a/test/metabase/events/dependencies_test.clj b/test/metabase/events/dependencies_test.clj index dd1215d4ad04711556b9cdd0751da2c31b150b3b..3b23f13b84491346eff8c6502deb9728adb7b06b 100644 --- a/test/metabase/events/dependencies_test.clj +++ b/test/metabase/events/dependencies_test.clj @@ -6,64 +6,85 @@ [database :refer [Database]] [dependency :refer [Dependency]] [metric :refer [Metric]] + [segment :refer [Segment]] [table :refer [Table]]] - [metabase.test.data :refer :all] + [metabase.test.data :as data] + [metabase.util :as u] [toucan.db :as db] [toucan.util.test :as tt])) +(defn- temp-segment [] + {:definition {:database (data/id) + :filter [:= [:field-id (data/id :categories :id)] 1]}}) + ;; `:card-create` event -(expect +(tt/expect-with-temp [Segment [segment-1 (temp-segment)] + Segment [segment-2 (temp-segment)]] #{{:dependent_on_model "Segment" - :dependent_on_id 2} + :dependent_on_id (u/get-id segment-1)} {:dependent_on_model "Segment" - :dependent_on_id 3}} - (tt/with-temp Card [card {:dataset_query {:database (id) + :dependent_on_id (u/get-id segment-2)}} + (tt/with-temp Card [card {:dataset_query {:database (data/id) :type :query - :query {:source_table (id :categories) - :filter ["AND" [">" 4 "2014-10-19"] ["=" 5 "yes"] ["SEGMENT" 2] ["SEGMENT" 3]]}}}] + :query {:source_table (data/id :categories) + :filter ["AND" + ["=" + (data/id :categories :name) + "Toucan-friendly"] + ["SEGMENT" (u/get-id segment-1)] + ["SEGMENT" (u/get-id segment-2)]]}}}] (process-dependencies-event {:topic :card-create :item card}) (set (map (partial into {}) - (db/select [Dependency :dependent_on_model :dependent_on_id], :model "Card", :model_id (:id card)))))) + (db/select [Dependency :dependent_on_model :dependent_on_id] + :model "Card", :model_id (u/get-id card)))))) ;; `:card-update` event (expect [] - (tt/with-temp Card [card {:dataset_query {:database (id) + (tt/with-temp Card [card {:dataset_query {:database (data/id) :type :query - :query {:source_table (id :categories)}}}] + :query {:source_table (data/id :categories)}}}] (process-dependencies-event {:topic :card-create :item card}) - (db/select [Dependency :dependent_on_model :dependent_on_id], :model "Card", :model_id (:id card)))) + (db/select [Dependency :dependent_on_model :dependent_on_id], :model "Card", :model_id (u/get-id card)))) ;; `:metric-create` event -(expect +(tt/expect-with-temp [Segment [segment-1 (temp-segment)] + Segment [segment-2 (temp-segment)]] #{{:dependent_on_model "Segment" - :dependent_on_id 18} + :dependent_on_id (u/get-id segment-1)} {:dependent_on_model "Segment" - :dependent_on_id 35}} + :dependent_on_id (u/get-id segment-2)}} (tt/with-temp* [Database [{database-id :id}] Table [{table-id :id} {:db_id database-id}] Metric [metric {:table_id table-id :definition {:aggregation ["count"] - :filter ["AND" ["SEGMENT" 18] ["SEGMENT" 35]]}}]] + :filter ["AND" + ["SEGMENT" (u/get-id segment-1)] + ["SEGMENT" (u/get-id segment-2)]]}}]] (process-dependencies-event {:topic :metric-create :item metric}) (set (map (partial into {}) - (db/select [Dependency :dependent_on_model :dependent_on_id], :model "Metric", :model_id (:id metric)))))) + (db/select [Dependency :dependent_on_model :dependent_on_id] + :model "Metric", :model_id (u/get-id metric)))))) ;; `:card-update` event -(expect +(tt/expect-with-temp [Segment [segment-1 (temp-segment)] + Segment [segment-2 (temp-segment)]] #{{:dependent_on_model "Segment" - :dependent_on_id 18} + :dependent_on_id (u/get-id segment-1)} {:dependent_on_model "Segment" - :dependent_on_id 35}} + :dependent_on_id (u/get-id segment-2)}} (tt/with-temp* [Database [{database-id :id}] Table [{table-id :id} {:db_id database-id}] Metric [metric {:table_id table-id :definition {:aggregation ["count"] - :filter ["AND" ["SEGMENT" 18] ["SEGMENT" 35]]}}]] + :filter ["AND" + ["SEGMENT" (u/get-id segment-1)] + ["SEGMENT" (u/get-id segment-2)]]}}]] (process-dependencies-event {:topic :metric-update :item metric}) (set (map (partial into {}) - (db/select [Dependency :dependent_on_model :dependent_on_id], :model "Metric", :model_id (:id metric)))))) + (db/select [Dependency :dependent_on_model :dependent_on_id] + :model "Metric", :model_id (u/get-id metric)))))) diff --git a/test/metabase/models/card_test.clj b/test/metabase/models/card_test.clj index 26fd2374dfb40afcba5e36f1ad1584234bd7e15f..66ab17d6eff1474db3b8cb40a2712e4cf00a4eb6 100644 --- a/test/metabase/models/card_test.clj +++ b/test/metabase/models/card_test.clj @@ -1,8 +1,9 @@ (ns metabase.models.card-test - (:require [expectations :refer :all] + (:require [cheshire.core :as json] + [expectations :refer :all] [metabase.api.common :refer [*current-user-permissions-set*]] [metabase.models - [card :refer :all] + [card :refer :all :as card] [dashboard :refer [Dashboard]] [dashboard-card :refer [DashboardCard]] [database :as database] @@ -217,3 +218,129 @@ (db/update! Card id {:name "another name" :dataset_query (dummy-dataset-query (data/id))}) (into {} (db/select-one [Card :name :database_id] :id id)))])) + + + +;;; ------------------------------------------ Circular Reference Detection ------------------------------------------ + +(defn- card-with-source-table + "Generate values for a Card with `source-table` for use with `with-temp`." + {:style/indent 1} + [source-table & {:as kvs}] + (merge {:dataset_query {:database (data/id) + :type :query + :query {:source-table source-table}}} + kvs)) + +(defn- force-update-card-to-reference-source-table! + "Skip normal pre-update stuff so we can force a Card to get into an invalid state." + [card source-table] + (db/update! Card {:where [:= :id (u/get-id card)] + :set (-> (card-with-source-table source-table + ;; clear out cached read permissions to make sure those aren't used for calcs + :read_permissions nil) + ;; we have to manually JSON-encode since we're skipping normal pre-update stuff + (update :dataset_query json/generate-string))})) + +;; No circular references = it should work! +(expect + {:card-a #{(perms/object-path (data/id) "PUBLIC" (data/id :venues))} + :card-b #{(perms/object-path (data/id) "PUBLIC" (data/id :venues))}} + ;; Make two cards. Card B references Card A. + (tt/with-temp* [Card [card-a (card-with-source-table (data/id :venues))] + Card [card-b (card-with-source-table (str "card__" (u/get-id card-a)))]] + {:card-a (#'card/card-perms-set-taking-collection-etc-into-account (Card (u/get-id card-a)) :read) + :card-b (#'card/card-perms-set-taking-collection-etc-into-account (Card (u/get-id card-b)) :read)})) + +;; If a Card uses itself as a source, perms calculations should fallback to the 'only admins can see it' perms of +;; #{"/db/0"} (DB 0 will never exist, so regular users will never get to see it, but because admins have root perms, +;; they will still get to see it and perhaps fix it.) +(expect + Exception + (tt/with-temp Card [card (card-with-source-table (data/id :venues))] + ;; now try to make the Card reference itself. Should throw Exception + (db/update! Card (u/get-id card) + (card-with-source-table (str "card__" (u/get-id card)))))) + +;; if for some reason somebody such an invalid Card was already saved in the DB make sure that calculating permissions +;; for it just returns the admin-only #{"/db/0"} perms set +(expect + #{"/db/0/"} + (tt/with-temp Card [card (card-with-source-table (data/id :venues))] + ;; now *make* the Card reference itself + (force-update-card-to-reference-source-table! card (str "card__" (u/get-id card))) + ;; ok. Calculate perms. Should fail and fall back to admin-only perms + (#'card/card-perms-set-taking-collection-etc-into-account (Card (u/get-id card)) :read))) + +;; Do the same stuff with circular reference between two Cards... (A -> B -> A) +(expect + Exception + (tt/with-temp* [Card [card-a (card-with-source-table (data/id :venues))] + Card [card-b (card-with-source-table (str "card__" (u/get-id card-a)))]] + (db/update! Card (u/get-id card-a) + (card-with-source-table (str "card__" (u/get-id card-b)))))) + +(expect + #{"/db/0/"} + ;; Make two cards. Card B references Card A + (tt/with-temp* [Card [card-a (card-with-source-table (data/id :venues))] + Card [card-b (card-with-source-table (str "card__" (u/get-id card-a)))]] + ;; force Card A to reference Card B + (force-update-card-to-reference-source-table! card-a (str "card__" (u/get-id card-b))) + ;; perms calc should fail and we should get admin-only perms + (#'card/card-perms-set-taking-collection-etc-into-account (Card (u/get-id card-a)) :read))) + +;; ok now try it with A -> C -> B -> A +(expect + Exception + (tt/with-temp* [Card [card-a (card-with-source-table (data/id :venues))] + Card [card-b (card-with-source-table (str "card__" (u/get-id card-a)))] + Card [card-c (card-with-source-table (str "card__" (u/get-id card-b)))]] + (db/update! Card (u/get-id card-a) + (card-with-source-table (str "card__" (u/get-id card-c)))))) + +(expect + #{"/db/0/"} + (tt/with-temp* [Card [card-a (card-with-source-table (data/id :venues))] + Card [card-b (card-with-source-table (str "card__" (u/get-id card-a)))] + Card [card-c (card-with-source-table (str "card__" (u/get-id card-b)))]] + ;; force Card A to reference Card C + (force-update-card-to-reference-source-table! card-a (str "card__" (u/get-id card-c))) + ;; perms calc should fail and we should get admin-only perms + (#'card/card-perms-set-taking-collection-etc-into-account (Card (u/get-id card-a)) :read))) + + +;;; ---------------------------------------------- Updating Read Perms ----------------------------------------------- + +;; Make sure when saving a new Card read perms get calculated +(expect + #{(format "/db/%d/native/read/" (data/id))} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT 1"}}}] + ;; read_permissions should have been populated + (db/select-one-field :read_permissions Card :id (u/get-id card)))) + +;; Make sure when updating a Card's query read perms get updated +(expect + #{(format "/db/%d/schema/PUBLIC/table/%d/" (data/id) (data/id :venues))} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT 1"}}}] + ;; now change the query... + (db/update! Card (u/get-id card) :dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues)}}) + ;; read permissions should have been updated + (db/select-one-field :read_permissions Card :id (u/get-id card)))) + +;; Make sure when updating a Card but not changing query read perms do not get changed +(expect + #{(format "/db/%d/native/read/" (data/id))} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT 1"}}}] + ;; now change something *besides* the query... + (db/update! Card (u/get-id card) :name "Cam's super-awesome CARD") + ;; read permissions should *not* have been updated + (db/select-one-field :read_permissions Card :id (u/get-id card)))) diff --git a/test/metabase/models/dashboard_test.clj b/test/metabase/models/dashboard_test.clj index 843d67a699e85056a1424a6a04088f6024dc53a0..340c0fe5af91b8a71011c1380a8be6189e77a0a5 100644 --- a/test/metabase/models/dashboard_test.clj +++ b/test/metabase/models/dashboard_test.clj @@ -1,10 +1,14 @@ (ns metabase.models.dashboard-test (:require [expectations :refer :all] + [metabase.api.common :as api] + [metabase.automagic-dashboards.core :as magic] [metabase.models [card :refer [Card]] [dashboard :refer :all :as dashboard] [dashboard-card :as dashboard-card :refer [DashboardCard]] - [dashboard-card-series :refer [DashboardCardSeries]]] + [dashboard-card-series :refer [DashboardCardSeries]] + [table :refer [Table]] + [user :as user]] [metabase.test [data :refer :all] [util :as tu]] @@ -167,3 +171,18 @@ (tu/with-temporary-setting-values [enable-public-sharing false] (tt/with-temp Dashboard [dashboard {:public_uuid (str (java.util.UUID/randomUUID))}] (:public_uuid dashboard)))) + + +;; test that we save a transient dashboard +(expect + 8 + (tu/with-model-cleanup ['Card 'Dashboard 'DashboardCard 'Collection] + (binding [api/*current-user-id* (user->id :rasta) + api/*current-user-permissions-set* (-> :rasta + user->id + user/permissions-set + atom)] + (->> (magic/automagic-analysis (Table (id :venues)) {}) + save-transient-dashboard! + :id + (db/count 'DashboardCard :dashboard_id))))) diff --git a/test/metabase/models/field_test.clj b/test/metabase/models/field_test.clj index ba98f7218c68c440bb6594e6ca3ee6bbf067ef91..a2981731b288c28c72bf1f86749927da339bf164 100644 --- a/test/metabase/models/field_test.clj +++ b/test/metabase/models/field_test.clj @@ -1,77 +1,13 @@ (ns metabase.models.field-test + "Tests for specific behavior related to the Field model." (:require [expectations :refer :all] - [metabase.models.field-values :refer :all] [metabase.sync.analyze.classifiers.name :as name])) -;; field-should-have-field-values? - -;; retired/sensitive/hidden/details-only fields should always be excluded -(expect false (field-should-have-field-values? {:base_type :type/Boolean - :special_type :type/Category - :visibility_type :retired})) -(expect false (field-should-have-field-values? {:base_type :type/Boolean - :special_type :type/Category - :visibility_type :sensitive})) -(expect false (field-should-have-field-values? {:base_type :type/Boolean - :special_type :type/Category - :visibility_type :hidden})) -(expect false (field-should-have-field-values? {:base_type :type/Boolean - :special_type :type/Category - :visibility_type :details-only})) -;; date/time based fields should always be excluded -(expect false (field-should-have-field-values? {:base_type :type/Date - :special_type :type/Category - :visibility_type :normal})) -(expect false (field-should-have-field-values? {:base_type :type/DateTime - :special_type :type/Category - :visibility_type :normal})) -(expect false (field-should-have-field-values? {:base_type :type/Time - :special_type :type/Category - :visibility_type :normal})) -;; most special types should be excluded -(expect false (field-should-have-field-values? {:base_type :type/Text - :special_type :type/ImageURL - :visibility_type :normal})) -(expect false (field-should-have-field-values? {:base_type :type/Text - :special_type :id - :visibility_type :normal})) -(expect false (field-should-have-field-values? {:base_type :type/Text - :special_type :type/FK - :visibility_type :normal})) -(expect false (field-should-have-field-values? {:base_type :type/Text - :special_type :type/Latitude - :visibility_type :normal})) -(expect false (field-should-have-field-values? {:base_type :type/Text - :special_type :type/Number - :visibility_type :normal})) -(expect false (field-should-have-field-values? {:base_type :type/Text - :special_type :type/UNIXTimestampMilliseconds - :visibility_type :normal})) -;; boolean fields + category/city/state/country fields are g2g -(expect true (field-should-have-field-values? {:base_type :type/Boolean - :special_type :type/Number - :visibility_type :normal})) -(expect true (field-should-have-field-values? {:base_type :type/Text - :special_type :type/Category - :visibility_type :normal})) -(expect true (field-should-have-field-values? {:base_type :type/Text - :special_type :type/City - :visibility_type :normal})) -(expect true (field-should-have-field-values? {:base_type :type/Text - :special_type :type/State - :visibility_type :normal})) -(expect true (field-should-have-field-values? {:base_type :type/Text - :special_type :type/Country - :visibility_type :normal})) -(expect true (field-should-have-field-values? {:base_type :type/Text - :special_type :type/Name - :visibility_type :normal})) - ;;; infer-field-special-type (expect :type/PK (#'name/special-type-for-name-and-base-type "id" :type/Integer)) ;; other pattern matches based on type/regex (remember, base_type matters in matching!) -(expect :type/Category (#'name/special-type-for-name-and-base-type "rating" :type/Integer)) +(expect :type/Score (#'name/special-type-for-name-and-base-type "rating" :type/Integer)) (expect nil (#'name/special-type-for-name-and-base-type "rating" :type/Boolean)) (expect :type/Country (#'name/special-type-for-name-and-base-type "country" :type/Text)) (expect nil (#'name/special-type-for-name-and-base-type "country" :type/Integer)) diff --git a/test/metabase/models/field_values_test.clj b/test/metabase/models/field_values_test.clj index 2b93f6ef1416d61dfba5134894bfab72abf68389..e00c0525bee4701b18649340dee82185f4dbd876 100644 --- a/test/metabase/models/field_values_test.clj +++ b/test/metabase/models/field_values_test.clj @@ -1,4 +1,5 @@ (ns metabase.models.field-values-test + "Tests for specific behavior related to FieldValues and functions in the `metabase.models.field-values` namespace." (:require [clojure.java.jdbc :as jdbc] [expectations :refer :all] [metabase @@ -13,36 +14,70 @@ [toucan.db :as db] [toucan.util.test :as tt])) -;; ## TESTS FOR FIELD-SHOULD-HAVE-FIELD-VALUES? +;;; ---------------------------------------- field-should-have-field-values? ----------------------------------------- -(expect (field-should-have-field-values? {:special_type :type/Category - :visibility_type :normal - :base_type :type/Text})) +(expect (field-should-have-field-values? {:has_field_values :list + :visibility_type :normal + :base_type :type/Text})) -(expect false (field-should-have-field-values? {:special_type :type/Category - :visibility_type :sensitive - :base_type :type/Text})) +(expect false (field-should-have-field-values? {:has_field_values :list + :visibility_type :sensitive + :base_type :type/Text})) -(expect false (field-should-have-field-values? {:special_type :type/Category - :visibility_type :hidden - :base_type :type/Text})) +(expect false (field-should-have-field-values? {:has_field_values :list + :visibility_type :hidden + :base_type :type/Text})) -(expect false (field-should-have-field-values? {:special_type :type/Category - :visibility_type :details-only - :base_type :type/Text})) +(expect false (field-should-have-field-values? {:has_field_values :list + :visibility_type :details-only + :base_type :type/Text})) -(expect false (field-should-have-field-values? {:special_type nil +(expect false (field-should-have-field-values? {:has_field_values nil :visibility_type :normal :base_type :type/Text})) -(expect (field-should-have-field-values? {:special_type "type/Country" - :visibility_type :normal - :base_type :type/Text})) +(expect (field-should-have-field-values? {:has_field_values :list + :visibility_type :normal + :base_type :type/Text})) + +(expect (field-should-have-field-values? {:has_field_values :list + :special_type :type/Category + :visibility_type :normal + :base_type "type/Boolean"})) + + +;; retired/sensitive/hidden/details-only fields should always be excluded +(expect false (field-should-have-field-values? {:base_type :type/Boolean + :has_field_values :list + :visibility_type :retired})) + +(expect false (field-should-have-field-values? {:base_type :type/Boolean + :has_field_values :list + :visibility_type :sensitive})) + +(expect false (field-should-have-field-values? {:base_type :type/Boolean + :has_field_values :list + :visibility_type :hidden})) + +(expect false (field-should-have-field-values? {:base_type :type/Boolean + :has_field_values :list + :visibility_type :details-only})) + +;; date/time based fields should always be excluded +(expect false (field-should-have-field-values? {:base_type :type/Date + :has_field_values :list + :visibility_type :normal})) + +(expect false (field-should-have-field-values? {:base_type :type/DateTime + :has_field_values :list + :visibility_type :normal})) + +(expect false (field-should-have-field-values? {:base_type :type/Time + :has_field_values :list + :visibility_type :normal})) -(expect (field-should-have-field-values? {:special_type nil - :visibility_type :normal - :base_type "type/Boolean"})) +;;; ------------------------------------------------ everything else ------------------------------------------------- (expect [[1 2 3] diff --git a/test/metabase/models/on_demand_test.clj b/test/metabase/models/on_demand_test.clj index 9a7b17c8343e40d5fa34821570280b057b6f14a2..3626cd7e80e7f143538ab150ef1c8b5c8b215cd2 100644 --- a/test/metabase/models/on_demand_test.clj +++ b/test/metabase/models/on_demand_test.clj @@ -14,8 +14,8 @@ [toucan.util.test :as tt])) (defn- do-with-mocked-field-values-updating - "Run F the function responsible for updating FieldValues bound to a mock function that instead just records - the names of Fields that should have been updated. Returns the set of updated Field names." + "Run F the function responsible for updating FieldValues bound to a mock function that instead just records the names + of Fields that should have been updated. Returns the set of updated Field names." {:style/indent 0} [f] (let [updated-field-names (atom #{})] @@ -44,7 +44,7 @@ (tt/with-temp* [Database [db (:db options)] Table [table (merge {:db_id (u/get-id db)} (:table options))] - Field [field (merge {:table_id (u/get-id table), :special_type "type/Category"} + Field [field (merge {:table_id (u/get-id table), :has_field_values "list"} (:field options))]] (do-with-mocked-field-values-updating (fn [updated-field-names] @@ -90,7 +90,7 @@ ;; clear out the list of updated field names (reset! updated-field-names #{}) ;; now Change the Field that is referenced by the Card's SQL param - (tt/with-temp Field [new-field {:table_id (u/get-id table), :special_type "type/Category", :name "New Field"}] + (tt/with-temp Field [new-field {:table_id (u/get-id table), :has_field_values "list", :name "New Field"}] (db/update! Card (u/get-id card) :dataset_query (native-query-with-template-tag new-field)))))) @@ -133,7 +133,7 @@ (do-with-updated-fields-for-card {:db {:is_on_demand false}} (fn [{:keys [table card]}] ;; change the query to one referencing a different Field. Field should not get values since DB is not On-Demand - (tt/with-temp Field [new-field {:table_id (u/get-id table), :special_type "type/Category", :name "New Field"}] + (tt/with-temp Field [new-field {:table_id (u/get-id table), :has_field_values "list", :name "New Field"}] (db/update! Card (u/get-id card) :dataset_query (native-query-with-template-tag new-field)))))) @@ -193,9 +193,9 @@ (do-with-updated-fields-for-dashboard {:db {:is_on_demand true}} (fn [{:keys [table field card dash dashcard updated-field-names]}] ;; create a Dashboard and add a DashboardCard with a param mapping - (tt/with-temp Field [new-field {:table_id (u/get-id table) - :name "New Field" - :special_type "type/Category"}] + (tt/with-temp Field [new-field {:table_id (u/get-id table) + :name "New Field" + :has_field_values "list"}] ;; clear out the list of updated Field Names (reset! updated-field-names #{}) ;; ok, now update the parameter mapping to the new field. The new Field should get new values @@ -219,6 +219,6 @@ #{} (do-with-updated-fields-for-dashboard {:db {:is_on_demand false}} (fn [{:keys [table field card dash dashcard updated-field-names]}] - (tt/with-temp Field [new-field {:table_id (u/get-id table), :special_type "type/Category"}] + (tt/with-temp Field [new-field {:table_id (u/get-id table), :has_field_values "list"}] (dashboard/update-dashcards! dash [(assoc dashcard :parameter_mappings (parameter-mappings-for-card-and-field card new-field))]))))) diff --git a/test/metabase/models/params_test.clj b/test/metabase/models/params_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..c6c9e45898cc934de1d8377ccd96fd35214aa099 --- /dev/null +++ b/test/metabase/models/params_test.clj @@ -0,0 +1,102 @@ +(ns metabase.models.params-test + "Tests for the utility functions for dealing with parameters in `metabase.models.params`." + (:require [expectations :refer :all] + [metabase.api.public-test :as public-test] + [metabase.models + [card :refer [Card]] + [field :refer [Field]]] + [metabase.test.data :as data] + [toucan + [db :as db] + [hydrate :refer [hydrate]]] + [toucan.util.test :as tt])) + +;;; ---------------------------------------------- name_field hydration ---------------------------------------------- + +;; make sure that we can hydrate the `name_field` property for PK Fields +(expect + {:name "ID" + :table_id (data/id :venues) + :special_type :type/PK + :name_field {:id (data/id :venues :name) + :table_id (data/id :venues) + :display_name "Name" + :base_type :type/Text + :special_type :type/Name + :has_field_values :list}} + (-> (db/select-one [Field :name :table_id :special_type], :id (data/id :venues :id)) + (hydrate :name_field))) + +;; make sure it works for multiple fields efficiently. Should only require one DB call to hydrate many Fields +(expect + 1 + (let [venues-fields (db/select Field :table_id (data/id :venues))] + (db/with-call-counting [call-count] + (hydrate venues-fields :name_field) + (call-count)))) + +;; It shouldn't hydrate for Fields that aren't PKs +(expect + {:name "PRICE" + :table_id (data/id :venues) + :special_type :type/Category + :name_field nil} + (-> (db/select-one [Field :name :table_id :special_type], :id (data/id :venues :price)) + (hydrate :name_field))) + +;; Or if it *is* a PK, but no name Field is available for that Table, it shouldn't hydrate +(expect + {:name "ID" + :table_id (data/id :checkins) + :special_type :type/PK + :name_field nil} + (-> (db/select-one [Field :name :table_id :special_type], :id (data/id :checkins :id)) + (hydrate :name_field))) + + +;;; -------------------------------------------------- param_fields -------------------------------------------------- + +;; check that we can hydrate param_fields for a Card +(expect + {(data/id :venues :id) {:id (data/id :venues :id) + :table_id (data/id :venues) + :display_name "ID" + :base_type :type/BigInteger + :special_type :type/PK + :has_field_values :none + :name_field {:id (data/id :venues :name) + :table_id (data/id :venues) + :display_name "Name" + :base_type :type/Text + :special_type :type/Name + :has_field_values :list} + :dimensions []}} + (tt/with-temp Card [card {:dataset_query + {:database (data/id) + :type :native + :native {:query "SELECT COUNT(*) FROM VENUES WHERE {{x}}" + :template_tags {:name {:name :name + :display_name "Name" + :type :dimension + :dimension [:field-id (data/id :venues :id)]}}}}}] + (-> (hydrate card :param_fields) + :param_fields))) + +;; check that we can hydrate param_fields for a Dashboard +(expect + {(data/id :venues :id) {:id (data/id :venues :id) + :table_id (data/id :venues) + :display_name "ID" + :base_type :type/BigInteger + :special_type :type/PK + :has_field_values :none + :name_field {:id (data/id :venues :name) + :table_id (data/id :venues) + :display_name "Name" + :base_type :type/Text + :special_type :type/Name + :has_field_values :list} + :dimensions []}} + (public-test/with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (-> (hydrate dashboard :param_fields) + :param_fields))) diff --git a/test/metabase/permissions_test.clj b/test/metabase/permissions_test.clj index 7acd179c39e89378d8671e363eef8c99a1d3beb4..1625c0f519d45ed818ab3b1dcffb50c763ef6b69 100644 --- a/test/metabase/permissions_test.clj +++ b/test/metabase/permissions_test.clj @@ -488,7 +488,7 @@ (let [results ((test-users/user->client username) :post "dataset" {:database (u/get-id db) :type :native - :native {:query "SELECT COUNT(*) FROM VENUES;"}})] + :native {:query "SELECT COUNT(*) FROM VENUES"}})] (if (string? results) results (or (:error results) diff --git a/test/metabase/pulse/render_test.clj b/test/metabase/pulse/render_test.clj index 43e01a9cf5ac0c26855ec811bf6f0b051c8ed3dc..f59991c0a704357eb7c3b01b0b335f449935ea78 100644 --- a/test/metabase/pulse/render_test.clj +++ b/test/metabase/pulse/render_test.clj @@ -1,5 +1,6 @@ (ns metabase.pulse.render-test - (:require [expectations :refer :all] + (:require [clojure.walk :as walk] + [expectations :refer :all] [hiccup.core :refer [html]] [metabase.pulse.render :as render :refer :all]) (:import java.util.TimeZone)) @@ -7,58 +8,125 @@ (def ^:private pacific-tz (TimeZone/getTimeZone "America/Los_Angeles")) (def ^:private test-columns - [{:name "ID", - :display_name "ID", - :base_type :type/BigInteger - :special_type nil} - {:name "latitude" - :display_name "Latitude" - :base-type :type/Float - :special-type :type/Latitude} - {:name "last_login" - :display_name "Last Login" - :base_type :type/DateTime - :special_type nil} - {:name "name" - :display_name "Name" - :base-type :type/Text - :special_type nil}]) + [{:name "ID", + :display_name "ID", + :base_type :type/BigInteger + :special_type nil + :visibility_type :normal} + {:name "latitude" + :display_name "Latitude" + :base_type :type/Float + :special_type :type/Latitude + :visibility_type :normal} + {:name "last_login" + :display_name "Last Login" + :base_type :type/DateTime + :special_type nil + :visibility_type :normal} + {:name "name" + :display_name "Name" + :base_type :type/Text + :special_type nil + :visibility_type :normal}]) (def ^:private test-data [[1 34.0996 "2014-04-01T08:30:00.0000" "Stout Burgers & Beers"] [2 34.0406 "2014-12-05T15:15:00.0000" "The Apple Pan"] [3 34.0474 "2014-08-01T12:45:00.0000" "The Gorbals"]]) +(defn- col-counts [results] + (set (map (comp count :row) results))) + +(defn- number [x] + (#'render/map->NumericWrapper {:num-str x})) + +(def ^:private default-header-result + [{:row [(number "ID") (number "Latitude") "Last Login" "Name"] + :bar-width nil} + #{4}]) + +(defn- prep-for-html-rendering' + [cols rows bar-column max-value] + (let [results (#'render/prep-for-html-rendering pacific-tz cols rows bar-column max-value (count cols))] + [(first results) + (col-counts results)])) + +(def ^:private description-col {:name "desc_col" + :display_name "Description Column" + :base_type :type/Text + :special_type :type/Description + :visibility_type :normal}) +(def ^:private detail-col {:name "detail_col" + :display_name "Details Column" + :base_type :type/Text + :special_type nil + :visibility_type :details-only}) + +(def ^:private sensitive-col {:name "sensitive_col" + :display_name "Sensitive Column" + :base_type :type/Text + :special_type nil + :visibility_type :sensitive}) + +(def ^:private retired-col {:name "retired_col" + :display_name "Retired Column" + :base_type :type/Text + :special_type nil + :visibility_type :retired}) + ;; Testing the format of headers (expect - {:row ["ID" "LATITUDE" "LAST LOGIN" "NAME"] - :bar-width nil} - (first (#'render/prep-for-html-rendering pacific-tz test-columns test-data nil nil (count test-columns)))) + default-header-result + (prep-for-html-rendering' test-columns test-data nil nil)) + +(expect + default-header-result + (let [cols-with-desc (conj test-columns description-col) + data-with-desc (mapv #(conj % "Desc") test-data)] + (prep-for-html-rendering' cols-with-desc data-with-desc nil nil))) + +(expect + default-header-result + (let [cols-with-details (conj test-columns detail-col) + data-with-details (mapv #(conj % "Details") test-data)] + (prep-for-html-rendering' cols-with-details data-with-details nil nil))) + +(expect + default-header-result + (let [cols-with-sensitive (conj test-columns sensitive-col) + data-with-sensitive (mapv #(conj % "Sensitive") test-data)] + (prep-for-html-rendering' cols-with-sensitive data-with-sensitive nil nil))) + +(expect + default-header-result + (let [columns-with-retired (conj test-columns retired-col) + data-with-retired (mapv #(conj % "Retired") test-data)] + (prep-for-html-rendering' columns-with-retired data-with-retired nil nil))) ;; When including a bar column, bar-width is 99% (expect - {:row ["ID" "LATITUDE" "LAST LOGIN" "NAME"] - :bar-width 99} - (first (#'render/prep-for-html-rendering pacific-tz test-columns test-data second 40.0 (count test-columns)))) + (assoc-in default-header-result [0 :bar-width] 99) + (prep-for-html-rendering' test-columns test-data second 40.0)) ;; When there are too many columns, #'render/prep-for-html-rendering show narrow it (expect - {:row ["ID" "LATITUDE"] - :bar-width 99} - (first (#'render/prep-for-html-rendering pacific-tz test-columns test-data second 40.0 2))) + [{:row [(number "ID") (number "Latitude")] + :bar-width 99} + #{2}] + (prep-for-html-rendering' (subvec test-columns 0 2) test-data second 40.0 )) ;; Basic test that result rows are formatted correctly (dates, floating point numbers etc) (expect - [{:bar-width nil, :row ["1" "34.10" "Apr 1, 2014" "Stout Burgers & Beers"]} - {:bar-width nil, :row ["2" "34.04" "Dec 5, 2014" "The Apple Pan"]} - {:bar-width nil, :row ["3" "34.05" "Aug 1, 2014" "The Gorbals"]}] + [{:bar-width nil, :row [(number "1") (number "34.10") "Apr 1, 2014" "Stout Burgers & Beers"]} + {:bar-width nil, :row [(number "2") (number "34.04") "Dec 5, 2014" "The Apple Pan"]} + {:bar-width nil, :row [(number "3") (number "34.05") "Aug 1, 2014" "The Gorbals"]}] (rest (#'render/prep-for-html-rendering pacific-tz test-columns test-data nil nil (count test-columns)))) ;; Testing the bar-column, which is the % of this row relative to the max of that column (expect - [{:bar-width (float 85.249), :row ["1" "34.10" "Apr 1, 2014" "Stout Burgers & Beers"]} - {:bar-width (float 85.1015), :row ["2" "34.04" "Dec 5, 2014" "The Apple Pan"]} - {:bar-width (float 85.1185), :row ["3" "34.05" "Aug 1, 2014" "The Gorbals"]}] + [{:bar-width (float 85.249), :row [(number "1") (number "34.10") "Apr 1, 2014" "Stout Burgers & Beers"]} + {:bar-width (float 85.1015), :row [(number "2") (number "34.04") "Dec 5, 2014" "The Apple Pan"]} + {:bar-width (float 85.1185), :row [(number "3") (number "34.05") "Aug 1, 2014" "The Gorbals"]}] (rest (#'render/prep-for-html-rendering pacific-tz test-columns test-data second 40 (count test-columns)))) (defn- add-rating @@ -91,15 +159,16 @@ ;; With a remapped column, the header should contain the name of the remapped column (not the original) (expect - {:row ["ID" "LATITUDE" "RATING DESC" "LAST LOGIN" "NAME"] - :bar-width nil} - (first (#'render/prep-for-html-rendering pacific-tz test-columns-with-remapping test-data-with-remapping nil nil (count test-columns-with-remapping)))) + [{:row [(number "ID") (number "Latitude") "Rating Desc" "Last Login" "Name"] + :bar-width nil} + #{5}] + (prep-for-html-rendering' test-columns-with-remapping test-data-with-remapping nil nil)) ;; Result rows should include only the remapped column value, not the original (expect - [["1" "34.10" "Bad" "Apr 1, 2014" "Stout Burgers & Beers"] - ["2" "34.04" "Ok" "Dec 5, 2014" "The Apple Pan"] - ["3" "34.05" "Good" "Aug 1, 2014" "The Gorbals"]] + [[(number "1") (number "34.10") "Bad" "Apr 1, 2014" "Stout Burgers & Beers"] + [(number "2") (number "34.04") "Ok" "Dec 5, 2014" "The Apple Pan"] + [(number "3") (number "34.05") "Good" "Aug 1, 2014" "The Gorbals"]] (map :row (rest (#'render/prep-for-html-rendering pacific-tz test-columns-with-remapping test-data-with-remapping nil nil (count test-columns-with-remapping))))) ;; There should be no truncation warning if the number of rows/cols is fewer than the row/column limit @@ -126,9 +195,9 @@ :special_type :type/DateTime})) (expect - [{:bar-width nil, :row ["1" "34.10" "Apr 1, 2014" "Stout Burgers & Beers"]} - {:bar-width nil, :row ["2" "34.04" "Dec 5, 2014" "The Apple Pan"]} - {:bar-width nil, :row ["3" "34.05" "Aug 1, 2014" "The Gorbals"]}] + [{:bar-width nil, :row [(number "1") (number "34.10") "Apr 1, 2014" "Stout Burgers & Beers"]} + {:bar-width nil, :row [(number "2") (number "34.04") "Dec 5, 2014" "The Apple Pan"]} + {:bar-width nil, :row [(number "3") (number "34.05") "Aug 1, 2014" "The Gorbals"]}] (rest (#'render/prep-for-html-rendering pacific-tz test-columns-with-date-special-type test-data nil nil (count test-columns)))) (defn- render-scalar-value [results] @@ -166,3 +235,56 @@ :base_type :type/DateTime :special_type nil}] :rows [["2014-04-01T08:30:00.0000"]]})) + +(defn- replace-style-maps [hiccup-map] + (walk/postwalk (fn [maybe-map] + (if (and (map? maybe-map) + (contains? maybe-map :style)) + :style-map + maybe-map)) hiccup-map)) + +(def ^:private render-truncation-warning' + (comp replace-style-maps #'render/render-truncation-warning)) + +(expect + nil + (render-truncation-warning' 10 5 20 10)) + +(expect + [:div :style-map + [:div :style-map + "Showing " [:strong :style-map "10"] " of " + [:strong :style-map "11"] " columns."]] + (render-truncation-warning' 10 11 20 10)) + +(expect + [:div + :style-map + [:div :style-map "Showing " + [:strong :style-map "20"] " of " [:strong :style-map "21"] " rows."]] + (render-truncation-warning' 10 5 20 21)) + +(expect + [:div + :style-map + [:div + :style-map + "Showing " + [:strong :style-map "20"] + " of " + [:strong :style-map "21"] + " rows and " + [:strong :style-map "10"] + " of " + [:strong :style-map "11"] + " columns."]] + (render-truncation-warning' 10 11 20 21)) + +(expect + 4 + (count-displayed-columns test-columns)) + +(expect + 4 + (count-displayed-columns + (concat test-columns [description-col detail-col sensitive-col retired-col]))) diff --git a/test/metabase/pulse_test.clj b/test/metabase/pulse_test.clj index 7c4b06ecfaeddabe54957b1af6a5cd3758befd7e..6c92c128d09d38ee2d85e5d853c13010dd176dec 100644 --- a/test/metabase/pulse_test.clj +++ b/test/metabase/pulse_test.clj @@ -1,17 +1,20 @@ (ns metabase.pulse-test - (:require [clojure.walk :as walk] + (:require [clojure.string :as str] + [clojure.walk :as walk] [expectations :refer :all] [medley.core :as m] [metabase.integrations.slack :as slack] [metabase [email-test :as et] - [pulse :refer :all]] + [pulse :refer :all] + [query-processor :as qp]] [metabase.models [card :refer [Card]] [pulse :refer [Pulse retrieve-pulse retrieve-pulse-or-alert]] [pulse-card :refer [PulseCard]] [pulse-channel :refer [PulseChannel]] [pulse-channel-recipient :refer [PulseChannelRecipient]]] + [metabase.pulse.render :as render] [metabase.test [data :as data] [util :as tu]] @@ -21,10 +24,12 @@ [toucan.db :as db] [toucan.util.test :as tt])) +(def ^:private card-name "Test card") + (defn checkins-query "Basic query that will return results for an alert" [query-map] - {:name "Test card" + {:name card-name :dataset_query {:database (data/id) :type :query :query (merge {:source_table (data/id :checkins) @@ -73,6 +78,15 @@ png-attachment]} email))) +(def ^:private csv-attachment + {:type :attachment, :content-type "text/csv", :file-name "Test card.csv", + :content java.net.URL, :description "More results for 'Test card'", :content-id false}) + +(def ^:private xls-attachment + {:type :attachment, :file-name "Test card.xlsx", + :content-type "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + :content java.net.URL, :description "More results for 'Test card'", :content-id false}) + ;; Basic test, 1 card, 1 recipient (expect (rasta-pulse-email) @@ -89,6 +103,72 @@ (send-pulse! (retrieve-pulse pulse-id)) (et/summarize-multipart-email #"Pulse Name")))) +;; Basic test, 1 card, 1 recipient, 21 results results in a CSV being attached and a table being sent +(expect + (rasta-pulse-email {:body [{"Pulse Name" true + "More results have been included" true + "ID</th>" true}, + csv-attachment]}) + (tt/with-temp* [Card [{card-id :id} (checkins-query {:aggregation nil + :limit 21})] + Pulse [{pulse-id :id} {:name "Pulse Name" + :skip_if_empty false}] + PulseCard [_ {:pulse_id pulse-id + :card_id card-id + :position 0}] + PulseChannel [{pc-id :id} {:pulse_id pulse-id}] + PulseChannelRecipient [_ {:user_id (rasta-id) + :pulse_channel_id pc-id}]] + (email-test-setup + (send-pulse! (retrieve-pulse pulse-id)) + (et/summarize-multipart-email #"Pulse Name" #"More results have been included" #"ID</th>")))) + +;; Validate pulse queries are limited by qp/default-query-constraints +(expect + 31 ;; Should return 30 results (the redef'd limit) plus the header row + (tt/with-temp* [Card [{card-id :id} (checkins-query {:aggregation nil})] + Pulse [{pulse-id :id} {:name "Pulse Name" + :skip_if_empty false}] + PulseCard [_ {:pulse_id pulse-id + :card_id card-id + :position 0}] + PulseChannel [{pc-id :id} {:pulse_id pulse-id}] + PulseChannelRecipient [_ {:user_id (rasta-id) + :pulse_channel_id pc-id}]] + (email-test-setup + (with-redefs [qp/default-query-constraints {:max-results 10000 + :max-results-bare-rows 30}] + (send-pulse! (retrieve-pulse pulse-id)) + ;; Slurp in the generated CSV and count the lines found in the file + (-> @et/inbox + vals + ffirst + :body + last + :content + slurp + str/split-lines + count))))) + +;; Basic test, 1 card, 1 recipient, 19 results, so no attachment +(expect + (rasta-pulse-email {:body [{"Pulse Name" true + "More results have been included" false + "ID</th>" true}]}) + (tt/with-temp* [Card [{card-id :id} (checkins-query {:aggregation nil + :limit 19})] + Pulse [{pulse-id :id} {:name "Pulse Name" + :skip_if_empty false}] + PulseCard [_ {:pulse_id pulse-id + :card_id card-id + :position 0}] + PulseChannel [{pc-id :id} {:pulse_id pulse-id}] + PulseChannelRecipient [_ {:user_id (rasta-id) + :pulse_channel_id pc-id}]] + (email-test-setup + (send-pulse! (retrieve-pulse pulse-id)) + (et/summarize-multipart-email #"Pulse Name" #"More results have been included" #"ID</th>")))) + ;; Pulse should be sent to two recipients (expect (into {} (map (fn [user-kwd] @@ -190,10 +270,13 @@ (et/email-to :rasta {:subject subject :body email-body})) +(def ^:private test-card-result {card-name true}) +(def ^:private test-card-regex (re-pattern card-name)) + ;; Rows alert with data (expect (rasta-alert-email "Metabase alert: Test card has results" - [{"Test card.*has results for you to see" true}, png-attachment]) + [(assoc test-card-result "More results have been included" false), png-attachment]) (tt/with-temp* [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})] Pulse [{pulse-id :id} {:alert_condition "rows" :alert_first_only false}] @@ -201,16 +284,37 @@ :card_id card-id :position 0}] PulseChannel [{pc-id :id} {:pulse_id pulse-id}] - PulseChannelRecipient [_ {:user_id (rasta-id) + PulseChannelRecipient [_ {:user_id (rasta-id) + :pulse_channel_id pc-id}]] + (email-test-setup + (send-pulse! (retrieve-pulse-or-alert pulse-id)) + (et/summarize-multipart-email test-card-regex #"More results have been included")))) + +;; Rows alert with too much data will attach as CSV and include a table +(expect + (rasta-alert-email "Metabase alert: Test card has results" + [(merge test-card-result + {"More results have been included" true + "ID</th>" true}), + csv-attachment]) + (tt/with-temp* [Card [{card-id :id} (checkins-query {:limit 21 + :aggregation nil})] + Pulse [{pulse-id :id} {:alert_condition "rows" + :alert_first_only false}] + PulseCard [_ {:pulse_id pulse-id + :card_id card-id + :position 0}] + PulseChannel [{pc-id :id} {:pulse_id pulse-id}] + PulseChannelRecipient [_ {:user_id (rasta-id) :pulse_channel_id pc-id}]] (email-test-setup (send-pulse! (retrieve-pulse-or-alert pulse-id)) - (et/summarize-multipart-email #"Test card.*has results for you to see")))) + (et/summarize-multipart-email test-card-regex #"More results have been included" #"ID</th>")))) ;; Above goal alert with data (expect (rasta-alert-email "Metabase alert: Test card has reached its goal" - [{"Test card.*has reached its goal" true}, png-attachment]) + [test-card-result, png-attachment]) (tt/with-temp* [Card [{card-id :id} (merge (checkins-query {:filter ["between",["field-id" (data/id :checkins :date)],"2014-04-01" "2014-06-01"] :breakout [["datetime-field" (data/id :checkins :date) "day"]]}) {:display :line @@ -226,12 +330,12 @@ :pulse_channel_id pc-id}]] (email-test-setup (send-pulse! (retrieve-pulse-or-alert pulse-id)) - (et/summarize-multipart-email #"Test card.*has reached its goal")))) + (et/summarize-multipart-email test-card-regex)))) ;; Native query with user-specified x and y axis (expect (rasta-alert-email "Metabase alert: Test card has reached its goal" - [{"Test card.*has reached its goal" true}, png-attachment]) + [test-card-result, png-attachment]) (tt/with-temp* [Card [{card-id :id} {:name "Test card" :dataset_query {:database (data/id) :type :native @@ -254,7 +358,7 @@ :pulse_channel_id pc-id}]] (email-test-setup (send-pulse! (retrieve-pulse-or-alert pulse-id)) - (et/summarize-multipart-email #"Test card.*has reached its goal")))) + (et/summarize-multipart-email test-card-regex)))) ;; Above goal alert, with no data above goal (expect @@ -299,7 +403,7 @@ ;; Below goal alert with data (expect (rasta-alert-email "Metabase alert: Test card has gone below its goal" - [{"Test card.*has gone below its goal of 1.1" true}, png-attachment]) + [test-card-result, png-attachment]) (tt/with-temp* [Card [{card-id :id} (merge (checkins-query {:filter ["between",["field-id" (data/id :checkins :date)],"2014-02-12" "2014-02-17"] :breakout [["datetime-field" (data/id :checkins :date) "day"]]}) {:display :line @@ -316,12 +420,48 @@ (email-test-setup (send-pulse! (retrieve-pulse-or-alert pulse-id)) - (et/summarize-multipart-email #"Test card.*has gone below its goal of 1.1")))) + (et/summarize-multipart-email test-card-regex)))) (defn- thunk->boolean [{:keys [attachments] :as result}] (assoc result :attachments (for [attachment-info attachments] (update attachment-info :attachment-bytes-thunk fn?)))) +(defprotocol WrappedFunction + (input [_]) + (output [_])) + +(defn- invoke-with-wrapping + "Apply `args` to `func`, capturing the arguments of the invocation and the result of the invocation. Store the arguments in + `input-atom` and the result in `output-atom`." + [input-atom output-atom func args] + (swap! input-atom conj args) + (let [result (apply func args)] + (swap! output-atom conj result) + result)) + +(defn- wrap-function + "Return a function that wraps `func`, not interfering with it but recording it's input and output, which is + available via the `input` function and `output`function that can be used directly on this object" + [func] + (let [input (atom nil) + output (atom nil)] + (reify WrappedFunction + (input [_] @input) + (output [_] @output) + clojure.lang.IFn + (invoke [_ x1] + (invoke-with-wrapping input output func [x1])) + (invoke [_ x1 x2] + (invoke-with-wrapping input output func [x1 x2])) + (invoke [_ x1 x2 x3] + (invoke-with-wrapping input output func [x1 x2 x3])) + (invoke [_ x1 x2 x3 x4] + (invoke-with-wrapping input output func [x1 x2 x3 x4])) + (invoke [_ x1 x2 x3 x4 x5] + (invoke-with-wrapping input output func [x1 x2 x3 x4 x5])) + (invoke [_ x1 x2 x3 x4 x5 x6] + (invoke-with-wrapping input output func [x1 x2 x3 x4 x5 x6]))))) + ;; Basic slack test, 1 card, 1 recipient channel (tt/expect-with-temp [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})] Pulse [{pulse-id :id} {:name "Pulse Name" @@ -335,17 +475,58 @@ {:channel-id "#general", :message "Pulse: Pulse Name", :attachments - [{:title "Test card", - :attachment-bytes-thunk true + [{:title card-name, + :attachment-bytes-thunk true, :title_link (str "https://metabase.com/testmb/question/" card-id), :attachment-name "image.png", :channel-id "FOO", - :fallback "Test card"}]} + :fallback card-name}]} (slack-test-setup (-> (send-pulse! (retrieve-pulse pulse-id)) first thunk->boolean))) +(defn- force-bytes-thunk + "Grabs the thunk that produces the image byte array and invokes it" + [results] + ((-> results + :attachments + first + :attachment-bytes-thunk))) + +;; Basic slack test, 1 card, 1 recipient channel, verifies that "more results in attachment" text is not present for +;; slack pulses +(tt/expect-with-temp [Card [{card-id :id} (checkins-query {:aggregation nil + :limit 25})] + Pulse [{pulse-id :id} {:name "Pulse Name" + :skip_if_empty false}] + PulseCard [_ {:pulse_id pulse-id + :card_id card-id + :position 0}] + PulseChannel [{pc-id :id} {:pulse_id pulse-id + :channel_type "slack" + :details {:channel "#general"}}]] + [{:channel-id "#general", + :message "Pulse: Pulse Name", + :attachments + [{:title card-name, + :attachment-bytes-thunk true + :title_link (str "https://metabase.com/testmb/question/" card-id), + :attachment-name "image.png", + :channel-id "FOO", + :fallback card-name}]} + 1 ;; -> attached-results-text should be invoked exactly once + [nil] ;; -> attached-results-text should return nil since it's a slack message + ] + (slack-test-setup + (with-redefs [render/attached-results-text (wrap-function (var-get #'render/attached-results-text))] + (let [[pulse-results] (send-pulse! (retrieve-pulse pulse-id))] + ;; If we don't force the thunk, the rendering code will never execute and attached-results-text won't be called + (force-bytes-thunk pulse-results) + [(thunk->boolean pulse-results) + (count (input (var-get #'render/attached-results-text))) + (output (var-get #'render/attached-results-text))])))) + (defn- produces-bytes? [{:keys [attachment-bytes-thunk]}] (< 0 (alength (attachment-bytes-thunk)))) @@ -368,12 +549,12 @@ [{:channel-id "#general", :message "Pulse: Pulse Name", :attachments - [{:title "Test card", - :attachment-bytes-thunk true + [{:title card-name, + :attachment-bytes-thunk true, :title_link (str "https://metabase.com/testmb/question/" card-id-1), :attachment-name "image.png", :channel-id "FOO", - :fallback "Test card"} + :fallback card-name} {:title "Test card 2", :attachment-bytes-thunk true :title_link (str "https://metabase.com/testmb/question/" card-id-2), @@ -413,10 +594,10 @@ :pulse_channel_id pc-id-2}]] [{:channel-id "#general", :message "Pulse: Pulse Name", - :attachments [{:title "Test card", :attachment-bytes-thunk true + :attachments [{:title card-name, :attachment-bytes-thunk true :title_link (str "https://metabase.com/testmb/question/" card-id), :attachment-name "image.png", :channel-id "FOO", - :fallback "Test card"}]} + :fallback card-name}]} true {:subject "Pulse: Pulse Name", :recipients ["rasta@metabase.com"], @@ -447,10 +628,10 @@ :details {:channel "#general"}}]] [{:channel-id "#general", :message "Alert: Test card", - :attachments [{:title "Test card", :attachment-bytes-thunk true, + :attachments [{:title card-name, :attachment-bytes-thunk true, :title_link (str "https://metabase.com/testmb/question/" card-id) :attachment-name "image.png", :channel-id "FOO", - :fallback "Test card"}]} + :fallback card-name}]} true] (slack-test-setup (let [[result] (send-pulse! (retrieve-pulse-or-alert pulse-id))] @@ -458,7 +639,7 @@ (every? produces-bytes? (:attachments result))]))) (defn- venues-query [aggregation-op] - {:name "Test card" + {:name card-name :dataset_query {:database (data/id) :type :query :query {:source_table (data/id :venues) @@ -467,7 +648,7 @@ ;; Above goal alert with a progress bar (expect (rasta-alert-email "Metabase alert: Test card has reached its goal" - [{"Test card.*has reached its goal of 3" true}]) + [test-card-result]) (tt/with-temp* [Card [{card-id :id} (merge (venues-query "max") {:display :progress :visualization_settings {:progress.goal 3}})] @@ -482,12 +663,12 @@ :pulse_channel_id pc-id}]] (email-test-setup (send-pulse! (retrieve-pulse-or-alert pulse-id)) - (et/summarize-multipart-email #"Test card.*has reached its goal of 3")))) + (et/summarize-multipart-email test-card-regex)))) ;; Below goal alert with progress bar (expect (rasta-alert-email "Metabase alert: Test card has gone below its goal" - [{"Test card.*has gone below its goal of 2" true}]) + [test-card-result]) (tt/with-temp* [Card [{card-id :id} (merge (venues-query "min") {:display :progress :visualization_settings {:progress.goal 2}})] @@ -502,13 +683,12 @@ :pulse_channel_id pc-id}]] (email-test-setup (send-pulse! (retrieve-pulse-or-alert pulse-id)) - (et/summarize-multipart-email #"Test card.*has gone below its goal of 2")))) + (et/summarize-multipart-email test-card-regex)))) ;; Rows alert, first run only with data (expect (rasta-alert-email "Metabase alert: Test card has results" - [{"Test card.*has results for you to see" true - "stop sending you alerts" true} + [(assoc test-card-result "stop sending you alerts" true) png-attachment]) (tt/with-temp* [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})] Pulse [{pulse-id :id} {:alert_condition "rows" @@ -521,7 +701,7 @@ :pulse_channel_id pc-id}]] (email-test-setup (send-pulse! (retrieve-pulse-or-alert pulse-id)) - (et/summarize-multipart-email #"Test card.*has results for you to see" + (et/summarize-multipart-email test-card-regex #"stop sending you alerts")))) ;; First run alert with no data @@ -542,15 +722,6 @@ [@et/inbox (db/exists? Pulse :id pulse-id)]))) -(def ^:private csv-attachment - {:type :attachment, :content-type "text/csv", :file-name "Test card.csv", - :content java.net.URL, :description "Full results for 'Test card'", :content-id false}) - -(def ^:private xls-attachment - {:type :attachment, :file-name "Test card.xlsx", - :content-type "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - :content java.net.URL, :description "Full results for 'Test card'", :content-id false}) - (defn- add-rasta-attachment "Append `ATTACHMENT` to the first email found for Rasta" [email attachment] @@ -577,7 +748,7 @@ ;; Basic alert test, 1 card, 1 recipient, with CSV attachment (expect (rasta-alert-email "Metabase alert: Test card has results" - [{"Test card.*has results for you to see" true}, png-attachment, csv-attachment]) + [test-card-result, png-attachment, csv-attachment]) (tt/with-temp* [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})] Pulse [{pulse-id :id} {:alert_condition "rows" :alert_first_only false}] @@ -590,7 +761,7 @@ :pulse_channel_id pc-id}]] (email-test-setup (send-pulse! (retrieve-pulse-or-alert pulse-id)) - (et/summarize-multipart-email #"Test card.*has results for you to see")))) + (et/summarize-multipart-email test-card-regex)))) ;; Basic test of card with CSV and XLS attachments, but no data. Should not include an attachment (expect @@ -633,7 +804,7 @@ ;; Rows alert with data and a CSV + XLS attachment (expect (rasta-alert-email "Metabase alert: Test card has results" - [{"Test card.*has results for you to see" true}, png-attachment, csv-attachment, xls-attachment]) + [test-card-result, png-attachment, csv-attachment, xls-attachment]) (tt/with-temp* [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})] Pulse [{pulse-id :id} {:alert_condition "rows" :alert_first_only false}] @@ -647,4 +818,4 @@ :pulse_channel_id pc-id}]] (email-test-setup (send-pulse! (retrieve-pulse-or-alert pulse-id)) - (et/summarize-multipart-email #"Test card.*has results for you to see")))) + (et/summarize-multipart-email test-card-regex)))) diff --git a/test/metabase/query_processor/expand_resolve_test.clj b/test/metabase/query_processor/expand_resolve_test.clj index ec2f42503df3e2b1ff005786bb70997d3a055783..5bd8b51f0a03a1fcc2204fda45558bb83d4b535b 100644 --- a/test/metabase/query_processor/expand_resolve_test.clj +++ b/test/metabase/query_processor/expand_resolve_test.clj @@ -235,7 +235,9 @@ :special-type nil :table-id (id :users) :table-name "USERS__via__USER_ID" - :fingerprint {:global {:distinct-count 11}}}) + :fingerprint {:global {:distinct-count 11} + :type {:type/DateTime {:earliest "2014-01-01T00:00:00.000Z" + :latest "2014-12-05T00:00:00.000Z"}}}}) :unit :year} :value {:value (u/->Timestamp "1980-01-01") :field {:field @@ -250,7 +252,9 @@ :visibility-type :normal :table-id (id :users) :table-name "USERS__via__USER_ID" - :fingerprint {:global {:distinct-count 11}}}) + :fingerprint {:global {:distinct-count 11} + :type {:type/DateTime {:earliest "2014-01-01T00:00:00.000Z" + :latest "2014-12-05T00:00:00.000Z"}}}}) :unit :year}}} :join-tables [{:source-field {:field-id (id :checkins :user_id) :field-name "USER_ID"} @@ -314,7 +318,9 @@ :field-id true :table-name "CHECKINS" :schema-name "PUBLIC" - :fingerprint {:global {:distinct-count 618}}}) + :fingerprint {:global {:distinct-count 618} + :type {:type/DateTime {:earliest "2013-01-03T00:00:00.000Z" + :latest "2015-12-29T00:00:00.000Z"}}}}) :unit :day-of-week}] :join-tables [{:source-field {:field-id true :field-name "VENUE_ID"} diff --git a/test/metabase/query_processor/middleware/expand_macros_test.clj b/test/metabase/query_processor/middleware/expand_macros_test.clj index f3496b5e3d18510e017aa067aae7ef47bb0f9bc5..dccea28fb9080f2c8bc80d537b5e45529edd7bff 100644 --- a/test/metabase/query_processor/middleware/expand_macros_test.clj +++ b/test/metabase/query_processor/middleware/expand_macros_test.clj @@ -188,3 +188,8 @@ (expect {:query {:aggregation [[:metric :gaid:users]]}} (#'expand-macros/expand-metrics-and-segments {:query {:aggregation [[:metric :gaid:users]]}})) + +;; make sure expansion works with multiple GA "metrics" (#7399) +(expect + {:query {:aggregation [[:METRIC :ga:users] [:METRIC :ga:1dayUsers]]}} + (#'expand-macros/expand-metrics-and-segments {:query {:aggregation [[:METRIC :ga:users] [:METRIC :ga:1dayUsers]]}})) diff --git a/test/metabase/query_processor/middleware/fetch_source_query_test.clj b/test/metabase/query_processor/middleware/fetch_source_query_test.clj index 9d254e0d34b524583965be937091b53ec73471ab..569716cf3bed99fc342498a6b88ae9765b6985ec 100644 --- a/test/metabase/query_processor/middleware/fetch_source_query_test.clj +++ b/test/metabase/query_processor/middleware/fetch_source_query_test.clj @@ -52,7 +52,8 @@ qp/expand (m/dissoc-in [:database :features]) (m/dissoc-in [:database :details]) - (m/dissoc-in [:database :timezone]))) + (m/dissoc-in [:database :timezone]) + (dissoc :driver))) (defn default-expanded-results [query] {:database {:name "test-data", :id (data/id), :engine :h2} @@ -60,7 +61,8 @@ :fk-field-ids #{} :query query}) -;; test that the `metabase.query-processor/expand` function properly handles nested queries (this function should call `fetch-source-query`) +;; test that the `metabase.query-processor/expand` function properly handles nested queries (this function should call +;; `fetch-source-query`) (expect (default-expanded-results {:source-query {:source-table {:schema "PUBLIC", :name "VENUES", :id (data/id :venues)} diff --git a/test/metabase/query_processor/middleware/format_rows_test.clj b/test/metabase/query_processor/middleware/format_rows_test.clj index c504eb58451e0ed74109ab708db8f4834a46cd83..7653a264916b0ff5293b13a4b2b6b2b8d46eb54a 100644 --- a/test/metabase/query_processor/middleware/format_rows_test.clj +++ b/test/metabase/query_processor/middleware/format_rows_test.clj @@ -8,7 +8,7 @@ [dataset-definitions :as defs] [datasets :refer [*engine*]]])) -(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift :presto} +(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift :presto :sparksql} (if (= :sqlite *engine*) [[1 "Plato Yeshua" "2014-04-01 00:00:00" "08:30:00"] [2 "Felipinho Asklepios" "2014-12-05 00:00:00" "15:15:00"] @@ -27,7 +27,7 @@ (ql/limit 5))) qpt/rows)) -(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift :presto} +(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift :presto :sparksql} (cond (= :sqlite *engine*) [[1 "Plato Yeshua" "2014-04-01 00:00:00" "08:30:00"] diff --git a/test/metabase/query_processor/middleware/parameters/mbql_test.clj b/test/metabase/query_processor/middleware/parameters/mbql_test.clj index 4d9c72018f29744453e575f3cb2858cd37ba010f..5193b192a54f88706d8fa9f19b6f73485d586f24 100644 --- a/test/metabase/query_processor/middleware/parameters/mbql_test.clj +++ b/test/metabase/query_processor/middleware/parameters/mbql_test.clj @@ -1,20 +1,14 @@ (ns metabase.query-processor.middleware.parameters.mbql-test "Tests for *MBQL* parameter substitution." (:require [expectations :refer :all] - [honeysql.core :as hsql] [metabase [query-processor :as qp] [query-processor-test :refer [first-row format-rows-by non-timeseries-engines rows]] [util :as u]] - [metabase.driver.generic-sql :as sql] - [metabase.models - [field :refer [Field]] - [table :refer [Table]]] [metabase.query-processor.middleware.expand :as ql] - [metabase.query-processor.middleware.parameters.mbql :refer :all] + [metabase.query-processor.middleware.parameters.mbql :as mbql-params :refer :all] [metabase.test.data :as data] - [metabase.test.data.datasets :as datasets] - [metabase.util.honeysql-extensions :as hx])) + [metabase.test.data.datasets :as datasets])) (defn- expand-parameters [query] (expand (dissoc query :parameters) (:parameters query))) @@ -24,7 +18,7 @@ (expect {:database 1 :type :query - :query {:filter [:= ["field-id" 123] "666"] + :query {:filter [:= ["field-id" (data/id :venues :name)] "Cam's Toucannery"] :breakout [17]}} (expand-parameters {:database 1 :type :query @@ -32,35 +26,40 @@ :parameters [{:hash "abc123" :name "foo" :type "id" - :target ["dimension" ["field-id" 123]] - :value "666"}]})) + :target ["dimension" ["field-id" (data/id :venues :name)]] + :value "Cam's Toucannery"}]})) ;; multiple filters are conjoined by an "AND" (expect - {:database 1 - :type :query - :query {:filter ["AND" ["AND" ["AND" ["=" 456 12]] [:= ["field-id" 123] "666"]] [:= ["field-id" 456] "999"]] - :breakout [17]}} + {:database 1 + :type :query + :query {:filter ["AND" + ["AND" + ["AND" + ["=" (data/id :venues :id) 12]] + [:= ["field-id" (data/id :venues :name)] "Cam's Toucannery"]] + [:= ["field-id" (data/id :venues :id)] 999]] + :breakout [17]}} (expand-parameters {:database 1 :type :query - :query {:filter ["AND" ["=" 456 12]] + :query {:filter ["AND" ["=" (data/id :venues :id) 12]] :breakout [17]} :parameters [{:hash "abc123" :name "foo" :type "id" - :target ["dimension" ["field-id" 123]] - :value "666"} + :target ["dimension" ["field-id" (data/id :venues :name)]] + :value "Cam's Toucannery"} {:hash "def456" :name "bar" :type "category" - :target ["dimension" ["field-id" 456]] - :value "999"}]})) + :target ["dimension" ["field-id" (data/id :venues :id)]] + :value 999}]})) ;; date range parameters (expect {:database 1 :type :query - :query {:filter ["TIME_INTERVAL" ["field-id" 123] -30 "day" {:include-current false}] + :query {:filter ["TIME_INTERVAL" ["field-id" (data/id :users :last_login)] -30 "day" {:include-current false}] :breakout [17]}} (expand-parameters {:database 1 :type :query @@ -68,13 +67,13 @@ :parameters [{:hash "abc123" :name "foo" :type "date" - :target ["dimension" ["field-id" 123]] + :target ["dimension" ["field-id" (data/id :users :last_login)]] :value "past30days"}]})) (expect {:database 1 :type :query - :query {:filter ["TIME_INTERVAL" ["field-id" 123] -30 "day" {:include-current true}] + :query {:filter ["TIME_INTERVAL" ["field-id" (data/id :users :last_login)] -30 "day" {:include-current true}] :breakout [17]}} (expand-parameters {:database 1 :type :query @@ -82,13 +81,13 @@ :parameters [{:hash "abc123" :name "foo" :type "date" - :target ["dimension" ["field-id" 123]] + :target ["dimension" ["field-id" (data/id :users :last_login)]] :value "past30days~"}]})) (expect {:database 1 :type :query - :query {:filter ["=" ["field-id" 123] ["relative_datetime" -1 "day"]] + :query {:filter ["=" ["field-id" (data/id :users :last_login)] ["relative_datetime" -1 "day"]] :breakout [17]}} (expand-parameters {:database 1 :type :query @@ -96,13 +95,13 @@ :parameters [{:hash "abc123" :name "foo" :type "date" - :target ["dimension" ["field-id" 123]] + :target ["dimension" ["field-id" (data/id :users :last_login)]] :value "yesterday"}]})) (expect {:database 1 :type :query - :query {:filter ["BETWEEN" ["field-id" 123] "2014-05-10" "2014-05-16"] + :query {:filter ["BETWEEN" ["field-id" (data/id :users :last_login)] "2014-05-10" "2014-05-16"] :breakout [17]}} (expand-parameters {:database 1 :type :query @@ -110,14 +109,14 @@ :parameters [{:hash "abc123" :name "foo" :type "date" - :target ["dimension" ["field-id" 123]] + :target ["dimension" ["field-id" (data/id :users :last_login)]] :value "2014-05-10~2014-05-16"}]})) -;;; +-------------------------------------------------------------------------------------------------------+ -;;; | END-TO-END TESTS | -;;; +-------------------------------------------------------------------------------------------------------+ +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | END-TO-END TESTS | +;;; +----------------------------------------------------------------------------------------------------------------+ ;; for some reason param substitution tests fail on Redshift & (occasionally) Crate so just don't run those for now (def ^:private ^:const params-test-engines (disj non-timeseries-engines :redshift :crate)) @@ -246,3 +245,12 @@ :value ["2014-06" "2015-06"]}]))] (-> (qp/process-query outer-query) :data :native_form))) + +;; make sure that "ID" type params get converted to numbers when appropriate +(expect + [:= ["field-id" (data/id :venues :id)] 1] + (#'mbql-params/build-filter-clause {:type "id" + :target ["dimension" ["field-id" (data/id :venues :id)]] + :slug "venue_id" + :value "1" + :name "Venue ID"})) diff --git a/test/metabase/query_processor/middleware/parameters/sql_test.clj b/test/metabase/query_processor/middleware/parameters/sql_test.clj index 91efbb7834ffcf9560770b0a91a50b3478f58dc5..3b3016fd793de8edc4c0f68fcf83566e02002b55 100644 --- a/test/metabase/query_processor/middleware/parameters/sql_test.clj +++ b/test/metabase/query_processor/middleware/parameters/sql_test.clj @@ -15,41 +15,89 @@ ;;; ------------------------------------------ basic parser tests ------------------------------------------ +(defn- parse-template + ([sql] + (parse-template sql {})) + ([sql param-key->value] + (binding [metabase.query-processor.middleware.parameters.sql/*driver* (driver/engine->driver :h2)] + (#'sql/parse-template sql param-key->value)))) + +(expect + {:query "select * from foo where bar=1" + :params []} + (parse-template "select * from foo where bar=1")) + (expect - [:SQL "select * from foo where bar=1"] - (#'sql/sql-template-parser "select * from foo where bar=1")) + {:query "select * from foo where bar=?" + :params ["foo"]} + (parse-template "select * from foo where bar={{baz}}" {:baz "foo"})) (expect - [:SQL "select * from foo where bar=" [:PARAM "baz"]] - (#'sql/sql-template-parser "select * from foo where bar={{baz}}")) + {:query "select * from foo where bar = ?" + :params ["foo"]} + (parse-template "select * from foo [[where bar = {{baz}} ]]" {:baz "foo"})) +;; Multiple optional clauses, all present (expect - [:SQL "select * from foo " [:OPTIONAL "where bar = " [:PARAM "baz"] " "]] - (#'sql/sql-template-parser "select * from foo [[where bar = {{baz}} ]]")) + {:query "select * from foo where bar1 = ? and bar2 = ? and bar3 = ? and bar4 = ?" + :params (repeat 4 "foo")} + (parse-template (str "select * from foo where bar1 = {{baz}} " + "[[and bar2 = {{baz}}]] " + "[[and bar3 = {{baz}}]] " + "[[and bar4 = {{baz}}]]") + {:baz "foo"})) +;; Multiple optional clauses, none present (expect - [:SQL "select * from foobars " - [:OPTIONAL " where foobars.id in (string_to_array(" [:PARAM "foobar_id"] ", ',')::integer" "[" "]" ") "]] - (#'sql/sql-template-parser "select * from foobars [[ where foobars.id in (string_to_array({{foobar_id}}, ',')::integer[]) ]]")) + {:query "select * from foo where bar1 = ?" + :params ["foo"]} + (parse-template (str "select * from foo where bar1 = {{baz}} " + "[[and bar2 = {{none}}]] " + "[[and bar3 = {{none}}]] " + "[[and bar4 = {{none}}]]") + {:baz "foo"})) (expect - [:SQL - "SELECT " "[" "test_data.checkins.venue_id" "]" " AS " "[" "venue_id" "]" - ", " "[" "test_data.checkins.user_id" "]" " AS " "[" "user_id" "]" - ", " "[" "test_data.checkins.id" "]" " AS " "[" "checkins_id" "]" - " FROM " "[" "test_data.checkins" "]" " LIMIT 2"] - (-> (str "SELECT [test_data.checkins.venue_id] AS [venue_id], " - " [test_data.checkins.user_id] AS [user_id], " - " [test_data.checkins.id] AS [checkins_id] " - "FROM [test_data.checkins] " - "LIMIT 2") - (#'sql/sql-template-parser) - (update 1 #(apply str %)))) + {:query "select * from foobars where foobars.id in (string_to_array(?, ',')::integer[])" + :params ["foo"]} + (parse-template "select * from foobars [[ where foobars.id in (string_to_array({{foobar_id}}, ',')::integer[]) ]]" + {:foobar_id "foo"})) + +(expect + {:query (str "SELECT [test_data.checkins.venue_id] AS [venue_id], " + " [test_data.checkins.user_id] AS [user_id], " + " [test_data.checkins.id] AS [checkins_id] " + "FROM [test_data.checkins] " + "LIMIT 2") + :params []} + (parse-template (str "SELECT [test_data.checkins.venue_id] AS [venue_id], " + " [test_data.checkins.user_id] AS [user_id], " + " [test_data.checkins.id] AS [checkins_id] " + "FROM [test_data.checkins] " + "LIMIT 2"))) ;; Valid syntax in PG (expect - [:SQL "SELECT array_dims(1 || '" "[" "0:1" "]" "=" "{" "2,3" "}" "'::int" "[" "]" ")"] - (#'sql/sql-template-parser "SELECT array_dims(1 || '[0:1]={2,3}'::int[])")) + {:query "SELECT array_dims(1 || '[0:1]={2,3}'::int[])" + :params []} + (parse-template "SELECT array_dims(1 || '[0:1]={2,3}'::int[])")) + +;; Testing that invalid/unterminated template params/clauses throw an exception +(expect + java.lang.IllegalArgumentException + (parse-template "select * from foo [[where bar = {{baz}} " {:baz "foo"})) + +(expect + java.lang.IllegalArgumentException + (parse-template "select * from foo [[where bar = {{baz]]" {:baz "foo"})) + +(expect + java.lang.IllegalArgumentException + (parse-template "select * from foo {{bar}} {{baz" {:bar "foo" :baz "foo"})) + +(expect + java.lang.IllegalArgumentException + (parse-template "select * from foo [[clause 1 {{bar}}]] [[clause 2" {:bar "foo"})) ;;; ------------------------------------------ simple substitution -- {{x}} ------------------------------------------ diff --git a/test/metabase/query_processor_test.clj b/test/metabase/query_processor_test.clj index cf5ed299b62bea9c546e2c62d3978696ee3e6039..51218c47c5004658c45d7bee61b36ae6f248db9c 100644 --- a/test/metabase/query_processor_test.clj +++ b/test/metabase/query_processor_test.clj @@ -155,7 +155,9 @@ :name (data/format-name "last_login") :display_name "Last Login" :unit :default - :fingerprint {:global {:distinct-count 11}}}))) + :fingerprint {:global {:distinct-count 11} + :type {:type/DateTime {:earliest "2014-01-01T00:00:00.000Z" + :latest "2014-12-05T00:00:00.000Z"}}}}))) ;; #### venues (defn venues-columns @@ -230,9 +232,8 @@ {:target_table_id (data/id :venues)} {}) :target (target-field (venues-col :id)) - :special_type (if (data/fks-supported?) - :type/FK - :type/Category) + :special_type (when (data/fks-supported?) + :type/FK) :base_type (data/expected-base-type->actual :type/Integer) :name (data/format-name "venue_id") :display_name "Venue ID" diff --git a/test/metabase/query_processor_test/date_bucketing_test.clj b/test/metabase/query_processor_test/date_bucketing_test.clj index 1b5d73eee80e9a6ff571a186b572c2a3b21248a6..8151678a19c39c0418d85214f7c229d840147150 100644 --- a/test/metabase/query_processor_test/date_bucketing_test.clj +++ b/test/metabase/query_processor_test/date_bucketing_test.clj @@ -189,7 +189,7 @@ ;; ;; The exclusions here are databases that give incorrect answers when ;; the JVM timezone doesn't match the databases timezone -(expect-with-non-timeseries-dbs-except #{:h2 :sqlserver :redshift :mongo} +(expect-with-non-timeseries-dbs-except #{:h2 :sqlserver :redshift :sparksql :mongo} (cond (contains? #{:sqlite :crate} *engine*) (sad-toucan-result (source-date-formatter utc-tz) result-date-formatter-without-tz) @@ -477,7 +477,7 @@ ;; ;; The exclusions here are databases that give incorrect answers when ;; the JVM timezone doesn't match the databases timezone -(expect-with-non-timeseries-dbs-except #{:h2 :sqlserver :redshift :mongo} +(expect-with-non-timeseries-dbs-except #{:h2 :sqlserver :redshift :sparksql :mongo} (cond (contains? #{:sqlite :crate} *engine*) (results-by-day date-formatter-without-time @@ -674,7 +674,7 @@ ;; ;; The exclusions here are databases that give incorrect answers when ;; the JVM timezone doesn't match the databases timezone -(expect-with-non-timeseries-dbs-except #{:h2 :sqlserver :redshift :mongo} +(expect-with-non-timeseries-dbs-except #{:h2 :sqlserver :redshift :sparksql :mongo} (cond (contains? #{:sqlite :crate} *engine*) (results-by-week date-formatter-without-time @@ -708,7 +708,7 @@ ;; Not really sure why different drivers have different opinions on these </3 (cond - (contains? #{:sqlserver :sqlite :crate :oracle} *engine*) + (contains? #{:sqlserver :sqlite :crate :oracle :sparksql} *engine*) [[23 54] [24 46] [25 39] [26 61]] (and (supports-report-timezone? *engine*) diff --git a/test/metabase/query_processor_test/filter_test.clj b/test/metabase/query_processor_test/filter_test.clj index 536d1bef4ff443236e872f6caabafbaa357148cc..4ef5759b2ec1f51078f341fd6874e6c68f0f90b6 100644 --- a/test/metabase/query_processor_test/filter_test.clj +++ b/test/metabase/query_processor_test/filter_test.clj @@ -130,6 +130,19 @@ (ql/aggregation (ql/count)) (ql/filter (ql/not-null $date)))))) +;; Creates a query that uses a field-literal. Normally our test queries will use a field placeholder, but +;; https://github.com/metabase/metabase/issues/7381 is only triggered by a field literal +(expect-with-non-timeseries-dbs + [1000] + (let [vec-filter #(assoc % :filter ["NOT_NULL" + ["field-id" + ["field-literal" (data/format-name "date") "type/DateTime"]]])] + (first-row + (format-rows-by [int] + (data/run-query checkins + (ql/aggregation (ql/count)) + vec-filter))))) + (expect-with-non-timeseries-dbs true (let [result (first-row (data/run-query checkins diff --git a/test/metabase/query_processor_test/nested_queries_test.clj b/test/metabase/query_processor_test/nested_queries_test.clj index fcd817f818b4bc5d112c02b814fcd63d19606f42..25d00276b94b8a9f77ed13ceae8d7ff4fc169d55 100644 --- a/test/metabase/query_processor_test/nested_queries_test.clj +++ b/test/metabase/query_processor_test/nested_queries_test.clj @@ -21,7 +21,8 @@ [util :as tu]] [metabase.test.data [datasets :as datasets] - [users :refer [user->client]]] + [dataset-definitions :as defs] + [users :refer [create-users-if-needed! user->client]]] [toucan.db :as db] [toucan.util.test :as tt])) @@ -190,6 +191,32 @@ :aggregation [:count] :breakout [[:field-literal (keyword (data/format-name :price)) :type/Integer]])))))) +;; Ensure trailing comments are trimmed and don't cause a wrapping SQL query to fail +(expect + breakout-results + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT * FROM VENUES -- small comment here"}}}] + (rows+cols + (format-rows-by [int int] + (qp/process-query + (query-with-source-card card + :aggregation [:count] + :breakout [[:field-literal (keyword (data/format-name :price)) :type/Integer]])))))) + +;; Ensure trailing comments followed by a newline are trimmed and don't cause a wrapping SQL query to fail +(expect + breakout-results + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT * FROM VENUES -- small comment here\n"}}}] + (rows+cols + (format-rows-by [int int] + (qp/process-query + (query-with-source-card card + :aggregation [:count] + :breakout [[:field-literal (keyword (data/format-name :price)) :type/Integer]])))))) + ;; make sure we can filter by a field literal (expect @@ -486,8 +513,10 @@ "Run `f` with a temporary Database that copies the details from the standard test database. `f` is invoked as `(f db)`." [f] - (tt/with-temp Database [db {:details (:details (data/db)), :engine "h2"}] - (f db))) + (data/with-db (data/get-or-create-database! defs/test-data) + (create-users-if-needed!) + (tt/with-temp Database [db {:details (:details (data/db)), :engine "h2"}] + (f db)))) (defn- save-card-via-API-with-native-source-query! "Attempt to save a Card that uses a native source query for Database with `db-id` via the API using Rasta. Use this to @@ -496,7 +525,7 @@ (tt/with-temp Card [card {:dataset_query {:database db-id :type :native :native {:query "SELECT * FROM VENUES"}}}] - ((user->client :rasta) :post "card" + ((user->client :rasta) :post expected-status-code "card" {:name (tu/random-name) :display "scalar" :visualization_settings {} diff --git a/test/metabase/query_processor_test/time_field_test.clj b/test/metabase/query_processor_test/time_field_test.clj index 688b30b7b061d94d6c0578aefb3a301ab77d4034..bd91e02f5cac173bf08f398ab0a63c2aa1afdb47 100644 --- a/test/metabase/query_processor_test/time_field_test.clj +++ b/test/metabase/query_processor_test/time_field_test.clj @@ -17,7 +17,7 @@ ~@filter-clauses)))) ;; Basic between query on a time field -(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift} +(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift :sparksql} (if (= :sqlite *engine*) [[1 "Plato Yeshua" "08:30:00"] [4 "Simcha Yan" "08:30:00"]] @@ -29,7 +29,7 @@ "09:00:00")))) ;; Basic between query on a time field with milliseconds -(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift} +(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift :sparksql} (if (= :sqlite *engine*) [[1 "Plato Yeshua" "08:30:00"] [4 "Simcha Yan" "08:30:00"]] @@ -41,7 +41,7 @@ "09:00:00.000")))) ;; Basic > query with a time field -(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift} +(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift :sparksql} (if (= :sqlite *engine*) [[3 "Kaneonuskatew Eiran" "16:15:00"] [5 "Quentin Sören" "17:30:00"] @@ -53,7 +53,7 @@ (time-query (ql/filter (ql/> $last_login_time "16:00:00.000Z")))) ;; Basic query with an = filter on a time field -(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift} +(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift :sparksql} (if (= :sqlite *engine*) [[3 "Kaneonuskatew Eiran" "16:15:00"]] @@ -61,7 +61,7 @@ (time-query (ql/filter (ql/= $last_login_time "16:15:00.000Z")))) ;; Query with a time filter and a report timezone -(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift} +(qpt/expect-with-non-timeseries-dbs-except #{:oracle :mongo :redshift :sparksql} (cond (= :sqlite *engine*) [[1 "Plato Yeshua" "08:30:00"] diff --git a/test/metabase/related_test.clj b/test/metabase/related_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..9783b360ecdf9c77c8066955800f7333ab8e885e --- /dev/null +++ b/test/metabase/related_test.clj @@ -0,0 +1,155 @@ +(ns metabase.related-test + (:require [expectations :refer :all] + [metabase.models + [card :refer [Card]] + [collection :refer [Collection]] + [metric :refer [Metric]] + [segment :refer [Segment]]] + [metabase.related :as r :refer :all] + [metabase.test.data :as data] + [metabase.test.data.users :as users] + [toucan.util.test :as tt])) + +(expect + #{[:field-id 1] [:metric 1] [:field-id 2] [:segment 1]} + (#'r/collect-context-bearing-forms [[:> [:field-id 1] 3] + ["and" [:= ["FIELD-ID" 2] 2] + ["segment" 1]] + [:metric 1]])) + + +(expect + [0.5 + 0.0 + 1.0] + (tt/with-temp* [Card [{card-id-1 :id} + {:dataset_query {:query {:source_table (data/id :venues) + :aggregation [:sum [:field-id (data/id :venues :price)]] + :breakout [[:field-id (data/id :venues :category_id)]]} + :type :query + :database (data/id)}}] + Card [{card-id-2 :id} + {:dataset_query {:query {:source_table (data/id :venues) + :aggregation [:sum [:field-id (data/id :venues :longitude)]] + :breakout [[:field-id (data/id :venues :category_id)]]} + :type :query + :database (data/id)}}] + Card [{card-id-3 :id} + {:dataset_query {:query {:source_table (data/id :venues) + :aggregation [:sum [:field-id (data/id :venues :longitude)]] + :breakout [[:field-id (data/id :venues :latitude)]]} + :type :query + :database (data/id)}}]] + (map double [(#'r/similarity (Card card-id-1) (Card card-id-2)) + (#'r/similarity (Card card-id-1) (Card card-id-3)) + (#'r/similarity (Card card-id-1) (Card card-id-1))]))) + + +(defmacro ^:private with-world + [& body] + `(tt/expect-with-temp [Collection [{~'collection-id :id}] + Metric [{~'metric-id-a :id} {:table_id (data/id :venues) + :definition {:source_table (data/id :venues) + :aggregation [:sum [:field-id (data/id :venues :price)]]}}] + Metric [{~'metric-id-b :id} {:table_id (data/id :venues) + :definition {:source_table (data/id :venues) + :aggregation [:count]}}] + Segment [{~'segment-id-a :id} {:table_id (data/id :venues) + :definition {:source_table (data/id :venues) + :filter [:not= [:field-id (data/id :venues :category_id)] nil]}}] + Segment [{~'segment-id-b :id} {:table_id (data/id :venues) + :definition {:source_table (data/id :venues) + :filter [:not= [:field-id (data/id :venues :name)] nil]}}] + Card [{~'card-id-a :id :as ~'card-a} + {:table_id (data/id :venues) + :dataset_query {:type :query + :database (data/id) + :query {:source_table (data/id :venues) + :aggregation [:sum [:field-id (data/id :venues :price)]] + :breakout [[:field-id (data/id :venues :category_id)]]}}}] + Card [{~'card-id-b :id :as ~'card-b} + {:table_id (data/id :venues) + :collection_id ~'collection-id + :dataset_query {:type :query + :database (data/id) + :query {:source_table (data/id :venues) + :aggregation [:sum [:field-id (data/id :venues :longitude)]] + :breakout [[:field-id (data/id :venues :category_id)]]}}}] + Card [{card-id-c :id :as ~'card-c} + {:table_id (data/id :venues) + :dataset_query {:type :query + :database (data/id) + :query {:source_table (data/id :venues) + :aggregation [:sum [:field-id (data/id :venues :longitude)]] + :breakout [[:field-id (data/id :venues :name)] + [:field-id (data/id :venues :latitude)]]}}}]] + ~@body)) + +(defn- result-mask + [x] + (into {} + (for [[k v] x] + [k (if (sequential? v) + (sort (map :id v)) + (:id v))]))) + +(with-world + {:table (data/id :venues) + :metrics (sort [metric-id-a metric-id-b]) + :segments (sort [segment-id-a segment-id-b]) + :dashboard-mates [] + :similar-questions [card-id-b] + :canonical-metric metric-id-a + :collections [collection-id] + :dashboards []} + (->> ((users/user->client :crowberto) :get 200 (format "card/%s/related" card-id-a)) + result-mask)) + +(with-world + {:table (data/id :venues) + :metrics [metric-id-b] + :segments (sort [segment-id-a segment-id-b])} + (->> ((users/user->client :crowberto) :get 200 (format "metric/%s/related" metric-id-a)) + result-mask)) + +(with-world + {:table (data/id :venues) + :metrics (sort [metric-id-a metric-id-b]) + :segments [segment-id-b] + :linked-from [(data/id :checkins)]} + (->> ((users/user->client :crowberto) :get 200 (format "segment/%s/related" segment-id-a)) + result-mask)) + +(with-world + {:metrics (sort [metric-id-a metric-id-b]) + :segments (sort [segment-id-a segment-id-b]) + :linking-to [(data/id :categories)] + :linked-from [(data/id :checkins)] + :tables [(data/id :users)]} + (->> ((users/user->client :crowberto) :get 200 (format "table/%s/related" (data/id :venues))) + result-mask)) + + +;; Test transitive similarity: +;; (A is similar to B and B is similar to C, but A is not similar to C). Test if +;; this property holds and `:similar-questions` for A returns B, for B A and C, +;; and for C B. Note that C is less similar to B than A is, as C has an additional +;; breakout dimension. + +(with-world + [card-id-b] + (->> ((users/user->client :crowberto) :get 200 (format "card/%s/related" card-id-a)) + result-mask + :similar-questions)) + +(with-world + [card-id-a card-id-c] ; Ordering matters as C is less similar to B than A. + (->> ((users/user->client :crowberto) :get 200 (format "card/%s/related" card-id-b)) + result-mask + :similar-questions)) + +(with-world + [card-id-b] + (->> ((users/user->client :crowberto) :get 200 (format "card/%s/related" card-id-c)) + result-mask + :similar-questions)) diff --git a/test/metabase/sample_dataset_test.clj b/test/metabase/sample_dataset_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..4edb2a20eb0b1f56d98bb07ab2a682b61e84829e --- /dev/null +++ b/test/metabase/sample_dataset_test.clj @@ -0,0 +1,73 @@ +(ns metabase.sample-dataset-test + "Tests to make sure the Sample Dataset syncs the way we would expect." + (:require [expectations :refer :all] + [metabase + [sample-data :as sample-data] + [sync :as sync] + [util :as u]] + [metabase.models + [database :refer [Database]] + [field :refer [Field]] + [table :refer [Table]]] + [toucan + [db :as db] + [hydrate :refer [hydrate]]] + [toucan.util.test :as tt])) + +;;; ---------------------------------------------------- Tooling ----------------------------------------------------- + +;; These tools are pretty sophisticated for the amount of tests we have! + +(defn- sample-dataset-db [] + {:details (#'sample-data/db-details) + :engine :h2 + :name "Sample Dataset"}) + +(defmacro ^:private with-temp-sample-dataset-db + "Execute `body` with a temporary Sample Dataset DB bound to `db-binding`." + {:style/indent 1} + [[db-binding] & body] + `(tt/with-temp Database [db# (sample-dataset-db)] + (sync/sync-database! db#) + (let [~db-binding db#] + ~@body))) + +(defn- table + "Get the Table in a `db` with `table-name`." + [db table-name] + (db/select-one Table :name table-name, :db_id (u/get-id db))) + +(defn- field + "Get the Field in a `db` with `table-name` and `field-name.`" + [db table-name field-name] + (db/select-one Field :name field-name, :table_id (u/get-id (table db table-name)))) + + +;;; ----------------------------------------------------- Tests ------------------------------------------------------ + +;; Make sure the Sample Dataset is getting synced correctly. For example PEOPLE.NAME should be has_field_values = search +;; instead of `list`. +(expect + {:description "The name of the user who owns an account" + :database_type "VARCHAR" + :special_type :type/Name + :name "NAME" + :has_field_values :search + :active true + :visibility_type :normal + :preview_display true + :display_name "Name" + :fingerprint {:global {:distinct-count 2499} + :type {:type/Text {:percent-json 0.0 + :percent-url 0.0 + :percent-email 0.0 + :average-length 13.532}}} + :base_type :type/Text} + (with-temp-sample-dataset-db [db] + (-> (field db "PEOPLE" "NAME") + ;; it should be `nil` after sync but get set to `search` by the auto-inference. We only set `list` in sync and + ;; setting anything else is reserved for admins, however we fill in what we think should be the appropiate value + ;; with the hydration fn + (hydrate :has_field_values) + (select-keys [:name :description :database_type :special_type :has_field_values :active :visibility_type + :preview_display :display_name :fingerprint :base_type])))) diff --git a/test/metabase/sync/analyze/classifiers/category_test.clj b/test/metabase/sync/analyze/classifiers/category_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..96b6819fab128bc926b08f03d9e712da0bb87248 --- /dev/null +++ b/test/metabase/sync/analyze/classifiers/category_test.clj @@ -0,0 +1,36 @@ +(ns metabase.sync.analyze.classifiers.category-test + "Tests for the category classifier." + (:require [expectations :refer :all] + [metabase.sync.analyze.classifiers.category :as category-classifier])) + +(defn- field-with-distinct-count [distinct-count] + {:database_type "VARCHAR" + :special_type :type/Name + :name "NAME" + :fingerprint_version 1 + :has_field_values nil + :active true + :visibility_type :normal + :preview_display true + :display_name "Name" + :fingerprint {:global {:distinct-count distinct-count} + :type + {:type/Text + {:percent-json 0.0 + :percent-url 0.0 + :percent-email 0.0 + :average-length 13.516}}} + :base_type :type/Text}) + +;; make sure the logic for deciding whether a Field should be a list works as expected +(expect + nil + (#'category-classifier/field-should-be-auto-list? + 2500 + (field-with-distinct-count 2500))) + +(expect + true + (#'category-classifier/field-should-be-auto-list? + 99 + (field-with-distinct-count 99))) diff --git a/test/metabase/sync/analyze/classifiers/name_test.clj b/test/metabase/sync/analyze/classifiers/name_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..7ae8575a89e44ca2a32f3bae4b7220362a78c7d2 --- /dev/null +++ b/test/metabase/sync/analyze/classifiers/name_test.clj @@ -0,0 +1,24 @@ +(ns metabase.sync.analyze.classifiers.name-test + (:require [expectations :refer :all] + [metabase.models.table :as table] + [metabase.sync.analyze.classifiers.name :refer :all])) + +;; Postfix + pluralization +(expect + :entity/TransactionTable + (-> {:name "MY_ORDERS"} table/map->TableInstance infer-entity-type :entity_type)) + +;; Prefix +(expect + :entity/ProductTable + (-> {:name "productcatalogue"} table/map->TableInstance infer-entity-type :entity_type)) + +;; Don't match in the middle of the name +(expect + :entity/GenericTable + (-> {:name "myproductcatalogue"} table/map->TableInstance infer-entity-type :entity_type)) + +;; Not-match/default +(expect + :entity/GenericTable + (-> {:name "foo"} table/map->TableInstance infer-entity-type :entity_type)) diff --git a/test/metabase/sync/analyze/fingerprint/datetime_test.clj b/test/metabase/sync/analyze/fingerprint/datetime_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..770cd8d96b0143bb8c845532ea584db7712a202e --- /dev/null +++ b/test/metabase/sync/analyze/fingerprint/datetime_test.clj @@ -0,0 +1,9 @@ +(ns metabase.sync.analyze.fingerprint.datetime-test + (:require [clj-time.core :as t] + [expectations :refer :all] + [metabase.sync.analyze.fingerprint.datetime :refer :all])) + +(expect + {:earliest "2013-01-01T00:00:00.000Z" + :latest "2018-01-01T00:00:00.000Z"} + (datetime-fingerprint ["2013" "2018" "2015"])) diff --git a/test/metabase/sync/analyze/fingerprint_test.clj b/test/metabase/sync/analyze/fingerprint_test.clj index c6c24fe4debf26eb5ae71ce14b1cfc26a9963772..caa8daaf8f6ef60d454e086936a09c1dc56e2e83 100644 --- a/test/metabase/sync/analyze/fingerprint_test.clj +++ b/test/metabase/sync/analyze/fingerprint_test.clj @@ -8,6 +8,7 @@ [metabase.sync.analyze.fingerprint.sample :as sample] [metabase.sync.interface :as i] [metabase.test.data :as data] + [metabase.test.util] [metabase.util :as u] [toucan.db :as db] [toucan.util.test :as tt])) @@ -36,7 +37,9 @@ ;; a datetime field (expect - {:global {:distinct-count 618}} + {:global {:distinct-count 618} + :type {:type/DateTime {:earliest "2013-01-03T00:00:00.000Z" + :latest "2015-12-29T00:00:00.000Z"}}} (fingerprint (Field (data/id :checkins :date)))) @@ -76,7 +79,7 @@ [:or [:and [:< :fingerprint_version 2] - [:in :base_type #{"type/Decimal" "type/Latitude" "type/Longitude" "type/Coordinate" "type/Float"}]] + [:in :base_type #{"type/Decimal" "type/Latitude" "type/Longitude" "type/Coordinate" "type/Float" "type/Share"}]] [:and [:< :fingerprint_version 1] [:in :base_type #{"type/ImageURL" "type/AvatarURL"}]]]]} @@ -94,7 +97,7 @@ [:or [:and [:< :fingerprint_version 2] - [:in :base_type #{"type/Decimal" "type/Latitude" "type/Longitude" "type/Coordinate" "type/Float"}]] + [:in :base_type #{"type/Decimal" "type/Latitude" "type/Longitude" "type/Coordinate" "type/Float" "type/Share"}]] ;; no type/Float stuff should be included for 1 [:and [:< :fingerprint_version 1] @@ -112,7 +115,7 @@ [:or [:and [:< :fingerprint_version 4] - [:in :base_type #{"type/Decimal" "type/Latitude" "type/Longitude" "type/Coordinate" "type/Float"}]] + [:in :base_type #{"type/Decimal" "type/Latitude" "type/Longitude" "type/Coordinate" "type/Float" "type/Share"}]] [:and [:< :fingerprint_version 3] [:in :base_type #{"type/URL" "type/ImageURL" "type/AvatarURL"}]] diff --git a/test/metabase/sync/field_values_test.clj b/test/metabase/sync/field_values_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..05e37bdf268c336d3914bc4fafdd88ecf4ebc544 --- /dev/null +++ b/test/metabase/sync/field_values_test.clj @@ -0,0 +1,139 @@ +(ns metabase.sync.field-values-test + "Tests around the way Metabase syncs FieldValues, and sets the values of `field.has_field_values`." + (:require [clojure.java.jdbc :as jdbc] + [clojure.string :as str] + [expectations :refer :all] + [metabase + [db :as mdb] + [driver :as driver] + [sync :as sync :refer :all]] + [metabase.driver.generic-sql :as sql] + [metabase.models + [database :refer [Database]] + [field :refer [Field]] + [field-values :as field-values :refer [FieldValues]]] + [metabase.test + [data :as data] + [util :as tu]] + [toucan.db :as db] + [toucan.util.test :as tt])) + +;;; --------------------------------------------------- Helper Fns --------------------------------------------------- + +(defn- insert-range-sql + "Generate SQL to insert a row for each number in `rang`." + [rang] + (str "INSERT INTO blueberries_consumed (num) VALUES " + (str/join ", " (for [n rang] + (str "(" n ")"))))) + +(def ^:private ^:dynamic *conn* nil) + +(defn- do-with-blueberries-db + "An empty canvas upon which you may paint your dreams. + + Creates a database with a single table, `blueberries_consumed`, with one column, `num`; binds this DB to + `data/*get-db*` so you can use `data/db` and `data/id` to access it." + {:style/indent 0} + [f] + (let [details {:db (str "mem:" (tu/random-name) ";DB_CLOSE_DELAY=10")}] + (binding [mdb/*allow-potentailly-unsafe-connections* true] + (tt/with-temp Database [db {:engine :h2, :details details}] + (jdbc/with-db-connection [conn (sql/connection-details->spec (driver/engine->driver :h2) details)] + (jdbc/execute! conn ["CREATE TABLE blueberries_consumed (num INTEGER NOT NULL);"]) + (binding [data/*get-db* (constantly db)] + (binding [*conn* conn] + (f)))))))) + +(defmacro ^:private with-blueberries-db {:style/indent 0} [& body] + `(do-with-blueberries-db (fn [] ~@body))) + +(defn- insert-blueberries-and-sync! + "With the temp blueberries db from above, insert a `range` of values and re-sync the DB. + + (insert-blueberries-and-sync! [0 1 2 3]) ; insert 4 rows" + [rang] + (jdbc/execute! *conn* [(insert-range-sql rang)]) + (sync-database! (data/db))) + + +;;; ---------------------------------------------- The Tests Themselves ---------------------------------------------- + +;; A Field with 50 values should get marked as `auto-list` on initial sync, because it should be 'list', but was +;; marked automatically, as opposed to explicitly (`list`) +(expect + :auto-list + (with-blueberries-db + ;; insert 50 rows & sync + (insert-blueberries-and-sync! (range 50)) + ;; has_field_values should be auto-list + (db/select-one-field :has_field_values Field :id (data/id :blueberries_consumed :num)))) + +;; ... and it should also have some FieldValues +(expect + #metabase.models.field_values.FieldValuesInstance + {:values [0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 + 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49] + :human_readable_values {}} + (with-blueberries-db + (insert-blueberries-and-sync! (range 50)) + (db/select-one [FieldValues :values :human_readable_values], :field_id (data/id :blueberries_consumed :num)))) + +;; ok, but if the number grows past the threshold & we sync again it should get unmarked as auto-list and set back to +;; `nil` (#3215) +(expect + nil + (with-blueberries-db + ;; insert 50 bloobs & sync. has_field_values should be auto-list + (insert-blueberries-and-sync! (range 50)) + (assert (= (db/select-one-field :has_field_values Field :id (data/id :blueberries_consumed :num)) + :auto-list)) + ;; now insert enough bloobs to put us over the limit and re-sync. + (insert-blueberries-and-sync! (range 50 (+ 100 field-values/auto-list-cardinality-threshold))) + ;; has_field_values should have been set to nil. + (db/select-one-field :has_field_values Field :id (data/id :blueberries_consumed :num)))) + +;; ...its FieldValues should also get deleted. +(expect + nil + (with-blueberries-db + ;; do the same steps as the test above... + (insert-blueberries-and-sync! (range 50)) + (insert-blueberries-and-sync! (range 50 (+ 100 field-values/auto-list-cardinality-threshold))) + ;; ///and FieldValues should also have been deleted. + (db/select-one [FieldValues :values :human_readable_values], :field_id (data/id :blueberries_consumed :num)))) + +;; If we had explicitly marked the Field as `list` (instead of `auto-list`), adding extra values shouldn't change +;; anything! +(expect + :list + (with-blueberries-db + ;; insert 50 bloobs & sync + (insert-blueberries-and-sync! (range 50)) + ;; change has_field_values to list + (db/update! Field (data/id :blueberries_consumed :num) :has_field_values "list") + ;; insert more bloobs & re-sync + (insert-blueberries-and-sync! (range 50 (+ 100 field-values/auto-list-cardinality-threshold))) + ;; has_field_values shouldn't change + (db/select-one-field :has_field_values Field :id (data/id :blueberries_consumed :num)))) + +;; it should still have FieldValues, and have new ones for the new Values. It should have 200 values even though this +;; is past the normal limit of 100 values! +(expect + #metabase.models.field_values.FieldValuesInstance + {:values [0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 + 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 + 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 + 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 + 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 + 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 + 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 + 189 190 191 192 193 194 195 196 197 198 199] + :human_readable_values {}} + (with-blueberries-db + ;; follow the same steps as the test above... + (insert-blueberries-and-sync! (range 50)) + (db/update! Field (data/id :blueberries_consumed :num) :has_field_values "list") + (insert-blueberries-and-sync! (range 50 (+ 100 field-values/auto-list-cardinality-threshold))) + ;; ... and FieldValues should still be there, but this time updated to include the new values! + (db/select-one [FieldValues :values :human_readable_values], :field_id (data/id :blueberries_consumed :num)))) diff --git a/test/metabase/sync/sync_metadata/fields_test.clj b/test/metabase/sync/sync_metadata/fields_test.clj index 942658fdd70d8bbead905fe9ca8d75d164cc9707..2dbde44ddefd8c5a51fb82f6980d66a60bd41136 100644 --- a/test/metabase/sync/sync_metadata/fields_test.clj +++ b/test/metabase/sync/sync_metadata/fields_test.clj @@ -14,11 +14,10 @@ [database :refer [Database]] [field :refer [Field]] [table :refer [Table]]] - [metabase.test.data - [generic-sql :as sql-test-data] - h2 - [interface :as tdi]] - [toucan.db :as db] + [metabase.test.data.interface :as tdi] + [toucan + [db :as db] + [hydrate :refer [hydrate]]] [toucan.util.test :as tt])) (defn- with-test-db-before-and-after-dropping-a-column @@ -79,6 +78,15 @@ (db/select [Field :name :active] :table_id [:in (db/select-ids Table :db_id (u/get-id database))])))))) +;; make sure deleted fields doesn't show up in `:fields` of a table +(expect + {:before-drop #{"species" "example_name"} + :after-drop #{"species"}} + (with-test-db-before-and-after-dropping-a-column + (fn [database] + (let [table (hydrate (db/select-one Table :db_id (u/get-id database)) :fields)] + (set + (map :name (:fields table))))))) ;; make sure that inactive columns don't end up getting spliced into queries! This test arguably belongs in the query ;; processor tests since it's ultimately checking to make sure columns marked as `:active` = `false` aren't getting diff --git a/test/metabase/sync_database/analyze_test.clj b/test/metabase/sync_database/analyze_test.clj index 9ed3a029fc4af71bf3e9db4dfc54936b09445054..8d803dbc470ace336342b907650cbb35f5209b14 100644 --- a/test/metabase/sync_database/analyze_test.clj +++ b/test/metabase/sync_database/analyze_test.clj @@ -105,7 +105,6 @@ (This is done via the API so we can see which, if any, side effects (e.g. analysis) get triggered.)" [table visibility-type] ((user->client :crowberto) :put 200 (format "table/%d" (:id table)) {:display_name "hiddentable" - :entity_type "person" :visibility_type visibility-type :description "What a nice table!"})) diff --git a/test/metabase/sync_database/sync_dynamic_test.clj b/test/metabase/sync_database/sync_dynamic_test.clj index 404dcdd3c8e18023c9410a1aae94d8b5a6cf4e83..2406784592557bc6a3da156fcb98c3ca6c79e77e 100644 --- a/test/metabase/sync_database/sync_dynamic_test.clj +++ b/test/metabase/sync_database/sync_dynamic_test.clj @@ -25,8 +25,9 @@ (-> (u/select-non-nil-keys table [:schema :name :fields]) (update :fields (fn [fields] (for [field fields] - (u/select-non-nil-keys field [:table_id :name :fk_target_field_id :parent_id :base_type - :special_type :database_type]))))))) + (u/select-non-nil-keys + field + [:table_id :name :fk_target_field_id :parent_id :base_type :database_type]))))))) (defn- get-tables [database-or-id] (->> (hydrate (db/select Table, :db_id (u/get-id database-or-id), {:order-by [:id]}) :fields) diff --git a/test/metabase/sync_database_test.clj b/test/metabase/sync_database_test.clj index a683b8795a2ada6b5e74f58695a242dee4a597b2..5f34d68e4eb73d9b9e6bd5d8e81a1b6a67aa5fab 100644 --- a/test/metabase/sync_database_test.clj +++ b/test/metabase/sync_database_test.clj @@ -1,28 +1,23 @@ (ns metabase.sync-database-test "Tests for sync behavior that use a imaginary `SyncTestDriver`. These are kept around mainly because they've already been written. For newer sync tests see `metabase.sync.*` test namespaces." - (:require [clojure.java.jdbc :as jdbc] - [clojure.string :as str] - [expectations :refer :all] + (:require [expectations :refer :all] [metabase - [db :as mdb] [driver :as driver] [sync :refer :all] [util :as u]] - [metabase.driver.generic-sql :as sql] [metabase.models [database :refer [Database]] [field :refer [Field]] [field-values :as field-values :refer [FieldValues]] [table :refer [Table]]] [metabase.test - [data :refer :all] + [data :as data] [util :as tu]] [metabase.test.mock.util :as mock-util] [toucan.db :as db] [toucan.util.test :as tt])) - (def ^:private ^:const sync-test-tables {"movie" {:name "movie" :schema "default" @@ -31,7 +26,8 @@ :base-type :type/Integer} {:name "title" :database-type "VARCHAR" - :base-type :type/Text} + :base-type :type/Text + :special-type :type/Title} {:name "studio" :database-type "VARCHAR" :base-type :type/Text}}} @@ -91,41 +87,42 @@ tu/boolean-ids-and-timestamps))) (def ^:private table-defaults - {:id true + {:active true + :caveats nil + :created_at true :db_id true - :raw_table_id false - :schema nil :description nil - :caveats nil + :entity_name nil + :entity_type :entity/GenericTable + :id true :points_of_interest nil + :raw_table_id false + :rows nil + :schema nil :show_in_getting_started false - :entity_type nil - :entity_name nil - :visibility_type nil - :rows 1000 - :active true - :created_at true - :updated_at true}) + :updated_at true + :visibility_type nil}) (def ^:private field-defaults - {:id true - :table_id true - :raw_column_id false - :description nil + {:active true :caveats nil - :points_of_interest nil - :active true + :created_at true + :description nil + :fingerprint false + :fingerprint_version false + :fk_target_field_id false + :has_field_values nil + :id true + :last_analyzed false :parent_id false + :points_of_interest nil :position 0 :preview_display true - :visibility_type :normal - :fk_target_field_id false - :created_at true + :raw_column_id false + :special_type nil + :table_id true :updated_at true - :last_analyzed true - :has_field_values nil - :fingerprint true - :fingerprint_version true}) + :visibility_type :normal}) ;; ## SYNC DATABASE (expect @@ -134,43 +131,41 @@ :name "movie" :display_name "Movie" :fields [(merge field-defaults - {:special_type :type/PK - :name "id" + {:name "id" :display_name "ID" :database_type "SERIAL" :base_type :type/Integer}) (merge field-defaults - {:special_type :type/FK - :name "studio" + {:name "studio" :display_name "Studio" :database_type "VARCHAR" :base_type :type/Text - :fk_target_field_id true}) + :fk_target_field_id true + :special_type :type/FK}) (merge field-defaults - {:special_type nil - :name "title" + {:name "title" :display_name "Title" :database_type "VARCHAR" - :base_type :type/Text})]}) + :base_type :type/Text + :special_type :type/Title})]}) (merge table-defaults {:name "studio" :display_name "Studio" :fields [(merge field-defaults - {:special_type :type/Name - :name "name" + {:name "name" :display_name "Name" :database_type "VARCHAR" :base_type :type/Text}) (merge field-defaults - {:special_type :type/PK - :name "studio" + {:name "studio" :display_name "Studio" :database_type "VARCHAR" - :base_type :type/Text})]})] + :base_type :type/Text + :special_type :type/PK})]})] (tt/with-temp Database [db {:engine :sync-test}] (sync-database! db) - ;; we are purposely running the sync twice to test for possible logic issues which only manifest - ;; on resync of a database, such as adding tables that already exist or duplicating fields + ;; we are purposely running the sync twice to test for possible logic issues which only manifest on resync of a + ;; database, such as adding tables that already exist or duplicating fields (sync-database! db) (mapv table-details (db/select Table, :db_id (u/get-id db), {:order-by [:name]})))) @@ -183,23 +178,21 @@ :name "movie" :display_name "Movie" :fields [(merge field-defaults - {:special_type :type/PK - :name "id" + {:name "id" :display_name "ID" :database_type "SERIAL" :base_type :type/Integer}) (merge field-defaults - {:special_type nil - :name "studio" + {:name "studio" :display_name "Studio" :database_type "VARCHAR" :base_type :type/Text}) (merge field-defaults - {:special_type nil - :name "title" + {:name "title" :display_name "Title" :database_type "VARCHAR" - :base_type :type/Text})]}) + :base_type :type/Text + :special_type :type/Title})]}) (tt/with-temp* [Database [db {:engine :sync-test}] Table [table {:name "movie" :schema "default" @@ -252,15 +245,15 @@ @calls-to-describe-database))) -;; Test that we will remove field-values when they aren't appropriate. Calling `sync-database!` below should cause -;; them to get removed since the Field doesn't have an appropriate special type +;; Test that we will remove field-values when they aren't appropriate. Calling `sync-database!` below should cause +;; them to get removed since the Field isn't `has_field_values` = `list` (expect [[1 2 3] nil] (tt/with-temp* [Database [db {:engine :sync-test}]] (sync-database! db) (let [table-id (db/select-one-id Table, :schema "default", :name "movie") - field-id (db/select-one-id Field, :table_id table-id, :name "title")] + field-id (db/select-one-id Field, :table_id table-id, :name "studio")] (tt/with-temp FieldValues [_ {:field_id field-id :values "[1,2,3]"}] (let [initial-field-values (db/select-one-field :values FieldValues, :field_id field-id)] @@ -277,41 +270,41 @@ :type/PK :type/Latitude :type/PK] - (let [get-special-type (fn [] (db/select-one-field :special_type Field, :id (id :venues :id)))] + (let [get-special-type (fn [] (db/select-one-field :special_type Field, :id (data/id :venues :id)))] [;; Special type should be :id to begin with (get-special-type) ;; Clear out the special type - (do (db/update! Field (id :venues :id), :special_type nil) + (do (db/update! Field (data/id :venues :id), :special_type nil) (get-special-type)) ;; Calling sync-table! should set the special type again - (do (sync-table! (Table (id :venues))) + (do (sync-table! (Table (data/id :venues))) (get-special-type)) ;; sync-table! should *not* change the special type of fields that are marked with a different type - (do (db/update! Field (id :venues :id), :special_type :type/Latitude) + (do (db/update! Field (data/id :venues :id), :special_type :type/Latitude) (get-special-type)) ;; Make sure that sync-table runs set-table-pks-if-needed! - (do (db/update! Field (id :venues :id), :special_type nil) - (sync-table! (Table (id :venues))) + (do (db/update! Field (data/id :venues :id), :special_type nil) + (sync-table! (Table (data/id :venues))) (get-special-type))])) ;; ## FK SYNCING ;; Check that Foreign Key relationships were created on sync as we expect -(expect (id :venues :id) - (db/select-one-field :fk_target_field_id Field, :id (id :checkins :venue_id))) +(expect (data/id :venues :id) + (db/select-one-field :fk_target_field_id Field, :id (data/id :checkins :venue_id))) -(expect (id :users :id) - (db/select-one-field :fk_target_field_id Field, :id (id :checkins :user_id))) +(expect (data/id :users :id) + (db/select-one-field :fk_target_field_id Field, :id (data/id :checkins :user_id))) -(expect (id :categories :id) - (db/select-one-field :fk_target_field_id Field, :id (id :venues :category_id))) +(expect (data/id :categories :id) + (db/select-one-field :fk_target_field_id Field, :id (data/id :venues :category_id))) ;; Check that sync-table! causes FKs to be set like we'd expect (expect [{:special_type :type/FK, :fk_target_field_id true} - {:special_type nil, :fk_target_field_id false} + {:special_type nil, :fk_target_field_id false} {:special_type :type/FK, :fk_target_field_id true}] - (let [field-id (id :checkins :user_id) + (let [field-id (data/id :checkins :user_id) get-special-type-and-fk-exists? (fn [] (into {} (-> (db/select-one [Field :special_type :fk_target_field_id], :id field-id) @@ -322,15 +315,15 @@ (do (db/update! Field field-id, :special_type nil, :fk_target_field_id nil) (get-special-type-and-fk-exists?)) ;; Run sync-table and they should be set again - (let [table (Table (id :checkins))] + (let [table (Table (data/id :checkins))] (sync-table! table) (get-special-type-and-fk-exists?))])) ;;; ## FieldValues Syncing -(let [get-field-values (fn [] (db/select-one-field :values FieldValues, :field_id (id :venues :price))) - get-field-values-id (fn [] (db/select-one-id FieldValues, :field_id (id :venues :price)))] +(let [get-field-values (fn [] (db/select-one-field :values FieldValues, :field_id (data/id :venues :price))) + get-field-values-id (fn [] (db/select-one-id FieldValues, :field_id (data/id :venues :price)))] ;; Test that when we delete FieldValues syncing the Table again will cause them to be re-created (expect [[1 2 3 4] ; 1 @@ -342,7 +335,7 @@ (do (db/delete! FieldValues :id (get-field-values-id)) (get-field-values)) ;; 3. Now re-sync the table and make sure they're back - (do (sync-table! (Table (id :venues))) + (do (sync-table! (Table (data/id :venues))) (get-field-values))]) ;; Test that syncing will cause FieldValues to be updated @@ -356,39 +349,11 @@ (do (db/update! FieldValues (get-field-values-id), :values [1 2 3]) (get-field-values)) ;; 3. Now re-sync the table and make sure the value is back - (do (sync-table! (Table (id :venues))) + (do (sync-table! (Table (data/id :venues))) (get-field-values))])) -;; Make sure that if a Field's cardinality passes `low-cardinality-threshold` (currently 300) -;; the corresponding FieldValues entry will be deleted (#3215) -(defn- insert-range-sql [rang] - (str "INSERT INTO blueberries_consumed (num) VALUES " - (str/join ", " (for [n rang] - (str "(" n ")"))))) - -(expect - false - (let [details {:db (str "mem:" (tu/random-name) ";DB_CLOSE_DELAY=10")}] - (binding [mdb/*allow-potentailly-unsafe-connections* true] - (tt/with-temp Database [db {:engine :h2, :details details}] - (jdbc/with-db-connection [conn (sql/connection-details->spec (driver/engine->driver :h2) details)] - (let [exec! #(doseq [statement %] - (jdbc/execute! conn [statement]))] - ;; create the `blueberries_consumed` table and insert a 100 values - (exec! ["CREATE TABLE blueberries_consumed (num INTEGER NOT NULL);" - (insert-range-sql (range 100))]) - (sync-database! db) - (let [table-id (db/select-one-id Table :db_id (u/get-id db)) - field-id (db/select-one-id Field :table_id table-id)] - ;; field values should exist... - (assert (= (count (db/select-one-field :values FieldValues :field_id field-id)) - 100)) - ;; ok, now insert enough rows to push the field past the `low-cardinality-threshold` and sync again, - ;; there should be no more field values - (exec! [(insert-range-sql (range 100 (+ 100 field-values/low-cardinality-threshold)))]) - (sync-database! db) - (db/exists? FieldValues :field_id field-id)))))))) +;; TODO - hey, what is this testing? If you wrote this test, please explain what's going on here (defn- narrow-to-min-max [row] (-> row (get-in [:type :type/Number]) @@ -399,9 +364,9 @@ (expect [{:min -165.374 :max -73.9533} {:min 10.0646 :max 40.7794}] - (tt/with-temp* [Database [database {:details (:details (Database (id))), :engine :h2}] + (tt/with-temp* [Database [database {:details (:details (Database (data/id))), :engine :h2}] Table [table {:db_id (u/get-id database), :name "VENUES"}]] (sync-table! table) (map narrow-to-min-max - [(db/select-one-field :fingerprint Field, :id (id :venues :longitude)) - (db/select-one-field :fingerprint Field, :id (id :venues :latitude))]))) + [(db/select-one-field :fingerprint Field, :id (data/id :venues :longitude)) + (db/select-one-field :fingerprint Field, :id (data/id :venues :latitude))]))) diff --git a/test/metabase/task/send_pulses_test.clj b/test/metabase/task/send_pulses_test.clj index 379b5b8a5f01cf46d066f59f928f1e820109c28a..00318d762e6eeda21d5d8a3c08ded68e19475529 100644 --- a/test/metabase/task/send_pulses_test.clj +++ b/test/metabase/task/send_pulses_test.clj @@ -30,9 +30,9 @@ :pulse_channel_id pc-id}]] (et/email-to :rasta {:subject "Metabase alert: My Question Name has results", - :body {"My Question Name.*has results" true}}) + :body {"My Question Name" true}}) (et/with-fake-inbox (data/with-db (data/get-or-create-database! defs/test-data) (et/with-expected-messages 1 (#'metabase.task.send-pulses/send-pulses! 0 "fri" :first :first)) - (et/regex-email-bodies #"My Question Name.*has results")))) + (et/regex-email-bodies #"My Question Name")))) diff --git a/test/metabase/test/data/generic_sql.clj b/test/metabase/test/data/generic_sql.clj index a4fcbcf6d76f6f5f1f30feae4fb05bf87bf608a4..b21330c2e07dc4645acb28105e890035468fcfa1 100644 --- a/test/metabase/test/data/generic_sql.clj +++ b/test/metabase/test/data/generic_sql.clj @@ -248,7 +248,11 @@ (def load-data-one-at-a-time-parallel! "Insert rows one at a time, in parallel." (make-load-data-fn load-data-add-ids (partial load-data-one-at-a-time pmap))) ;; ^ the parallel versions aren't neccesarily faster than the sequential versions for all drivers so make sure to do some profiling in order to pick the appropriate implementation -(defn default-execute-sql! [driver context dbdef sql] +(defn- jdbc-execute! [db-spec sql] + (jdbc/execute! db-spec [sql] {:transaction? false, :multi? true})) + +(defn default-execute-sql! [driver context dbdef sql & {:keys [execute!] + :or {execute! jdbc-execute!}}] (let [sql (some-> sql s/trim)] (when (and (seq sql) ;; make sure SQL isn't just semicolons @@ -256,7 +260,7 @@ ;; Remove excess semicolons, otherwise snippy DBs like Oracle will barf (let [sql (s/replace sql #";+" ";")] (try - (jdbc/execute! (database->spec driver context dbdef) [sql] {:transaction? false, :multi? true}) + (execute! (database->spec driver context dbdef) sql) (catch SQLException e (println "Error executing SQL:" sql) (printf "Caught SQLException:\n%s\n" @@ -268,7 +272,6 @@ (with-out-str (.printStackTrace e))) (throw e))))))) - (def DefaultsMixin "Default implementations for methods marked *Optional* in `IGenericSQLTestExtensions`." {:add-fk-sql default-add-fk-sql @@ -294,11 +297,11 @@ Since there are some cases were you might want to execute compound statements without splitting, an upside-down ampersand (`⅋`) is understood as an \"escaped\" semicolon in the resulting SQL statement." - [driver context dbdef sql] + [driver context dbdef sql & {:keys [execute!] :or {execute! default-execute-sql!}}] (when sql (doseq [statement (map s/trim (s/split sql #";+"))] (when (seq statement) - (default-execute-sql! driver context dbdef (s/replace statement #"⅋" ";")))))) + (execute! driver context dbdef (s/replace statement #"⅋" ";")))))) (defn- create-db! [driver {:keys [table-definitions], :as dbdef}] ;; Exec SQL for creating the DB diff --git a/test/metabase/test/data/h2.clj b/test/metabase/test/data/h2.clj index 9c118b8bef18f3c8f43c7fdf383a56d3cae9cfbd..65ee48be530e8a797476195ebf6db486f505b3fd 100644 --- a/test/metabase/test/data/h2.clj +++ b/test/metabase/test/data/h2.clj @@ -2,6 +2,7 @@ "Code for creating / destroying an H2 database from a `DatabaseDefinition`." (:require [clojure.string :as s] [metabase.db.spec :as dbspec] + metabase.driver.h2 ; because we import metabase.driver.h2.H2Driver below [metabase.test.data [generic-sql :as generic] [interface :as i]] diff --git a/test/metabase/test/data/sparksql.clj b/test/metabase/test/data/sparksql.clj new file mode 100644 index 0000000000000000000000000000000000000000..cd70910e0a8b67e50e120945ad193e5cdedf388b --- /dev/null +++ b/test/metabase/test/data/sparksql.clj @@ -0,0 +1,117 @@ +(ns metabase.test.data.sparksql + (:require [clojure.java.jdbc :as jdbc] + [clojure.string :as s] + [honeysql + [core :as hsql] + [format :as hformat] + [helpers :as h]] + [metabase.driver + [generic-sql :as sql] + [hive-like :as hive-like]] + [metabase.driver.generic-sql.query-processor :as sqlqp] + [metabase.test.data + [generic-sql :as generic] + [interface :as i]] + [metabase.util :as u] + [metabase.util.honeysql-extensions :as hx]) + (:import metabase.driver.sparksql.SparkSQLDriver)) + +(def ^:private field-base-type->sql-type + {:type/BigInteger "BIGINT" + :type/Boolean "BOOLEAN" + :type/Date "DATE" + :type/DateTime "TIMESTAMP" + :type/Decimal "DECIMAL" + :type/Float "DOUBLE" + :type/Integer "INTEGER" + :type/Text "STRING"}) + +(defn- quote-name [nm] + (str \` nm \`)) + +(defn- dashes->underscores [s] + (s/replace s #"-" "_")) + +(defn- qualified-name-components [& args] + (map dashes->underscores args)) + +(defn- database->connection-details [context {:keys [database-name]}] + (merge {:host "localhost" + :port 10000 + :user "admin" + :password "admin"} + (when (= context :db) + {:db (dashes->underscores database-name)}))) + +(defn- do-insert! + "Insert ROWS-OR-ROWS into TABLE-NAME for the DRIVER database defined by SPEC." + [driver spec table-name row-or-rows] + (let [prepare-key (comp keyword (partial generic/prepare-identifier driver) name) + rows (if (sequential? row-or-rows) + row-or-rows + [row-or-rows]) + columns (keys (first rows)) + values (for [row rows] + (for [value (map row columns)] + (sqlqp/->honeysql driver value))) + hsql-form (-> (h/insert-into (prepare-key table-name)) + (h/values values)) + sql+args (hive-like/unprepare + (hx/unescape-dots (binding [hformat/*subquery?* false] + (hsql/format hsql-form + :quoting (sql/quote-style driver) + :allow-dashed-names? false))))] + (with-open [conn (jdbc/get-connection spec)] + (try + (.setAutoCommit conn false) + (jdbc/execute! {:connection conn} sql+args {:transaction? false}) + (catch java.sql.SQLException e + (jdbc/print-sql-exception-chain e)))))) + +(defn- load-data! + [driver {:keys [database-name], :as dbdef} {:keys [table-name], :as tabledef}] + (let [spec (generic/database->spec driver :db dbdef) + table-name (apply hx/qualify-and-escape-dots (qualified-name-components database-name table-name)) + insert! (generic/load-data-add-ids (partial do-insert! driver spec table-name)) + rows (generic/load-data-get-rows driver dbdef tabledef)] + (insert! rows))) + +(defn- create-table-sql [driver {:keys [database-name], :as dbdef} {:keys [table-name field-definitions]}] + (let [quot (partial generic/quote-name driver) + pk-field-name (quot (generic/pk-field-name driver))] + (format "CREATE TABLE %s (%s, %s %s)" + (generic/qualify+quote-name driver database-name table-name) + (->> field-definitions + (map (fn [{:keys [field-name base-type]}] + (format "%s %s" (quot field-name) (if (map? base-type) + (:native base-type) + (generic/field-base-type->sql-type driver base-type))))) + (interpose ", ") + (apply str)) + pk-field-name (generic/pk-sql-type driver) + pk-field-name))) + +(defn- drop-table-if-exists-sql [driver {:keys [database-name]} {:keys [table-name]}] + (format "DROP TABLE IF EXISTS %s" (generic/qualify+quote-name driver database-name table-name))) + +(defn- drop-db-if-exists-sql [driver {:keys [database-name]}] + (format "DROP DATABASE IF EXISTS %s CASCADE" (generic/qualify+quote-name driver database-name))) + +(u/strict-extend SparkSQLDriver + generic/IGenericSQLTestExtensions + (merge generic/DefaultsMixin + {:add-fk-sql (constantly nil) + :execute-sql! generic/sequentially-execute-sql! + :field-base-type->sql-type (u/drop-first-arg field-base-type->sql-type) + :create-table-sql create-table-sql + :drop-table-if-exists-sql drop-table-if-exists-sql + :drop-db-if-exists-sql drop-db-if-exists-sql + :load-data! load-data! + :pk-sql-type (constantly "INT") + :qualified-name-components (u/drop-first-arg qualified-name-components) + :quote-name (u/drop-first-arg quote-name)}) + i/IDriverTestExtensions + (merge generic/IDriverTestExtensionsMixin + {:database->connection-details (u/drop-first-arg database->connection-details) + :default-schema (constantly "test_data") + :engine (constantly :sparksql)})) diff --git a/test/metabase/test/util.clj b/test/metabase/test/util.clj index 2b359601c68650a0aae3a8ae0a95eff740827fd9..3c28c5a0bb0db30e475edc809e7947d8ac5f2962 100644 --- a/test/metabase/test/util.clj +++ b/test/metabase/test/util.clj @@ -16,6 +16,7 @@ [dashboard :refer [Dashboard]] [dashboard-card-series :refer [DashboardCardSeries]] [database :refer [Database]] + [dimension :refer [Dimension]] [field :refer [Field]] [metric :refer [Metric]] [permissions-group :refer [PermissionsGroup]] @@ -151,10 +152,15 @@ (u/strict-extend (class Database) test/WithTempDefaults {:with-temp-defaults (fn [_] {:details {} - :engine :yeehaw ; wtf? + :engine :h2 :is_sample false :name (random-name)})}) +(u/strict-extend (class Dimension) + test/WithTempDefaults + {:with-temp-defaults (fn [_] {:name (random-name) + :type "internal"})}) + (u/strict-extend (class Field) test/WithTempDefaults {:with-temp-defaults (fn [_] {:database_type "VARCHAR" diff --git a/test/metabase/timeseries_query_processor_test.clj b/test/metabase/timeseries_query_processor_test.clj index 7fff141eda069f3f65eef05b2428d8743732b4f7..d6169e1d486c150f737f79442864cee571875971 100644 --- a/test/metabase/timeseries_query_processor_test.clj +++ b/test/metabase/timeseries_query_processor_test.clj @@ -6,43 +6,7 @@ [util :as u]] [metabase.query-processor.middleware.expand :as ql] [metabase.test.data :as data] - [metabase.test.data - [dataset-definitions :as defs] - [datasets :as datasets] - [interface :as i]])) - -(def ^:private ^:const event-based-dbs - #{:druid}) - -(def ^:private flattened-db-def - "The normal test-data DB definition as a flattened, single-table DB definition. (This is a function rather than a - straight delay because clojure complains when they delay gets embedding in expanded macros)" - (delay (i/flatten-dbdef defs/test-data "checkins"))) - -;; force loading of the flattened db definitions for the DBs that need it -(defn- load-event-based-db-data! - {:expectations-options :before-run} - [] - (doseq [engine event-based-dbs] - (datasets/with-engine-when-testing engine - (data/do-with-temp-db @flattened-db-def (constantly nil))))) - -(defn do-with-flattened-dbdef - "Execute F with a flattened version of the test data DB as the current DB def." - [f] - (data/do-with-temp-db @flattened-db-def (u/drop-first-arg f))) - -(defmacro with-flattened-dbdef - "Execute BODY using the flattened test data DB definition." - [& body] - `(do-with-flattened-dbdef (fn [] ~@body))) - -(defmacro ^:private expect-with-timeseries-dbs - {:style/indent 0} - [expected actual] - `(datasets/expect-with-engines event-based-dbs - (with-flattened-dbdef ~expected) - (with-flattened-dbdef ~actual))) + [metabase.timeseries-query-processor-test.util :refer :all])) (defn- data [results] (when-let [data (or (:data results) diff --git a/test/metabase/timeseries_query_processor_test/util.clj b/test/metabase/timeseries_query_processor_test/util.clj new file mode 100644 index 0000000000000000000000000000000000000000..f400d76dbf4c540bd590078c621639f4172d617d --- /dev/null +++ b/test/metabase/timeseries_query_processor_test/util.clj @@ -0,0 +1,41 @@ +(ns metabase.timeseries-query-processor-test.util + "Utility functions and macros for testing timeseries database drivers, such as Druid." + (:require [metabase.test.data :as data] + [metabase.test.data + [dataset-definitions :as defs] + [datasets :as datasets] + [interface :as i]] + [metabase.util :as u])) + +(def event-based-dbs + #{:druid}) + +(def flattened-db-def + "The normal test-data DB definition as a flattened, single-table DB definition. (This is a function rather than a + straight delay because clojure complains when they delay gets embedding in expanded macros)" + (delay (i/flatten-dbdef defs/test-data "checkins"))) + +;; force loading of the flattened db definitions for the DBs that need it +(defn- load-event-based-db-data! + {:expectations-options :before-run} + [] + (doseq [engine event-based-dbs] + (datasets/with-engine-when-testing engine + (data/do-with-temp-db @flattened-db-def (constantly nil))))) + +(defn do-with-flattened-dbdef + "Execute F with a flattened version of the test data DB as the current DB def." + [f] + (data/do-with-temp-db @flattened-db-def (u/drop-first-arg f))) + +(defmacro with-flattened-dbdef + "Execute BODY using the flattened test data DB definition." + [& body] + `(do-with-flattened-dbdef (fn [] ~@body))) + +(defmacro expect-with-timeseries-dbs + {:style/indent 0} + [expected actual] + `(datasets/expect-with-engines event-based-dbs + (with-flattened-dbdef ~expected) + (with-flattened-dbdef ~actual))) diff --git a/test/metabase/util/stats_test.clj b/test/metabase/util/stats_test.clj index 486d9088a7dc972cd0844ead329cf5d97438f0ae..cd9cf4a095d54f5171e4b821f6e24747dd58b817 100644 --- a/test/metabase/util/stats_test.clj +++ b/test/metabase/util/stats_test.clj @@ -1,9 +1,15 @@ (ns metabase.util.stats-test (:require [expectations :refer :all] - [metabase.models.query-execution :refer [QueryExecution]] + [metabase.models [query-execution :refer [QueryExecution]] + [pulse :refer [Pulse]] + [pulse-channel :refer [PulseChannel]] + [card :refer [Card]] + [pulse-card :refer [PulseCard]]] [metabase.test.util :as tu] [metabase.util.stats :as stats-util :refer :all] - [toucan.db :as db])) + [toucan.db :as db] + [metabase.util :as u] + [toucan.util.test :as tt])) (expect "0" (#'stats-util/bin-micro-number 0)) (expect "1" (#'stats-util/bin-micro-number 1)) @@ -63,8 +69,8 @@ (expect false ((anonymous-usage-stats) :sso_configured)) (expect false ((anonymous-usage-stats) :has_sample_data)) -;;Spot checking a few system stats to ensure conversion from property -;;names and presence in the anonymous-usage-stats +;; Spot checking a few system stats to ensure conversion from property +;; names and presence in the anonymous-usage-stats (expect #{true} (let [system-stats (get-in (anonymous-usage-stats) [:stats :system])] @@ -87,3 +93,80 @@ (expect (old-execution-metrics) (#'stats-util/execution-metrics)) + + +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Pulses & Alerts | +;;; +----------------------------------------------------------------------------------------------------------------+ + +;; make sure we get some reasonable Pulses & Alert metrics, and they filter each other out as appropriate + +;; alert_condition character varying(254), -- Condition (i.e. "rows" or "goal") used as a guard for alerts +;; alert_first_only boolean, -- True if the alert should be disabled after the first notification +;; alert_above_goal boolean, -- For a goal condition, alert when above the goal +(defn- x [] + {:pulses {:pulses 3 + :with_table_cards 2 + :pulse_types {"slack" 1, "email" 2} + :pulse_schedules {"daily" 2, "weekly" 1} + :num_pulses_per_user {"1-5" 1} + :num_pulses_per_card {"6-10" 1} + :num_cards_per_pulses {"1-5" 1, "6-10" 1}} + :alerts {:alerts 4 + :with_table_cards 2 + :first_time_only 1 + :above_goal 1 + :alert_types {"slack" 2, "email" 2} + :num_alerts_per_user {"1-5" 1} + :num_alerts_per_card {"11-25" 1} + :num_cards_per_alerts {"1-5" 1, "6-10" 1}}} + (tt/with-temp* [Card [c] + ;; ---------- Pulses ---------- + Pulse [p1] + Pulse [p2] + Pulse [p3] + PulseChannel [_ {:pulse_id (u/get-id p1), :schedule_type "daily", :channel_type "email"}] + PulseChannel [_ {:pulse_id (u/get-id p1), :schedule_type "weekly", :channel_type "email"}] + PulseChannel [_ {:pulse_id (u/get-id p2), :schedule_type "daily", :channel_type "slack"}] + ;; Pulse 1 gets 2 Cards (1 CSV) + PulseCard [_ {:pulse_id (u/get-id p1), :card_id (u/get-id c)}] + PulseCard [_ {:pulse_id (u/get-id p1), :card_id (u/get-id c), :include_csv true}] + ;; Pulse 2 gets 1 Card + PulseCard [_ {:pulse_id (u/get-id p1), :card_id (u/get-id c)}] + ;; Pulse 3 gets 7 Cards (1 CSV, 2 XLS, 2 BOTH) + PulseCard [_ {:pulse_id (u/get-id p3), :card_id (u/get-id c)}] + PulseCard [_ {:pulse_id (u/get-id p3), :card_id (u/get-id c)}] + PulseCard [_ {:pulse_id (u/get-id p3), :card_id (u/get-id c), :include_csv true}] + PulseCard [_ {:pulse_id (u/get-id p3), :card_id (u/get-id c), :include_xls true}] + PulseCard [_ {:pulse_id (u/get-id p3), :card_id (u/get-id c), :include_xls true}] + PulseCard [_ {:pulse_id (u/get-id p3), :card_id (u/get-id c), :include_csv true, :include_xls true}] + PulseCard [_ {:pulse_id (u/get-id p3), :card_id (u/get-id c), :include_csv true, :include_xls true}] + ;; ---------- Alerts ---------- + Pulse [a1 {:alert_condition "rows", :alert_first_only false}] + Pulse [a2 {:alert_condition "rows", :alert_first_only true }] + Pulse [a3 {:alert_condition "goal", :alert_first_only false}] + Pulse [a4 {:alert_condition "goal", :alert_first_only false, :alert_above_goal true}] + ;; Alert 1 is Email, Alert 2 is Email & Slack, Alert 3 is Slack-only + PulseChannel [_ {:pulse_id (u/get-id a1), :channel_type "email"}] + PulseChannel [_ {:pulse_id (u/get-id a1), :channel_type "email"}] + PulseChannel [_ {:pulse_id (u/get-id a2), :channel_type "slack"}] + PulseChannel [_ {:pulse_id (u/get-id a3), :channel_type "slack"}] + ;; Alert 1 gets 2 Cards (1 CSV) + PulseCard [_ {:pulse_id (u/get-id a1), :card_id (u/get-id c)}] + PulseCard [_ {:pulse_id (u/get-id a1), :card_id (u/get-id c), :include_csv true}] + ;; Alert 2 gets 1 Card + PulseCard [_ {:pulse_id (u/get-id a1), :card_id (u/get-id c)}] + ;; Alert 3 gets 7 Cards (1 CSV, 2 XLS, 2 BOTH) + PulseCard [_ {:pulse_id (u/get-id a3), :card_id (u/get-id c)}] + PulseCard [_ {:pulse_id (u/get-id a3), :card_id (u/get-id c)}] + PulseCard [_ {:pulse_id (u/get-id a3), :card_id (u/get-id c), :include_csv true}] + PulseCard [_ {:pulse_id (u/get-id a3), :card_id (u/get-id c), :include_xls true}] + PulseCard [_ {:pulse_id (u/get-id a3), :card_id (u/get-id c), :include_xls true}] + PulseCard [_ {:pulse_id (u/get-id a3), :card_id (u/get-id c), :include_csv true, :include_xls true}] + PulseCard [_ {:pulse_id (u/get-id a3), :card_id (u/get-id c), :include_csv true, :include_xls true}] + ;; Alert 4 gets 3 Cards + PulseCard [_ {:pulse_id (u/get-id a3), :card_id (u/get-id c)}] + PulseCard [_ {:pulse_id (u/get-id a3), :card_id (u/get-id c)}] + PulseCard [_ {:pulse_id (u/get-id a3), :card_id (u/get-id c)}]] + {:pulses (#'metabase.util.stats/pulse-metrics) + :alerts (#'metabase.util.stats/alert-metrics)})) diff --git a/test/metabase/util_test.clj b/test/metabase/util_test.clj index 40022e26ea13755dcf070e868331e948e8e42ae9..90647a34cc885475f4db4dee4551c8afc17771a1 100644 --- a/test/metabase/util_test.clj +++ b/test/metabase/util_test.clj @@ -204,12 +204,12 @@ (select-nested-keys {} [:c])) -;;; tests for base-64-string? -(expect (base-64-string? "ABc")) -(expect (base-64-string? "ABc/+asdasd==")) -(expect false (base-64-string? 100)) -(expect false (base-64-string? "<<>>")) -(expect false (base-64-string? "{\"a\": 10}")) +;;; tests for base64-string? +(expect (base64-string? "ABc")) +(expect (base64-string? "ABc/+asdasd==")) +(expect false (base64-string? 100)) +(expect false (base64-string? "<<>>")) +(expect false (base64-string? "{\"a\": 10}")) ;;; tests for `occurances-of-substring` @@ -243,12 +243,28 @@ :present #{:a :b :c} :non-nil #{:d :e :f})) -(expect - [-2 -1 0 1 2 3 0 3] - (map order-of-magnitude [0.01 0.5 4 12 444 1023 0 -1444])) -(expect - [{:foo 2} - {:foo 2 :bar 3}] - [(update-when {:foo 2} :bar inc) - (update-when {:foo 2 :bar 2} :bar inc)]) +;;; tests for `order-of-magnitude` +(expect -2 (order-of-magnitude 0.01)) +(expect -1 (order-of-magnitude 0.5)) +(expect 0 (order-of-magnitude 4)) +(expect 1 (order-of-magnitude 12)) +(expect 2 (order-of-magnitude 444)) +(expect 3 (order-of-magnitude 1023)) +(expect 0 (order-of-magnitude 0)) +(expect 3 (order-of-magnitude -1444)) + + +;;; tests for `update-when` and `update-in-when` +(expect {:foo 2} (update-when {:foo 2} :bar inc)) +(expect {:foo 2 :bar 3} (update-when {:foo 2 :bar 2} :bar inc)) + +(expect {:foo 2} (update-in-when {:foo 2} [:foo :bar] inc)) +(expect {:foo {:bar 3}} (update-in-when {:foo {:bar 2}} [:foo :bar] inc)) + + +;;; tests for `index-of` +(expect 2 (index-of pos? [-1 0 2 3])) +(expect nil (index-of pos? [-1 0 -2 -3])) +(expect nil (index-of pos? nil)) +(expect nil (index-of pos? [])) diff --git a/webpack.config.js b/webpack.config.js index b6175627fc922491fe2b3270a29714b6a5de5a96..1d605d00239f10f93cec26653ccbadc8abebd8f8 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -114,11 +114,11 @@ var config = module.exports = { new UnusedFilesWebpackPlugin({ globOptions: { ignore: [ + "**/types.js", "**/types/*.js", "**/*.spec.*", "**/__support__/*.js", "**/__mocks__/*.js*", - "public/lib/types.js", "internal/lib/components-node.js" ] } diff --git a/yarn.lock b/yarn.lock index 746764665592b93af5e78526c1946839766d33cc..f9dcaa11b32d981a2dab4aabfb04488dd24d8036 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1,10 +1,8 @@ # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. # yarn lockfile v1 - - "@slack/client@^3.5.4": - version "3.14.1" - resolved "https://registry.yarnpkg.com/@slack/client/-/client-3.14.1.tgz#be3ff80ca2a4bd881b72ac9c2c3dd5bb7276b2a5" + version "3.15.0" + resolved "https://registry.yarnpkg.com/@slack/client/-/client-3.15.0.tgz#796ee2b1182cd37fadbaeb37752121b2028a1704" dependencies: async "^1.5.0" bluebird "^3.3.3" @@ -19,13 +17,6 @@ winston "^2.1.1" ws "^1.0.1" -JSONStream@^1.0.3: - version "1.3.1" - resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.1.tgz#707f761e01dae9e16f1bcf93703b78c70966579a" - dependencies: - jsonparse "^1.2.0" - through ">=2.2.7 <3" - abab@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e" @@ -34,13 +25,6 @@ abbrev@1: version "1.1.1" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" -accepts@1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.3.tgz#c3ca7434938648c3e0d9c1e328dd68b622c284ca" - dependencies: - mime-types "~2.1.11" - negotiator "0.6.1" - accepts@~1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.4.tgz#86246758c7dd6d21a6474ff084a4740ec05eb21f" @@ -48,9 +32,16 @@ accepts@~1.3.4: mime-types "~2.1.16" negotiator "0.6.1" +accepts@1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.3.tgz#c3ca7434938648c3e0d9c1e328dd68b622c284ca" + dependencies: + mime-types "~2.1.11" + negotiator "0.6.1" + ace-builds@^1.2.2: - version "1.2.8" - resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.2.8.tgz#062dc45772b00ece6a547a266a3081fe57591103" + version "1.2.9" + resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.2.9.tgz#2947fb47a881005e914e3dd8d095b6e84e5e5216" acorn-dynamic-import@^2.0.0: version "2.0.2" @@ -78,22 +69,22 @@ acorn@^4.0.3, acorn@^4.0.4: version "4.0.13" resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787" -acorn@^5.0.0, acorn@^5.1.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.1.2.tgz#911cb53e036807cf0fa778dc5d370fbd864246d7" +acorn@^5.0.0, acorn@^5.2.1: + version "5.3.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.3.0.tgz#7446d39459c54fb49a80e6ee6478149b940ec822" add-px-to-style@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/add-px-to-style/-/add-px-to-style-1.0.0.tgz#d0c135441fa8014a8137904531096f67f28f263a" -adm-zip@0.4.4: - version "0.4.4" - resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.4.tgz#a61ed5ae6905c3aea58b3a657d25033091052736" - adm-zip@~0.4.3: version "0.4.7" resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.7.tgz#8606c2cbf1c426ce8c8ec00174447fd49b6eafc1" +adm-zip@0.4.4: + version "0.4.4" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.4.tgz#a61ed5ae6905c3aea58b3a657d25033091052736" + after@0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f" @@ -109,31 +100,31 @@ ajv-keywords@^1.0.0: version "1.5.1" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.5.1.tgz#314dd0a4b3368fad3dfcdc54ede6171b886daf3c" -ajv-keywords@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.0.tgz#a296e17f7bfae7c1ce4f7e0de53d29cb32162df0" - -ajv@4.9.0, ajv@^4.7.0: - version "4.9.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.9.0.tgz#5a358085747b134eb567d6d15e015f1d7802f45c" - dependencies: - co "^4.6.0" - json-stable-stringify "^1.0.1" +ajv-keywords@^2.0.0, ajv-keywords@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.1.tgz#617997fc5f60576894c435f940d819e135b80762" -ajv@^4.9.1: +ajv@^4.7.0, ajv@^4.9.1: version "4.11.8" resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.11.8.tgz#82ffb02b29e662ae53bdc20af15947706739c536" dependencies: co "^4.6.0" json-stable-stringify "^1.0.1" -ajv@^5.0.0, ajv@^5.1.5: - version "5.2.3" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.3.tgz#c06f598778c44c6b161abafe3466b81ad1814ed2" +ajv@^5.0.0, ajv@^5.1.0, ajv@^5.1.5: + version "5.5.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965" dependencies: co "^4.6.0" fast-deep-equal "^1.0.0" + fast-json-stable-stringify "^2.0.0" json-schema-traverse "^0.3.0" + +ajv@4.9.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.9.0.tgz#5a358085747b134eb567d6d15e015f1d7802f45c" + dependencies: + co "^4.6.0" json-stable-stringify "^1.0.1" align-text@^0.1.1, align-text@^0.1.3: @@ -160,10 +151,16 @@ ansi-escapes@^1.0.0, ansi-escapes@^1.1.0, ansi-escapes@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" -ansi-html@0.0.7, ansi-html@^0.0.7: +ansi-html@^0.0.7, ansi-html@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" +ansi-red@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ansi-red/-/ansi-red-0.1.1.tgz#8c638f9d1080800a353c9c28c8a81ca4705d946c" + dependencies: + ansi-wrap "0.1.0" + ansi-regex@^0.2.0, ansi-regex@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-0.2.1.tgz#0d8e946967a3d8143f93e24e298525fc1b2235f9" @@ -196,6 +193,10 @@ ansi-styles@^3.0.0, ansi-styles@^3.1.0: dependencies: color-convert "^1.9.0" +ansi-wrap@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf" + any-promise@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-0.1.0.tgz#830b680aa7e56f33451d4b049f3bd8044498ee27" @@ -211,6 +212,13 @@ anymatch@^1.3.0: micromatch "^2.1.5" normalize-path "^2.0.0" +anymatch@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" + dependencies: + micromatch "^3.1.4" + normalize-path "^2.1.1" + app-root-path@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-2.0.1.tgz#cd62dcf8e4fd5a417efc664d2e5b10653c651b46" @@ -238,6 +246,13 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +argparse@~0.1.15: + version "0.1.16" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-0.1.16.tgz#cfd01e0fbba3d6caed049fbd758d40f65196f57c" + dependencies: + underscore "~1.7.0" + underscore.string "~2.4.0" + arr-diff@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" @@ -248,7 +263,7 @@ arr-diff@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" -arr-flatten@^1.0.1, arr-flatten@^1.0.3: +arr-flatten@^1.0.1, arr-flatten@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" @@ -268,14 +283,14 @@ array-find@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/array-find/-/array-find-1.0.0.tgz#6c8e286d11ed768327f8e62ecee87353ca3e78b8" -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - array-flatten@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.1.tgz#426bb9da84090c1838d812c8150af20a8331e296" +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + array-includes@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.0.3.tgz#184b48f62d92d7452bb31b323165c7f8bd02266d" @@ -337,8 +352,8 @@ asap@~2.0.3: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" asn1.js@^4.0.0: - version "4.9.1" - resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.9.1.tgz#48ba240b45a9280e94748990ba597d216617fd40" + version "4.9.2" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.9.2.tgz#8117ef4f7ed87cd8f89044b5bff97ac243a16c9a" dependencies: bn.js "^4.0.0" inherits "^2.0.1" @@ -348,20 +363,24 @@ asn1@~0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86" -assert-plus@1.0.0, assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - assert-plus@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234" +assert-plus@^1.0.0, assert-plus@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + assert@^1.1.1: version "1.4.1" resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91" dependencies: util "0.10.3" +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + async-each@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" @@ -371,8 +390,8 @@ async@^1.4.0, async@^1.5.0, async@^1.5.2: resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" async@^2.1.2, async@^2.1.4, async@^2.4.1: - version "2.5.0" - resolved "https://registry.yarnpkg.com/async/-/async-2.5.0.tgz#843190fd6b7357a0b9e1c956edddd5ec8462b54d" + version "2.6.0" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.0.tgz#61a29abb6fcc026fea77e56d1c6ec53a795951f4" dependencies: lodash "^4.14.0" @@ -396,6 +415,10 @@ atob@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/atob/-/atob-2.0.3.tgz#19c7a760473774468f20b2d2d03372ad7d4cbf5d" +autolinker@~0.15.0: + version "0.15.3" + resolved "https://registry.yarnpkg.com/autolinker/-/autolinker-0.15.3.tgz#342417d8f2f3461b14cf09088d5edf8791dc9832" + autoprefixer@^6.0.2, autoprefixer@^6.3.1: version "6.7.7" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.7.7.tgz#1dbd1c835658e35ce3f9984099db00585c782014" @@ -411,7 +434,11 @@ aws-sign2@~0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f" -aws4@^1.2.1: +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + +aws4@^1.2.1, aws4@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" @@ -436,7 +463,7 @@ babel-cli@^6.11.4: optionalDependencies: chokidar "^1.6.1" -babel-code-frame@^6.11.0, babel-code-frame@^6.16.0, babel-code-frame@^6.22.0, babel-code-frame@^6.26.0: +babel-code-frame@^6.16.0, babel-code-frame@^6.22.0, babel-code-frame@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" dependencies: @@ -477,6 +504,19 @@ babel-eslint@^7.1.1: babel-types "^6.23.0" babylon "^6.17.0" +babel-generator@^6.18.0, babel-generator@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.0.tgz#ac1ae20070b79f6e3ca1d3269613053774f20dc5" + dependencies: + babel-messages "^6.23.0" + babel-runtime "^6.26.0" + babel-types "^6.26.0" + detect-indent "^4.0.0" + jsesc "^1.3.0" + lodash "^4.17.4" + source-map "^0.5.6" + trim-right "^1.0.1" + babel-generator@6.18.0: version "6.18.0" resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.18.0.tgz#e4f104cb3063996d9850556a45aae4a022060a07" @@ -502,19 +542,6 @@ babel-generator@6.25.0: source-map "^0.5.0" trim-right "^1.0.1" -babel-generator@^6.18.0, babel-generator@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.0.tgz#ac1ae20070b79f6e3ca1d3269613053774f20dc5" - dependencies: - babel-messages "^6.23.0" - babel-runtime "^6.26.0" - babel-types "^6.26.0" - detect-indent "^4.0.0" - jsesc "^1.3.0" - lodash "^4.17.4" - source-map "^0.5.6" - trim-right "^1.0.1" - babel-helper-bindify-decorators@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-helper-bindify-decorators/-/babel-helper-bindify-decorators-6.24.1.tgz#14c19e5f142d7b47f19a52431e52b1ccbc40a330" @@ -667,9 +694,9 @@ babel-plugin-add-react-displayname@^0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/babel-plugin-add-react-displayname/-/babel-plugin-add-react-displayname-0.0.4.tgz#bc2a74bcbee6e505025b3352fea85ee7bc4c6f7c" -babel-plugin-c-3po@^0.5.8: - version "0.5.8" - resolved "https://registry.yarnpkg.com/babel-plugin-c-3po/-/babel-plugin-c-3po-0.5.8.tgz#9cf8e5e0bc997a7e385f9f00e7169c5b75332b1d" +babel-plugin-c-3po@0.8.0-0: + version "0.8.0-0" + resolved "https://registry.yarnpkg.com/babel-plugin-c-3po/-/babel-plugin-c-3po-0.8.0-0.tgz#57d9372e4269183ea12e133bcb61a842092c95a5" dependencies: ajv "4.9.0" babel-generator "6.18.0" @@ -1176,22 +1203,19 @@ babel-register@^6.11.6, babel-register@^6.26.0: mkdirp "^0.5.1" source-map-support "^0.4.15" -babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.23.0, babel-runtime@^6.26.0, babel-runtime@^6.9.0, babel-runtime@^6.9.1: +babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0, babel-runtime@^6.9.0, babel-runtime@^6.9.1: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" dependencies: core-js "^2.4.0" regenerator-runtime "^0.11.0" -babel-template@6.16.0: - version "6.16.0" - resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.16.0.tgz#e149dd1a9f03a35f817ddbc4d0481988e7ebc8ca" +babel-runtime@^7.0.0-beta.3: + version "7.0.0-beta.3" + resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-7.0.0-beta.3.tgz#7c750de5514452c27612172506b49085a4a630f2" dependencies: - babel-runtime "^6.9.0" - babel-traverse "^6.16.0" - babel-types "^6.16.0" - babylon "^6.11.0" - lodash "^4.2.0" + core-js "^2.4.0" + regenerator-runtime "^0.11.0" babel-template@^6.16.0, babel-template@^6.24.1, babel-template@^6.26.0, babel-template@^6.3.0: version "6.26.0" @@ -1203,6 +1227,16 @@ babel-template@^6.16.0, babel-template@^6.24.1, babel-template@^6.26.0, babel-te babylon "^6.18.0" lodash "^4.17.4" +babel-template@6.16.0: + version "6.16.0" + resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.16.0.tgz#e149dd1a9f03a35f817ddbc4d0481988e7ebc8ca" + dependencies: + babel-runtime "^6.9.0" + babel-traverse "^6.16.0" + babel-types "^6.16.0" + babylon "^6.11.0" + lodash "^4.2.0" + babel-traverse@^6.16.0, babel-traverse@^6.18.0, babel-traverse@^6.23.1, babel-traverse@^6.24.1, babel-traverse@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee" @@ -1217,15 +1251,6 @@ babel-traverse@^6.16.0, babel-traverse@^6.18.0, babel-traverse@^6.23.1, babel-tr invariant "^2.2.2" lodash "^4.17.4" -babel-types@6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.18.0.tgz#1f7d5a73474c59eb9151b2417bbff4e4fce7c3f8" - dependencies: - babel-runtime "^6.9.1" - esutils "^2.0.2" - lodash "^4.2.0" - to-fast-properties "^1.0.1" - babel-types@^6.16.0, babel-types@^6.18.0, babel-types@^6.19.0, babel-types@^6.23.0, babel-types@^6.24.1, babel-types@^6.25.0, babel-types@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497" @@ -1235,6 +1260,15 @@ babel-types@^6.16.0, babel-types@^6.18.0, babel-types@^6.19.0, babel-types@^6.23 lodash "^4.17.4" to-fast-properties "^1.0.3" +babel-types@6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.18.0.tgz#1f7d5a73474c59eb9151b2417bbff4e4fce7c3f8" + dependencies: + babel-runtime "^6.9.1" + esutils "^2.0.2" + lodash "^4.2.0" + to-fast-properties "^1.0.1" + babelify@^7.3.0: version "7.3.0" resolved "https://registry.yarnpkg.com/babelify/-/babelify-7.3.0.tgz#aa56aede7067fd7bd549666ee16dc285087e88e5" @@ -1254,10 +1288,6 @@ bail@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.2.tgz#f7d6c1731630a9f9f0d4d35ed1f962e2074a1764" -balanced-match@0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.1.0.tgz#b504bd05869b39259dd0c5efc35d843176dccc4a" - balanced-match@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.2.1.tgz#7bc658b4bed61eee424ad74f75f5c3e2c4df3cc7" @@ -1270,10 +1300,26 @@ balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" +balanced-match@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.1.0.tgz#b504bd05869b39259dd0c5efc35d843176dccc4a" + banner-webpack-plugin@^0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/banner-webpack-plugin/-/banner-webpack-plugin-0.2.3.tgz#e9dee9d9644ccef1fd970e11d82408aff42290eb" +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + base64-arraybuffer@0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8" @@ -1286,22 +1332,10 @@ base64id@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6" -base64url@2.0.0, base64url@^2.0.0: +base64url@^2.0.0, base64url@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/base64url/-/base64url-2.0.0.tgz#eac16e03ea1438eff9423d69baa36262ed1f70bb" -base@^0.11.1: - version "0.11.2" - resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" - dependencies: - cache-base "^1.0.1" - class-utils "^0.3.5" - component-emitter "^1.2.1" - define-property "^1.0.0" - isobject "^3.0.1" - mixin-deep "^1.2.0" - pascalcase "^0.1.1" - batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -1323,8 +1357,8 @@ big.js@^3.1.3: resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" binary-extensions@^1.0.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.10.0.tgz#9aeb9a6c5e88638aad171e167f5900abe24835d0" + version "1.11.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205" blob@0.0.4: version "0.0.4" @@ -1348,7 +1382,7 @@ bo-selector@0.0.10: version "0.0.10" resolved "https://registry.yarnpkg.com/bo-selector/-/bo-selector-0.0.10.tgz#9816dcb00adf374ea87941a863b2acfc026afa3e" -body-parser@1.18.2: +body-parser@^1.16.1, body-parser@1.18.2: version "1.18.2" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.2.tgz#87678a19d84b47d859b83199bd59bce222b10454" dependencies: @@ -1363,21 +1397,6 @@ body-parser@1.18.2: raw-body "2.3.2" type-is "~1.6.15" -body-parser@^1.16.1: - version "1.17.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.17.1.tgz#75b3bc98ddd6e7e0d8ffe750dfaca5c66993fa47" - dependencies: - bytes "2.4.0" - content-type "~1.0.2" - debug "2.6.1" - depd "~1.1.0" - http-errors "~1.6.1" - iconv-lite "0.4.15" - on-finished "~2.3.0" - qs "6.4.0" - raw-body "~2.2.0" - type-is "~1.6.14" - body@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/body/-/body-5.1.0.tgz#e4ba0ce410a46936323367609ecb4e6553125069" @@ -1408,6 +1427,18 @@ boom@2.x.x: dependencies: hoek "2.x.x" +boom@4.x.x: + version "4.3.1" + resolved "https://registry.yarnpkg.com/boom/-/boom-4.3.1.tgz#4f8a3005cb4a7e3889f749030fd25b96e01d2e31" + dependencies: + hoek "4.x.x" + +boom@5.x.x: + version "5.2.0" + resolved "https://registry.yarnpkg.com/boom/-/boom-5.2.0.tgz#5dd9da6ee3a5f302077436290cb717d3f4a54e02" + dependencies: + hoek "4.x.x" + brace-expansion@^1.1.7: version "1.1.8" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292" @@ -1429,20 +1460,20 @@ braces@^1.8.2: preserve "^0.2.0" repeat-element "^1.1.2" -braces@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-2.2.2.tgz#241f868c2b2690d9febeee5a7c83fbbf25d00b1b" +braces@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.0.tgz#a46941cb5fb492156b3d6a656e06c35364e3e66e" dependencies: - arr-flatten "^1.0.3" + arr-flatten "^1.1.0" array-unique "^0.3.2" define-property "^1.0.0" extend-shallow "^2.0.1" fill-range "^4.0.0" - isobject "^3.0.0" + isobject "^3.0.1" repeat-element "^1.1.2" snapdragon "^0.8.1" snapdragon-node "^2.0.1" - split-string "^2.1.0" + split-string "^3.0.2" to-regex "^3.0.1" brorand@^1.0.1: @@ -1456,8 +1487,8 @@ browser-resolve@^1.11.2, browser-resolve@^1.7.0: resolve "1.1.7" browserify-aes@^1.0.0, browserify-aes@^1.0.4: - version "1.1.0" - resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.1.0.tgz#1d2ad62a8b479f23f0ab631c1be86a82dbccbe48" + version "1.1.1" + resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.1.1.tgz#38b7ab55edb806ff2dcda1a7f1620773a477c49f" dependencies: buffer-xor "^1.0.3" cipher-base "^1.0.0" @@ -1501,11 +1532,11 @@ browserify-sign@^4.0.0: inherits "^2.0.1" parse-asn1 "^5.0.0" -browserify-zlib@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d" +browserify-zlib@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f" dependencies: - pako "~0.2.0" + pako "~1.0.5" browserslist@^1.0.0, browserslist@^1.3.6, browserslist@^1.5.2, browserslist@^1.7.6: version "1.7.7" @@ -1514,18 +1545,18 @@ browserslist@^1.0.0, browserslist@^1.3.6, browserslist@^1.5.2, browserslist@^1.7 caniuse-db "^1.0.30000639" electron-to-chromium "^1.2.7" -bser@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/bser/-/bser-1.0.2.tgz#381116970b2a6deea5646dd15dd7278444b56169" - dependencies: - node-int64 "^0.4.0" - bser@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/bser/-/bser-2.0.0.tgz#9ac78d3ed5d915804fd87acb158bc797147a1719" dependencies: node-int64 "^0.4.0" +bser@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bser/-/bser-1.0.2.tgz#381116970b2a6deea5646dd15dd7278444b56169" + dependencies: + node-int64 "^0.4.0" + buffer-equal-constant-time@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" @@ -1534,7 +1565,7 @@ buffer-indexof@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" -buffer-shims@^1.0.0, buffer-shims@~1.0.0: +buffer-shims@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-shims/-/buffer-shims-1.0.0.tgz#9978ce317388c649ad8793028c3477ef044a8b51" @@ -1570,24 +1601,21 @@ bytes@1: version "1.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-1.0.0.tgz#3569ede8ba34315fab99c3e92cb04c7220de1fa8" -bytes@2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.4.0.tgz#7d97196f9d5baf7f6935e25985549edd2a6c2339" - bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" -c-3po@^0.5.8: - version "0.5.8" - resolved "https://registry.yarnpkg.com/c-3po/-/c-3po-0.5.8.tgz#9d235ea9b53a99fb996075afdb8edd43f46fd000" +c-3po@^0.7.3: + version "0.7.3" + resolved "https://registry.yarnpkg.com/c-3po/-/c-3po-0.7.3.tgz#d28d274eb28008bba6275ac882bb466160b849e7" dependencies: dedent "^0.7.0" gettext-parser "1.2.2" + gitbook-plugin-atoc "^1.0.3" -cacache@^10.0.0: - version "10.0.0" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-10.0.0.tgz#3bba88bf62b0773fd9a691605f60c9d3c595e853" +cacache@^10.0.1: + version "10.0.2" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-10.0.2.tgz#105a93a162bbedf3a25da42e1939ed99ffb145f8" dependencies: bluebird "^3.5.0" chownr "^1.0.1" @@ -1675,8 +1703,8 @@ caniuse-api@^1.5.2, caniuse-api@^1.5.3: lodash.uniq "^4.5.0" caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000634, caniuse-db@^1.0.30000639: - version "1.0.30000746" - resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000746.tgz#501098c66f5fbbf634c02f25508b05e8809910f4" + version "1.0.30000789" + resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000789.tgz#5cf3fec75480041ab162ca06413153141e234325" caseless@~0.11.0: version "0.11.0" @@ -1701,16 +1729,6 @@ chain-function@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc" -chalk@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.5.1.tgz#663b3a648b68b55d04690d49167aa837858f2174" - dependencies: - ansi-styles "^1.1.0" - escape-string-regexp "^1.0.0" - has-ansi "^0.1.0" - strip-ansi "^0.3.0" - supports-color "^0.2.0" - chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" @@ -1721,14 +1739,24 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" -chalk@^2.0.0, chalk@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.1.0.tgz#ac5becf14fa21b99c6c92ca7a7d7cfd5b17e743e" +chalk@^2.0.0, chalk@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.0.tgz#b5ea48efc9c1793dccc9b4767c93914d3f2d52ba" dependencies: ansi-styles "^3.1.0" escape-string-regexp "^1.0.5" supports-color "^4.0.0" +chalk@0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.5.1.tgz#663b3a648b68b55d04690d49167aa837858f2174" + dependencies: + ansi-styles "^1.1.0" + escape-string-regexp "^1.0.0" + has-ansi "^0.1.0" + strip-ansi "^0.3.0" + supports-color "^0.2.0" + change-emitter@^0.1.2: version "0.1.6" resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515" @@ -1774,7 +1802,7 @@ chevrotain@0.21.0: version "0.21.0" resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-0.21.0.tgz#7e54eee5abb049caf2421c32b56c848e2cb64f71" -chokidar@^1.2.0, chokidar@^1.4.1, chokidar@^1.6.0, chokidar@^1.6.1, chokidar@^1.7.0: +chokidar@^1.2.0, chokidar@^1.4.1, chokidar@^1.6.1, chokidar@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468" dependencies: @@ -1789,13 +1817,30 @@ chokidar@^1.2.0, chokidar@^1.4.1, chokidar@^1.6.0, chokidar@^1.6.1, chokidar@^1. optionalDependencies: fsevents "^1.0.0" +chokidar@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.0.tgz#6686313c541d3274b2a5c01233342037948c911b" + dependencies: + anymatch "^2.0.0" + async-each "^1.0.0" + braces "^2.3.0" + glob-parent "^3.1.0" + inherits "^2.0.1" + is-binary-path "^1.0.0" + is-glob "^4.0.0" + normalize-path "^2.1.1" + path-is-absolute "^1.0.0" + readdirp "^2.0.0" + optionalDependencies: + fsevents "^1.0.0" + chownr@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181" ci-info@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.1.1.tgz#47b44df118c48d2597b56d342e7e25791060171a" + version "1.1.2" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.1.2.tgz#03561259db48d0474c8bdc90f5b47b068b6bbfb4" cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" @@ -1893,8 +1938,8 @@ clone-stats@^1.0.0: resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680" clone@^1.0.0, clone@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.2.tgz#260b7a99ebb1edfe247538175f783243cb19d149" + version "1.0.3" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.3.tgz#298d7e2231660f40c003c2ed3140decf3f53085f" clone@^2.1.1: version "2.1.1" @@ -1922,6 +1967,10 @@ code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" +coffee-script@^1.12.4: + version "1.12.7" + resolved "https://registry.yarnpkg.com/coffee-script/-/coffee-script-1.12.7.tgz#c05dae0cb79591d05b3070a8433a98c9a89ccc53" + collapse-white-space@^1.0.0, collapse-white-space@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.3.tgz#4b906f670e5a963a87b76b0e1689643341b6023c" @@ -1937,12 +1986,18 @@ color-convert@^0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd" -color-convert@^1.3.0, color-convert@^1.8.2, color-convert@^1.9.0: +color-convert@^1.3.0, color-convert@^1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a" dependencies: color-name "^1.1.1" +color-convert@^1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.1.tgz#c1261107aeb2f294ebffec9ed9ecad529a6097ed" + dependencies: + color-name "^1.1.1" + color-name@^1.0.0, color-name@^1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" @@ -1953,7 +2008,7 @@ color-string@^0.3.0: dependencies: color-name "^1.0.0" -color-string@^1.4.0: +color-string@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.2.tgz#26e45814bc3c9a7cbd6751648a41434514a773a9" dependencies: @@ -1975,12 +2030,12 @@ color@^0.11.0, color@^0.11.3, color@^0.11.4: color-convert "^1.3.0" color-string "^0.3.0" -color@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/color/-/color-1.0.3.tgz#e48e832d85f14ef694fb468811c2d5cfe729b55d" +color@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a" dependencies: - color-convert "^1.8.2" - color-string "^1.4.0" + color-convert "^1.9.1" + color-string "^1.5.2" colormin@^1.0.5: version "1.1.2" @@ -1990,14 +2045,14 @@ colormin@^1.0.5: css-color-names "0.0.4" has "^1.0.1" +colors@^1.1.0, colors@~1.1.2, colors@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" + colors@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" -colors@1.1.2, colors@^1.1.0, colors@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" - combine-lists@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/combine-lists/-/combine-lists-1.0.1.tgz#458c07e09e0d900fc28b70a3fec2dacd1d2cb7f6" @@ -2016,13 +2071,9 @@ comma-separated-tokens@^1.0.1: dependencies: trim "0.0.1" -commander@2.11.x, commander@^2.11.0, commander@^2.9.0, commander@~2.11.0: - version "2.11.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563" - -commander@2.6.0: - version "2.6.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.6.0.tgz#9df7e52fb2a0cb0fb89058ee80c3104225f37e1d" +commander@^2.11.0, commander@^2.9.0, commander@~2.12.1, commander@2.12.x: + version "2.12.2" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.12.2.tgz#0f5946c427ed9ec0d91a46bb9def53e54650e555" commander@~2.9.0: version "2.9.0" @@ -2030,6 +2081,10 @@ commander@~2.9.0: dependencies: graceful-readlink ">= 1.0.0" +commander@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.6.0.tgz#9df7e52fb2a0cb0fb89058ee80c3104225f37e1d" + commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" @@ -2038,23 +2093,23 @@ component-bind@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1" +component-emitter@^1.2.1, component-emitter@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" + component-emitter@1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.1.2.tgz#296594f2753daa63996d2af08d15a95116c9aec3" -component-emitter@1.2.1, component-emitter@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" - component-inherit@0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143" compressible@~2.0.11: - version "2.0.11" - resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.11.tgz#16718a75de283ed8e604041625a2064586797d8a" + version "2.0.12" + resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.12.tgz#c59a5c99db76767e9876500e271ef63b3493bd66" dependencies: - mime-db ">= 1.29.0 < 2" + mime-db ">= 1.30.0 < 2" compression@^1.5.2: version "1.7.1" @@ -2072,7 +2127,7 @@ concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" -concat-stream@^1.5.0, concat-stream@^1.5.2: +concat-stream@^1.5.0, concat-stream@^1.5.1, concat-stream@^1.5.2: version "1.6.0" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.0.tgz#0aac662fd52be78964d5532f694784e70110acf7" dependencies: @@ -2089,8 +2144,8 @@ concat-stream@~1.5.0: typedarray "~0.0.5" concurrently@^3.1.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-3.5.0.tgz#8cf1b7707a6916a78a4ff5b77bb04dec54b379b2" + version "3.5.1" + resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-3.5.1.tgz#ee8b60018bbe86b02df13e5249453c6ececd2521" dependencies: chalk "0.5.1" commander "2.6.0" @@ -2102,8 +2157,8 @@ concurrently@^3.1.0: tree-kill "^1.1.0" connect-history-api-fallback@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.4.0.tgz#3db24f973f4b923b0e82f619ce0df02411ca623d" + version "1.5.0" + resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.5.0.tgz#b06873934bc5e344fef611a196a6faae0aee015a" connect@^3.6.0: version "3.6.5" @@ -2137,10 +2192,10 @@ content-disposition@0.5.2: resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" content-type-parser@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/content-type-parser/-/content-type-parser-1.0.1.tgz#c3e56988c53c65127fb46d4032a3a900246fdc94" + version "1.0.2" + resolved "https://registry.yarnpkg.com/content-type-parser/-/content-type-parser-1.0.2.tgz#caabe80623e63638b2502fd4c7f12ff4ce2352e7" -content-type@~1.0.2, content-type@~1.0.4: +content-type@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" @@ -2149,8 +2204,8 @@ continuable-cache@^0.3.1: resolved "https://registry.yarnpkg.com/continuable-cache/-/continuable-cache-0.3.1.tgz#bd727a7faed77e71ff3985ac93351a912733ad0f" convert-source-map@^1.1.1, convert-source-map@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.0.tgz#9acd70851c6d5dfdd93d9282e5edf94a03ff46b5" + version "1.5.1" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.1.tgz#b8278097b9bc229365de5c62cf5fcaed8b5599e5" cookie-signature@1.0.6: version "1.0.6" @@ -2186,10 +2241,10 @@ core-js@^1.0.0: resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" core-js@^2.2.0, core-js@^2.4.0, core-js@^2.5.0: - version "2.5.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.1.tgz#ae6874dc66937789b80754ff5428df66819ca50b" + version "2.5.3" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.3.tgz#8acc38345824f16d8365b7c9b4259168e8ed603e" -core-util-is@1.0.2, core-util-is@~1.0.0: +core-util-is@~1.0.0, core-util-is@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -2245,13 +2300,6 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: safe-buffer "^5.0.1" sha.js "^2.4.8" -create-react-class@15.5.2: - version "15.5.2" - resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.5.2.tgz#6a8758348df660b88326a0e764d569f274aad681" - dependencies: - fbjs "^0.8.9" - object-assign "^4.1.1" - create-react-class@^15.5.1, create-react-class@^15.5.2, create-react-class@^15.6.0: version "15.6.2" resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.6.2.tgz#cf1ed15f12aad7f14ef5f2dfe05e6c42f91ef02a" @@ -2275,23 +2323,29 @@ cross-spawn@^5.0.1: shebang-command "^1.2.0" which "^1.2.9" -crossfilter2@~1.3: - version "1.3.14" - resolved "https://registry.yarnpkg.com/crossfilter2/-/crossfilter2-1.3.14.tgz#c45bd8d335f6c91accbac26eda203377f195f680" - crossfilter@^1.3.12: version "1.3.12" resolved "https://registry.yarnpkg.com/crossfilter/-/crossfilter-1.3.12.tgz#147d7236a98c45c69f78bdc3a99d6fb00f70930c" +crossfilter2@~1.3: + version "1.3.14" + resolved "https://registry.yarnpkg.com/crossfilter2/-/crossfilter2-1.3.14.tgz#c45bd8d335f6c91accbac26eda203377f195f680" + cryptiles@2.x.x: version "2.0.5" resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8" dependencies: boom "2.x.x" +cryptiles@3.x.x: + version "3.1.2" + resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-3.1.2.tgz#a89fbb220f5ce25ec56e8c4aa8a4fd7b5b0d29fe" + dependencies: + boom "5.x.x" + crypto-browserify@^3.11.0: - version "3.11.1" - resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.1.tgz#948945efc6757a400d6e5e5af47194d10064279f" + version "3.12.0" + resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" dependencies: browserify-cipher "^1.0.0" browserify-sign "^4.0.0" @@ -2303,6 +2357,7 @@ crypto-browserify@^3.11.0: pbkdf2 "^3.0.3" public-encrypt "^4.0.0" randombytes "^2.0.0" + randomfill "^1.0.3" css-color-function@^1.2.0: version "1.3.3" @@ -2318,21 +2373,21 @@ css-color-names@0.0.4: resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" css-loader@^0.28.7: - version "0.28.7" - resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-0.28.7.tgz#5f2ee989dd32edd907717f953317656160999c1b" + version "0.28.8" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-0.28.8.tgz#ff36381464dea18fe60f2601a060ba6445886bd5" dependencies: - babel-code-frame "^6.11.0" + babel-code-frame "^6.26.0" css-selector-tokenizer "^0.7.0" - cssnano ">=2.6.1 <4" + cssnano "^3.10.0" icss-utils "^2.1.0" loader-utils "^1.0.2" lodash.camelcase "^4.3.0" - object-assign "^4.0.1" + object-assign "^4.1.1" postcss "^5.0.6" - postcss-modules-extract-imports "^1.0.0" - postcss-modules-local-by-default "^1.0.1" - postcss-modules-scope "^1.0.0" - postcss-modules-values "^1.1.0" + postcss-modules-extract-imports "^1.1.0" + postcss-modules-local-by-default "^1.2.0" + postcss-modules-scope "^1.1.0" + postcss-modules-values "^1.3.0" postcss-value-parser "^3.3.0" source-list-map "^2.0.0" @@ -2368,7 +2423,7 @@ cssesc@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4" -"cssnano@>=2.6.1 <4": +cssnano@^3.10.0: version "3.10.0" resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-3.10.0.tgz#4f38f6cea2b9b17fa01490f23f1dc68ea65c1c38" dependencies: @@ -2412,7 +2467,7 @@ csso@~2.3.1: clap "^1.0.9" source-map "^0.5.3" -cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0": +"cssom@>= 0.3.2 < 0.4.0", cssom@0.3.x: version "0.3.2" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.2.tgz#b8036170c79f07a90ff2f16e22284027a243848b" @@ -2451,9 +2506,11 @@ cyclist@~0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" -d3@^3, d3@^3.5.17: - version "3.5.17" - resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.17.tgz#bc46748004378b21a360c9fc7cf5231790762fb8" +d@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/d/-/d-0.1.1.tgz#da184c535d18d8ee7ba2aa229b914009fae11309" + dependencies: + es5-ext "~0.10.2" d@1: version "1.0.0" @@ -2461,11 +2518,9 @@ d@1: dependencies: es5-ext "^0.10.9" -d@~0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/d/-/d-0.1.1.tgz#da184c535d18d8ee7ba2aa229b914009fae11309" - dependencies: - es5-ext "~0.10.2" +d3@^3, d3@^3.5.17: + version "3.5.17" + resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.17.tgz#bc46748004378b21a360c9fc7cf5231790762fb8" dashdash@^1.12.0: version "1.14.1" @@ -2482,19 +2537,25 @@ date-now@^0.1.4: resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" dc@^2.0.0: - version "2.1.8" - resolved "https://registry.yarnpkg.com/dc/-/dc-2.1.8.tgz#460b22fdeadb256ed9515bf9810104be73d3c920" + version "2.1.9" + resolved "https://registry.yarnpkg.com/dc/-/dc-2.1.9.tgz#693621dbbded18c4dac5023d7bd6a0c1215cd99a" dependencies: crossfilter2 "~1.3" d3 "^3" -debug@2, debug@2.6.9, debug@^2.1.1, debug@^2.2.0, debug@^2.3.3, debug@^2.6.3, debug@^2.6.6, debug@^2.6.8, debug@~2.6.7: +debug@^2.1.1, debug@^2.2.0, debug@^2.3.3, debug@^2.6.6, debug@^2.6.8, debug@^2.6.9, debug@~2.6.7, debug@2, debug@2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" dependencies: ms "2.0.0" -debug@2.2.0, debug@~2.2.0: +debug@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + dependencies: + ms "2.0.0" + +debug@~2.2.0, debug@2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/debug/-/debug-2.2.0.tgz#f87057e995b1a1f6ae6a4960664137bc56f039da" dependencies: @@ -2506,30 +2567,22 @@ debug@2.3.3: dependencies: ms "0.7.2" -debug@2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.1.tgz#79855090ba2c4e3115cc7d8769491d58f0491351" - dependencies: - ms "0.7.2" - -debug@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" - dependencies: - ms "2.0.0" - decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" -dedent@0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.6.0.tgz#0e6da8f0ce52838ef5cec5c8f9396b0c1b64a3cb" +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" +dedent@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.6.0.tgz#0e6da8f0ce52838ef5cec5c8f9396b0c1b64a3cb" + deep-diff@^0.3.5: version "0.3.8" resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-0.3.8.tgz#c01de63efb0eec9798801d40c7e0dae25b582c84" @@ -2606,7 +2659,7 @@ delegates@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" -depd@1.1.1, depd@~1.1.0, depd@~1.1.1: +depd@~1.1.1, depd@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359" @@ -2633,15 +2686,19 @@ detect-indent@^4.0.0: dependencies: repeating "^2.0.0" +detect-libc@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + detect-node@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.3.tgz#a2033c09cc8e158d37748fbde7507832bd6ce127" detective@^4.0.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/detective/-/detective-4.5.0.tgz#6e5a8c6b26e6c7a254b1c6b6d7490d98ec91edd1" + version "4.7.1" + resolved "https://registry.yarnpkg.com/detective/-/detective-4.7.1.tgz#0eca7314338442febb6d65da54c10bb1c82b246e" dependencies: - acorn "^4.0.3" + acorn "^5.2.1" defined "^1.0.0" di@^0.0.1: @@ -2695,7 +2752,7 @@ doctrine-temporary-fork@2.0.0-alpha-allowarrayindex: esutils "^2.0.2" isarray "^1.0.0" -doctrine@1.5.0, doctrine@^1.2.2: +doctrine@^1.2.2, doctrine@1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" dependencies: @@ -2703,11 +2760,10 @@ doctrine@1.5.0, doctrine@^1.2.2: isarray "^1.0.0" doctrine@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.0.0.tgz#c73d8d2909d22291e1a007a395804da8b665fe63" + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" dependencies: esutils "^2.0.2" - isarray "^1.0.0" documentation@^4.0.0-rc.1: version "4.0.0" @@ -2770,8 +2826,8 @@ dom-converter@~0.1: utila "~0.3" "dom-helpers@^2.4.0 || ^3.0.0", dom-helpers@^3.2.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a" + version "3.3.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.3.1.tgz#fc1a4e15ffdf60ddde03a480a9c0fece821dd4a6" dom-serialize@^2.2.0: version "2.2.1" @@ -2782,7 +2838,7 @@ dom-serialize@^2.2.0: extend "^3.0.0" void-elements "^2.0.0" -dom-serializer@0, dom-serializer@~0.1.0: +dom-serializer@~0.1.0, dom-serializer@0: version "0.1.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82" dependencies: @@ -2793,7 +2849,7 @@ domain-browser@^1.1.1: version "1.1.7" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.1.7.tgz#867aa4b093faa05f1de08c06f4d7b21fdf8698bc" -domelementtype@1, domelementtype@^1.3.0: +domelementtype@^1.3.0, domelementtype@1: version "1.3.0" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.0.tgz#b17aed82e8ab59e52dd9c19b1756e0fc187204c2" @@ -2801,16 +2857,23 @@ domelementtype@~1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.1.3.tgz#bd28773e2642881aec51544924299c5cd822185b" +domhandler@^2.3.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.1.tgz#892e47000a99be55bbf3774ffea0561d8879c259" + dependencies: + domelementtype "1" + domhandler@2.1: version "2.1.0" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.1.0.tgz#d2646f5e57f6c3bab11cf6cb05d3c0acf7412594" dependencies: domelementtype "1" -domhandler@^2.3.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.1.tgz#892e47000a99be55bbf3774ffea0561d8879c259" +domutils@^1.5.1: + version "1.6.2" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.6.2.tgz#1958cc0b4c9426e9ed367fb1c8e854891b0fa3ff" dependencies: + dom-serializer "0" domelementtype "1" domutils@1.1: @@ -2819,7 +2882,7 @@ domutils@1.1: dependencies: domelementtype "1" -domutils@1.5.1, domutils@^1.5.1: +domutils@1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" dependencies: @@ -2854,17 +2917,19 @@ ecdsa-sig-formatter@1.0.9: base64url "^2.0.0" safe-buffer "^5.0.1" -editions@^1.1.1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/editions/-/editions-1.3.3.tgz#0907101bdda20fac3cbe334c27cbd0688dc99a5b" - ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" +electron-releases@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/electron-releases/-/electron-releases-2.1.0.tgz#c5614bf811f176ce3c836e368a0625782341fd4e" + electron-to-chromium@^1.2.7: - version "1.3.26" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.26.tgz#996427294861a74d9c7c82b9260ea301e8c02d66" + version "1.3.30" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.30.tgz#9666f532a64586651fc56a72513692e820d06a80" + dependencies: + electron-releases "^2.1.0" elegant-spinner@^1.0.1: version "1.0.1" @@ -2894,7 +2959,7 @@ encodeurl@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.1.tgz#79e3d58655346909fe6f0f45a5de68103b294d20" -encoding@0.1.12, encoding@^0.1.11, encoding@^0.1.12: +encoding@^0.1.11, encoding@^0.1.12, encoding@0.1.12: version "0.1.12" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" dependencies: @@ -2970,7 +3035,7 @@ entities@^1.1.1, entities@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0" -enzyme@^2.7.0: +enzyme@2: version "2.9.1" resolved "https://registry.yarnpkg.com/enzyme/-/enzyme-2.9.1.tgz#07d5ce691241240fb817bf2c4b18d6e530240df6" dependencies: @@ -2990,10 +3055,10 @@ err-code@^0.1.0: resolved "https://registry.yarnpkg.com/err-code/-/err-code-0.1.2.tgz#122a92b3342b9899da02b5ac994d30f95d4763ee" errno@^0.1.3, errno@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.4.tgz#b896e23a9e5e8ba33871fc996abd3635fc9a1c7d" + version "0.1.6" + resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.6.tgz#c386ce8a6283f14fc09563b71560908c9bf53026" dependencies: - prr "~0.0.0" + prr "~1.0.1" error-ex@^1.2.0: version "1.3.1" @@ -3008,14 +3073,15 @@ error@^7.0.0: string-template "~0.2.1" xtend "~4.0.0" -es-abstract@^1.6.1, es-abstract@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.7.0.tgz#dfade774e01bfcd97f96180298c449c8623fb94c" +es-abstract@^1.5.1, es-abstract@^1.6.1, es-abstract@^1.7.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.10.0.tgz#1ecb36c197842a00d8ee4c2dfd8646bb97d60864" dependencies: es-to-primitive "^1.1.1" - function-bind "^1.1.0" + function-bind "^1.1.1" + has "^1.0.1" is-callable "^1.1.3" - is-regex "^1.0.3" + is-regex "^1.0.4" es-to-primitive@^1.1.1: version "1.1.1" @@ -3025,20 +3091,20 @@ es-to-primitive@^1.1.1: is-date-object "^1.0.1" is-symbol "^1.0.1" -es5-ext@^0.10.14, es5-ext@^0.10.9, es5-ext@~0.10.11, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.5, es5-ext@~0.10.6: - version "0.10.33" - resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.33.tgz#f5db913c35f67836d5bfc03535bec83cde34ea03" +es5-ext@^0.10.14, es5-ext@^0.10.35, es5-ext@^0.10.9, es5-ext@~0.10.11, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.5, es5-ext@~0.10.6: + version "0.10.37" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.37.tgz#0ee741d148b80069ba27d020393756af257defc3" dependencies: es6-iterator "~2.0.1" es6-symbol "~3.1.1" es6-iterator@^2.0.1, es6-iterator@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.1.tgz#8e319c9f0453bf575d374940a655920e59ca5512" + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" dependencies: d "1" - es5-ext "^0.10.14" - es6-symbol "^3.1" + es5-ext "^0.10.35" + es6-symbol "^3.1.1" es6-iterator@~0.1.3: version "0.1.3" @@ -3073,7 +3139,7 @@ es6-set@~0.1.5: es6-symbol "3.1.1" event-emitter "~0.3.5" -es6-symbol@3.1.1, es6-symbol@^3.1, es6-symbol@^3.1.1, es6-symbol@~3.1.1: +es6-symbol@^3.1.1, es6-symbol@~3.1.1, es6-symbol@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77" dependencies: @@ -3134,15 +3200,15 @@ escope@^3.6.0: estraverse "^4.1.1" eslint-import-resolver-node@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.1.tgz#4422574cde66a9a7b099938ee4d508a199e0e3cc" + version "0.3.2" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.2.tgz#58f15fb839b8d0576ca980413476aab2472db66a" dependencies: - debug "^2.6.8" - resolve "^1.2.0" + debug "^2.6.9" + resolve "^1.5.0" eslint-import-resolver-webpack@^0.8.3: - version "0.8.3" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-webpack/-/eslint-import-resolver-webpack-0.8.3.tgz#ad61e28df378a474459d953f246fd43f92675385" + version "0.8.4" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-webpack/-/eslint-import-resolver-webpack-0.8.4.tgz#0f7cd74bc9d7fc1773e8d5fc25baf864b2f87a42" dependencies: array-find "^1.0.0" debug "^2.6.8" @@ -3152,7 +3218,7 @@ eslint-import-resolver-webpack@^0.8.3: interpret "^1.0.0" is-absolute "^0.2.3" lodash.get "^3.7.0" - node-libs-browser "^1.0.0" + node-libs-browser "^1.0.0 || ^2.0.0" resolve "^1.2.0" semver "^5.3.0" @@ -3174,14 +3240,14 @@ eslint-module-utils@^2.1.1: pkg-dir "^1.0.0" eslint-plugin-flowtype@^2.22.0: - version "2.39.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-2.39.1.tgz#b5624622a0388bcd969f4351131232dcb9649cd5" + version "2.41.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-2.41.0.tgz#fd5221c60ba917c059d7ef69686a99cca09fd871" dependencies: lodash "^4.15.0" eslint-plugin-import@^2.2.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.7.0.tgz#21de33380b9efb55f5ef6d2e210ec0e07e7fa69f" + version "2.8.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.8.0.tgz#fa1b6ef31fcb3c501c09859c1b86f1fc5b986894" dependencies: builtin-modules "^1.1.1" contains-path "^0.1.0" @@ -3249,10 +3315,10 @@ eslint@^3.5.0: user-home "^2.0.0" espree@^3.4.0: - version "3.5.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.1.tgz#0c988b8ab46db53100a1954ae4ba995ddd27d87e" + version "3.5.2" + resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.2.tgz#756ada8b979e9dcfcdb30aad8d1a9304a905e1ca" dependencies: - acorn "^5.1.1" + acorn "^5.2.1" acorn-jsx "^3.0.0" esprima@^2.6.0: @@ -3299,7 +3365,7 @@ event-emitter@~0.3.4, event-emitter@~0.3.5: d "1" es5-ext "~0.10.14" -eventemitter3@1.x.x, eventemitter3@^1.1.1: +eventemitter3@^1.1.1, eventemitter3@1.x.x: version "1.2.0" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-1.2.0.tgz#1c86991d816ad1e504750e73874224ecf3bec508" @@ -3392,7 +3458,7 @@ exports-loader@^0.6.3: loader-utils "^1.0.2" source-map "0.5.x" -express@^4.13.3: +express@^4.16.2: version "4.16.2" resolved "https://registry.yarnpkg.com/express/-/express-4.16.2.tgz#e35c6dfe2d64b7dca0a5cd4f21781be3299e076c" dependencies: @@ -3433,9 +3499,16 @@ extend-shallow@^2.0.1: dependencies: is-extendable "^0.1.0" -extend@3, extend@^3.0.0, extend@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.0.tgz#5a474353b9f3353ddd8176dfd37b91c83a46f1d4" +extend-shallow@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +extend@^3.0.0, extend@~3.0.0, extend@~3.0.1, extend@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" extglob@^0.3.1: version "0.3.2" @@ -3444,8 +3517,8 @@ extglob@^0.3.1: is-extglob "^1.0.0" extglob@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.2.tgz#3290f46208db1b2e8eb8be0c94ed9e6ad80edbe2" + version "2.0.3" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.3.tgz#55e019d0c95bf873949c737b7e5172dba84ebb29" dependencies: array-unique "^0.3.2" define-property "^1.0.0" @@ -3457,15 +3530,19 @@ extglob@^2.0.2: to-regex "^3.0.1" extract-text-webpack-plugin@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/extract-text-webpack-plugin/-/extract-text-webpack-plugin-3.0.1.tgz#605a8893faca1dd49bb0d2ca87493f33fd43d102" + version "3.0.2" + resolved "https://registry.yarnpkg.com/extract-text-webpack-plugin/-/extract-text-webpack-plugin-3.0.2.tgz#5f043eaa02f9750a9258b78c0a6e0dc1408fb2f7" dependencies: async "^2.4.1" loader-utils "^1.1.0" schema-utils "^0.3.0" webpack-sources "^1.0.1" -extsprintf@1.3.0, extsprintf@^1.2.0: +extsprintf@^1.2.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" + +extsprintf@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" @@ -3477,6 +3554,10 @@ fast-deep-equal@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff" +fast-json-stable-stringify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" + fast-levenshtein@~2.0.4: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" @@ -3688,6 +3769,14 @@ form-data@~2.1.1: combined-stream "^1.0.5" mime-types "^2.1.12" +form-data@~2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.1.tgz#6fb94fbd71885306d73d15cc497fe4cc4ecd44bf" + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.5" + mime-types "^2.1.12" + forwarded@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" @@ -3732,8 +3821,8 @@ fs-promise@^2.0.2: thenify-all "^1.6.0" fs-readdir-recursive@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs-readdir-recursive/-/fs-readdir-recursive-1.0.0.tgz#8cd1745c8b4f8a29c8caec392476921ba195f560" + version "1.1.0" + resolved "https://registry.yarnpkg.com/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz#e32fc030a2ccee44a6b5371308da54be0b397d27" fs-write-stream-atomic@^1.0.8: version "1.0.10" @@ -3749,11 +3838,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" fsevents@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.1.2.tgz#3282b713fb3ad80ede0e9fcf4611b5aa6fc033f4" + version "1.1.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.1.3.tgz#11f82318f5fe7bb2cd22965a108e9306208216d8" dependencies: nan "^2.3.0" - node-pre-gyp "^0.6.36" + node-pre-gyp "^0.6.39" fstream-ignore@^1.0.5: version "1.0.5" @@ -3772,16 +3861,16 @@ fstream@^1.0.0, fstream@^1.0.10, fstream@^1.0.2: mkdirp ">=0.5 0" rimraf "2" -function-bind@^1.0.2, function-bind@^1.1.0: +function-bind@^1.0.2, function-bind@^1.1.0, function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" function.prototype.name@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.0.3.tgz#0099ae5572e9dd6f03c97d023fd92bcc5e639eac" + version "1.1.0" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.0.tgz#8bd763cc0af860a859cc5d49384d74b932cd2327" dependencies: define-properties "^1.1.2" - function-bind "^1.1.0" + function-bind "^1.1.1" is-callable "^1.1.3" gauge@~2.7.3: @@ -3858,8 +3947,8 @@ gettext-parser@1.2.2: encoding "0.1.12" git-up@^2.0.0: - version "2.0.9" - resolved "https://registry.yarnpkg.com/git-up/-/git-up-2.0.9.tgz#219bfd27c82daeead8495beb386dc18eae63636d" + version "2.0.10" + resolved "https://registry.yarnpkg.com/git-up/-/git-up-2.0.10.tgz#20fe6bafbef4384cae253dc4f463c49a0c3bd2ec" dependencies: is-ssh "^1.3.0" parse-url "^1.3.0" @@ -3870,12 +3959,30 @@ git-url-parse@^6.0.1: dependencies: git-up "^2.0.0" -github-slugger@1.1.3, github-slugger@^1.0.0, github-slugger@^1.1.1: +gitbook-plugin-atoc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/gitbook-plugin-atoc/-/gitbook-plugin-atoc-1.0.3.tgz#622e334d8aa15ee2b9cb772f97f89d84271f4297" + dependencies: + github-slugid "1.0.1" + markdown-toc "^0.11.7" + +github-slugger@^1.0.0, github-slugger@^1.1.1, github-slugger@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.1.3.tgz#314a6e759a18c2b0cc5760d512ccbab549c549a7" dependencies: emoji-regex ">=6.0.0 <=6.1.1" +github-slugid@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/github-slugid/-/github-slugid-1.0.1.tgz#bccdd0815bfad69d8a359fa4fd65947d606ec3c0" + +glob-all@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/glob-all/-/glob-all-3.1.0.tgz#8913ddfb5ee1ac7812656241b03d5217c64b02ab" + dependencies: + glob "^7.0.5" + yargs "~1.2.6" + glob-base@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" @@ -3889,7 +3996,7 @@ glob-parent@^2.0.0: dependencies: is-glob "^2.0.0" -glob-parent@^3.0.0: +glob-parent@^3.0.0, glob-parent@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" dependencies: @@ -3942,8 +4049,8 @@ glob@^7.1.1: path-is-absolute "^1.0.0" globals-docs@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/globals-docs/-/globals-docs-2.3.0.tgz#dca4088af196f7800f4eba783eaeff335cb6759c" + version "2.4.0" + resolved "https://registry.yarnpkg.com/globals-docs/-/globals-docs-2.4.0.tgz#f2c647544eb6161c7c38452808e16e693c2dafbb" globals@^9.14.0, globals@^9.18.0: version "9.18.0" @@ -3986,6 +4093,16 @@ graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.4, version "1.0.1" resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" +gray-matter@^2.0.2: + version "2.1.1" + resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-2.1.1.tgz#3042d9adec2a1ded6a7707a9ed2380f8a17a430e" + dependencies: + ansi-red "^0.1.1" + coffee-script "^1.12.4" + extend-shallow "^2.0.1" + js-yaml "^3.8.1" + toml "^2.3.2" + growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" @@ -4005,8 +4122,8 @@ handle-thing@^1.2.5: resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4" handlebars@^4.0.3: - version "4.0.10" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.10.tgz#3d30c718b09a3d96f23ea4cc1f403c4d3ba9ff4f" + version "4.0.11" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.11.tgz#630a35dfe0294bc281edae6ffc5d329fc7982dcc" dependencies: async "^1.4.0" optimist "^0.6.1" @@ -4018,6 +4135,10 @@ har-schema@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e" +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + har-validator@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-2.0.6.tgz#cdcbc08188265ad119b6a5a7c8ab70eecfb5d27d" @@ -4034,6 +4155,13 @@ har-validator@~4.2.1: ajv "^4.9.1" har-schema "^1.0.5" +har-validator@~5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.0.3.tgz#ba402c266194f15956ef15e0fcf242993f6a7dfd" + dependencies: + ajv "^5.1.0" + har-schema "^2.0.0" + has-ansi@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-0.1.0.tgz#84f265aae8c0e6a88a12d7022894b7568894c62e" @@ -4064,6 +4192,10 @@ has-flag@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51" +has-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44" + has-unicode@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" @@ -4151,7 +4283,7 @@ hast-util-whitespace@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-1.0.0.tgz#bd096919625d2936e1ff17bc4df7fd727f17ece9" -hawk@3.1.3, hawk@~3.1.3: +hawk@~3.1.3, hawk@3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4" dependencies: @@ -4160,6 +4292,15 @@ hawk@3.1.3, hawk@~3.1.3: hoek "2.x.x" sntp "1.x.x" +hawk@~6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/hawk/-/hawk-6.0.2.tgz#af4d914eb065f9b5ce4d9d11c1cb2126eecc3038" + dependencies: + boom "4.x.x" + cryptiles "3.x.x" + hoek "4.x.x" + sntp "2.x.x" + he@1.1.x: version "1.1.1" resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" @@ -4168,7 +4309,7 @@ highlight.js@^9.1.0: version "9.12.0" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.12.0.tgz#e6d9dbe57cbefe60751f02af336195870c90c01e" -history@3, history@^3.0.0: +history@^3.0.0, history@3: version "3.3.0" resolved "https://registry.yarnpkg.com/history/-/history-3.3.0.tgz#fcedcce8f12975371545d735461033579a6dae9c" dependencies: @@ -4189,11 +4330,15 @@ hoek@2.x.x: version "2.16.3" resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" -hoist-non-react-statics@1.2.0, hoist-non-react-statics@^1.0.0, hoist-non-react-statics@^1.0.5, hoist-non-react-statics@^1.2.0: +hoek@4.x.x: + version "4.2.0" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d" + +hoist-non-react-statics@^1.0.5, hoist-non-react-statics@^1.2.0, hoist-non-react-statics@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb" -hoist-non-react-statics@^2.2.1: +hoist-non-react-statics@^2.2.1, hoist-non-react-statics@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.3.1.tgz#343db84c6018c650778898240135a1420ee22ce0" @@ -4222,8 +4367,8 @@ html-comment-regex@^1.1.0: resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e" html-encoding-sniffer@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.1.tgz#79bf7a785ea495fe66165e734153f363ff5437da" + version "1.0.2" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz#e70d84b94da53aa375e11fe3a351be6642ca46f8" dependencies: whatwg-encoding "^1.0.1" @@ -4232,17 +4377,17 @@ html-entities@^1.2.0: resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f" html-minifier@^3.2.3: - version "3.5.5" - resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-3.5.5.tgz#3bdc9427e638bbe3dbde96c0eb988b044f02739e" + version "3.5.8" + resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-3.5.8.tgz#5ccdb1f73a0d654e6090147511f6e6b2ee312700" dependencies: camel-case "3.0.x" clean-css "4.1.x" - commander "2.11.x" + commander "2.12.x" he "1.1.x" ncname "1.0.x" param-case "2.1.x" relateurl "0.2.x" - uglify-js "3.1.x" + uglify-js "3.3.x" html-void-elements@^1.0.0: version "1.0.2" @@ -4289,7 +4434,7 @@ http-deceiver@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" -http-errors@1.6.2, http-errors@~1.6.1, http-errors@~1.6.2: +http-errors@~1.6.2, http-errors@1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736" dependencies: @@ -4326,9 +4471,17 @@ http-signature@~1.1.0: jsprim "^1.2.2" sshpk "^1.7.0" -https-browserify@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82" +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +https-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" https-proxy-agent@^1.0.0, https-proxy-agent@~1.0.0: version "1.0.0" @@ -4346,15 +4499,7 @@ icepick@^1.1.0: version "1.3.0" resolved "https://registry.yarnpkg.com/icepick/-/icepick-1.3.0.tgz#e4942842ed8f9ee778d7dd78f7e36627f49fdaef" -iconv-lite@0.4.13: - version "0.4.13" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2" - -iconv-lite@0.4.15, iconv-lite@~0.4.13: - version "0.4.15" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.15.tgz#fe265a218ac6a57cfe854927e9d04c19825eddeb" - -iconv-lite@0.4.19: +iconv-lite@~0.4.13, iconv-lite@0.4.19: version "0.4.19" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" @@ -4377,12 +4522,12 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" iframe-resizer@^3.5.11: - version "3.5.14" - resolved "https://registry.yarnpkg.com/iframe-resizer/-/iframe-resizer-3.5.14.tgz#db770e8f1cf63d2c77d10d0e18edc2524bf36fb4" + version "3.5.15" + resolved "https://registry.yarnpkg.com/iframe-resizer/-/iframe-resizer-3.5.15.tgz#67ae959cc07478cdfb71347ead8ebfa95bd9824a" ignore@^3.2.0: - version "3.3.5" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.5.tgz#c4e715455f6073a8d7e5dae72d2fc9d71663dba6" + version "3.3.7" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.7.tgz#612289bfb3c220e186a58118618d5be8c1bab021" image-diff@^1.6.3: version "1.6.3" @@ -4399,6 +4544,13 @@ image-exists@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/image-exists/-/image-exists-1.1.0.tgz#ba49cccbaddca8cbbf10f89cafd4d1c8ecfd38d0" +import-local@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc" + dependencies: + pkg-dir "^2.0.0" + resolve-cwd "^2.0.0" + imports-loader@^0.7.0: version "0.7.1" resolved "https://registry.yarnpkg.com/imports-loader/-/imports-loader-0.7.1.tgz#f204b5f34702a32c1db7d48d89d5e867a0441253" @@ -4439,7 +4591,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.0, inherits@~2.0.1: +inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3, inherits@2, inherits@2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" @@ -4448,8 +4600,8 @@ inherits@2.0.1: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" ini@^1.3.3, ini@~1.3.0: - version "1.3.4" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e" + version "1.3.5" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" inquirer@^0.12.0: version "0.12.0" @@ -4484,8 +4636,8 @@ internal-ip@1.2.0: meow "^3.3.0" interpret@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.4.tgz#820cdd588b868ffb191a809506d6c9c8f212b1b0" + version "1.1.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614" invariant@^2.0.0, invariant@^2.2.1, invariant@^2.2.2: version "2.2.2" @@ -4516,12 +4668,25 @@ is-absolute@^0.2.3: is-relative "^0.2.1" is-windows "^0.2.0" +is-absolute@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-1.0.0.tgz#395e1ae84b11f26ad1795e73c17378e48a301576" + dependencies: + is-relative "^1.0.0" + is-windows "^1.0.1" + is-accessor-descriptor@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" dependencies: kind-of "^3.0.2" +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + dependencies: + kind-of "^6.0.0" + is-alphabetical@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.1.tgz#c77079cc91d4efac775be1034bf2d243f95e6f08" @@ -4552,8 +4717,8 @@ is-binary-path@^1.0.0: binary-extensions "^1.0.0" is-buffer@^1.1.4, is-buffer@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.5.tgz#1f3b26ef613b214b88cbca23cc6c01d87961eecc" + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" is-builtin-module@^1.0.0: version "1.0.0" @@ -4566,8 +4731,8 @@ is-callable@^1.1.1, is-callable@^1.1.3: resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2" is-ci@^1.0.9: - version "1.0.10" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.0.10.tgz#f739336b2632365061a9d48270cd56ae3369318e" + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.1.0.tgz#247e4162e7860cebbdaf30b774d6b0ac7dcfe7a5" dependencies: ci-info "^1.0.0" @@ -4577,6 +4742,12 @@ is-data-descriptor@^0.1.4: dependencies: kind-of "^3.0.2" +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + dependencies: + kind-of "^6.0.0" + is-date-object@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16" @@ -4594,12 +4765,12 @@ is-descriptor@^0.1.0: kind-of "^5.0.0" is-descriptor@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.1.tgz#2c6023599bde2de9d5d2c8b9a9d94082036b6ef2" + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" dependencies: - is-accessor-descriptor "^0.1.6" - is-data-descriptor "^0.1.4" - kind-of "^5.0.0" + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" is-directory@^0.3.1: version "0.3.1" @@ -4619,11 +4790,17 @@ is-extendable@^0.1.0, is-extendable@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + dependencies: + is-plain-object "^2.0.4" + is-extglob@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" -is-extglob@^2.1.0: +is-extglob@^2.1.0, is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -4655,13 +4832,19 @@ is-glob@^3.1.0: dependencies: is-extglob "^2.1.0" +is-glob@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.0.tgz#9521c76845cc2610a85203ddf080a958c2ffabc0" + dependencies: + is-extglob "^2.1.1" + is-hexadecimal@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.1.tgz#6e084bbc92061fbb0971ec58b6ce6d404e24da69" is-my-json-valid@^2.10.0, is-my-json-valid@^2.12.4: - version "2.16.1" - resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.16.1.tgz#5a846777e2c2620d1e69104e5d3a03b1f6088f11" + version "2.17.1" + resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.17.1.tgz#3da98914a70a22f0a8563ef1511a246c6fc55471" dependencies: generate-function "^2.0.0" generate-object-property "^1.1.0" @@ -4705,8 +4888,8 @@ is-path-in-cwd@^1.0.0: is-path-inside "^1.0.0" is-path-inside@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.0.tgz#fc06e5a1683fbda13de667aff717bbc10a48f37f" + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" dependencies: path-is-inside "^1.0.1" @@ -4714,7 +4897,7 @@ is-plain-obj@^1.0.0, is-plain-obj@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" -is-plain-object@^2.0.1, is-plain-object@^2.0.3: +is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4, is-plain-object@2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" dependencies: @@ -4736,7 +4919,7 @@ is-property@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" -is-regex@^1.0.3: +is-regex@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" dependencies: @@ -4752,11 +4935,15 @@ is-relative@^0.2.1: dependencies: is-unc-path "^0.1.1" -is-resolvable@^1.0.0: +is-relative@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.0.0.tgz#8df57c61ea2e3c501408d100fb013cf8d6e0cc62" + resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-1.0.0.tgz#a1bb6935ce8c5dba1e8b9754b9b2dcc020e2260d" dependencies: - tryit "^1.0.1" + is-unc-path "^1.0.0" + +is-resolvable@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.0.1.tgz#acca1cd36dbe44b974b924321555a70ba03b1cf4" is-retina@^1.0.3: version "1.0.3" @@ -4796,6 +4983,12 @@ is-unc-path@^0.1.1: dependencies: unc-path-regex "^0.1.0" +is-unc-path@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-unc-path/-/is-unc-path-1.0.0.tgz#d731e8898ed090a12c352ad2eaed5095ad322c9d" + dependencies: + unc-path-regex "^0.1.2" + is-utf8@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" @@ -4812,6 +5005,10 @@ is-windows@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-0.2.0.tgz#de1aa6d63ea29dd248737b69f1ff8b8002d2108c" +is-windows@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.1.tgz#310db70f742d259a16a369202b51af84233310d9" + is-word-character@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-word-character/-/is-word-character-1.0.1.tgz#5a03fa1ea91ace8a6eb0c7cd770eb86d65c8befb" @@ -4820,14 +5017,14 @@ is-wsl@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" +isarray@^1.0.0, isarray@~1.0.0, isarray@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + isarray@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" -isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - isbinaryfile@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-3.0.2.tgz#4a3e974ec0cba9004d3fc6cde7209ea69368a621" @@ -4861,22 +5058,22 @@ isomorphic-fetch@^2.1.1, isomorphic-fetch@^2.2.1: node-fetch "^1.0.1" whatwg-fetch ">=0.10.0" -isstream@0.1.x, isstream@~0.1.2: +isstream@~0.1.2, isstream@0.1.x: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" istanbul-api@^1.1.0-alpha.1: - version "1.1.14" - resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-1.1.14.tgz#25bc5701f7c680c0ffff913de46e3619a3a6e680" + version "1.2.1" + resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-1.2.1.tgz#0c60a0515eb11c7d65c6b50bba2c6e999acd8620" dependencies: async "^2.1.4" fileset "^2.0.2" istanbul-lib-coverage "^1.1.1" - istanbul-lib-hook "^1.0.7" - istanbul-lib-instrument "^1.8.0" - istanbul-lib-report "^1.1.1" - istanbul-lib-source-maps "^1.2.1" - istanbul-reports "^1.1.2" + istanbul-lib-hook "^1.1.0" + istanbul-lib-instrument "^1.9.1" + istanbul-lib-report "^1.1.2" + istanbul-lib-source-maps "^1.2.2" + istanbul-reports "^1.1.3" js-yaml "^3.7.0" mkdirp "^0.5.1" once "^1.4.0" @@ -4885,15 +5082,15 @@ istanbul-lib-coverage@^1.0.0, istanbul-lib-coverage@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.1.tgz#73bfb998885299415c93d38a3e9adf784a77a9da" -istanbul-lib-hook@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-1.0.7.tgz#dd6607f03076578fe7d6f2a630cf143b49bacddc" +istanbul-lib-hook@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-1.1.0.tgz#8538d970372cb3716d53e55523dd54b557a8d89b" dependencies: append-transform "^0.4.0" -istanbul-lib-instrument@^1.1.1, istanbul-lib-instrument@^1.7.5, istanbul-lib-instrument@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-1.8.0.tgz#66f6c9421cc9ec4704f76f2db084ba9078a2b532" +istanbul-lib-instrument@^1.1.1, istanbul-lib-instrument@^1.7.5, istanbul-lib-instrument@^1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-1.9.1.tgz#250b30b3531e5d3251299fdd64b0b2c9db6b558e" dependencies: babel-generator "^6.18.0" babel-template "^6.16.0" @@ -4903,28 +5100,28 @@ istanbul-lib-instrument@^1.1.1, istanbul-lib-instrument@^1.7.5, istanbul-lib-ins istanbul-lib-coverage "^1.1.1" semver "^5.3.0" -istanbul-lib-report@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-1.1.1.tgz#f0e55f56655ffa34222080b7a0cd4760e1405fc9" +istanbul-lib-report@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-1.1.2.tgz#922be27c13b9511b979bd1587359f69798c1d425" dependencies: istanbul-lib-coverage "^1.1.1" mkdirp "^0.5.1" path-parse "^1.0.5" supports-color "^3.1.2" -istanbul-lib-source-maps@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.1.tgz#a6fe1acba8ce08eebc638e572e294d267008aa0c" +istanbul-lib-source-maps@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.2.tgz#750578602435f28a0c04ee6d7d9e0f2960e62c1c" dependencies: - debug "^2.6.3" + debug "^3.1.0" istanbul-lib-coverage "^1.1.1" mkdirp "^0.5.1" rimraf "^2.6.1" source-map "^0.5.3" -istanbul-reports@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-1.1.2.tgz#0fb2e3f6aa9922bd3ce45d05d8ab4d5e8e07bd4f" +istanbul-reports@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-1.1.3.tgz#3b9e1e8defb6d18b1d425da8e8b32c5a163f2d10" dependencies: handlebars "^4.0.3" @@ -4937,8 +5134,8 @@ jasmine-promises@^0.4.1: resolved "https://registry.yarnpkg.com/jasmine-promises/-/jasmine-promises-0.4.1.tgz#386b63e2c714d33d9b1b7adae507773366dbf0ab" jasmine-reporters@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/jasmine-reporters/-/jasmine-reporters-2.2.1.tgz#de9a9201367846269e7ca8adff5b44221671fcbd" + version "2.3.0" + resolved "https://registry.yarnpkg.com/jasmine-reporters/-/jasmine-reporters-2.3.0.tgz#eb8cb7359658572a87eef4aa088a363036f3792a" dependencies: mkdirp "^0.5.1" xmldom "^0.1.22" @@ -5169,16 +5366,16 @@ joi@^6.10.1: topo "1.x.x" js-base64@^2.1.9: - version "2.3.2" - resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.3.2.tgz#a79a923666372b580f8e27f51845c6f7e8fbfbaf" + version "2.4.0" + resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.4.0.tgz#9e566fee624751a1d720c966cd6226d29d4025aa" js-base64@~2.1.8: version "2.1.9" resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.1.9.tgz#f0e80ae039a4bd654b5f281fc93f04a914a7fcce" js-cookie@^2.1.2: - version "2.1.4" - resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.1.4.tgz#da4ec503866f149d164cf25f579ef31015025d8d" + version "2.2.0" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.0.tgz#1b2c279a6eece380a12168b92485265b35b1effb" js-managed-css@^1.4.1: version "1.4.2" @@ -5191,7 +5388,7 @@ js-tokens@^3.0.0, js-tokens@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" -js-yaml@^3.4.3, js-yaml@^3.5.1, js-yaml@^3.7.0, js-yaml@^3.8.4: +js-yaml@^3.4.3, js-yaml@^3.5.1, js-yaml@^3.7.0, js-yaml@^3.8.1, js-yaml@^3.8.4: version "3.10.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.10.0.tgz#2e78441646bd4682e963f22b6e92823c309c62dc" dependencies: @@ -5263,7 +5460,7 @@ json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" -json3@3.3.2, json3@^3.3.2: +json3@^3.3.2, json3@3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" @@ -5289,9 +5486,16 @@ jsonpointer@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9" -jsonwebtoken@^7.2.1: - version "7.4.3" - resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-7.4.3.tgz#77f5021de058b605a1783fa1283e99812e645638" +JSONStream@^1.0.3: + version "1.3.2" + resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.2.tgz#c102371b6ec3a7cf3b847ca00c20bb0fce4c6dea" + dependencies: + jsonparse "^1.2.0" + through ">=2.2.7 <3" + +jsonwebtoken@^7.2.1: + version "7.4.3" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-7.4.3.tgz#77f5021de058b605a1783fa1283e99812e645638" dependencies: joi "^6.10.1" jws "^3.1.4" @@ -5341,8 +5545,8 @@ karma-chrome-launcher@^2.0.0: which "^1.2.1" karma-jasmine@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/karma-jasmine/-/karma-jasmine-1.1.0.tgz#22e4c06bf9a182e5294d1f705e3733811b810acf" + version "1.1.1" + resolved "https://registry.yarnpkg.com/karma-jasmine/-/karma-jasmine-1.1.1.tgz#6fe840e75a11600c9d91e84b33c458e1c46a3529" karma-junit-reporter@^1.1.0: version "1.2.0" @@ -5358,14 +5562,14 @@ karma-nyan-reporter@^0.2.2: cli-color "^0.3.2" karma-webpack@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/karma-webpack/-/karma-webpack-2.0.5.tgz#4f56887e32cf4f9583391c2388415de06af06efd" + version "2.0.9" + resolved "https://registry.yarnpkg.com/karma-webpack/-/karma-webpack-2.0.9.tgz#61c88091f7dd910635134c032b266a465affb57f" dependencies: async "~0.9.0" loader-utils "^0.2.5" lodash "^3.8.0" - source-map "^0.1.41" - webpack-dev-middleware "^1.0.11" + source-map "^0.5.6" + webpack-dev-middleware "^1.12.0" karma@^1.3.0: version "1.7.1" @@ -5403,6 +5607,10 @@ kebab-case@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/kebab-case/-/kebab-case-1.0.0.tgz#3f9e4990adcad0c686c0e701f7645868f75f91eb" +killable@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.0.tgz#da8b84bd47de5395878f95d64d02f2449fe05e6b" + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -5419,7 +5627,11 @@ kind-of@^5.0.0, kind-of@^5.0.2: version "5.1.0" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" -lazy-cache@^1.0.3: +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" + +lazy-cache@^1.0.2, lazy-cache@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" @@ -5449,7 +5661,7 @@ leaflet.heat@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/leaflet.heat/-/leaflet.heat-0.2.0.tgz#109d8cf586f0adee41f05aff031e27a77fecc229" -leaflet@^1.0.1: +leaflet@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.2.0.tgz#fd5d93d9cb00091f5f8a69206d0d6744c1c82697" @@ -5496,8 +5708,8 @@ listr-update-renderer@^0.2.0: strip-ansi "^3.0.1" listr-verbose-renderer@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/listr-verbose-renderer/-/listr-verbose-renderer-0.4.0.tgz#44dc01bb0c34a03c572154d4d08cde9b1dc5620f" + version "0.4.1" + resolved "https://registry.yarnpkg.com/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz#8206f4cf6d52ddc5827e5fd14989e0e965933a35" dependencies: chalk "^1.1.3" cli-cursor "^1.0.2" @@ -5757,12 +5969,12 @@ log4js@^0.6.31: semver "~4.3.3" loglevel@^1.4.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.5.1.tgz#189078c94ab9053ee215a0acdbf24244ea0f6502" + version "1.6.0" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.0.tgz#ae0caa561111498c5ba13723d6fb631d24003934" longest-streak@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.1.tgz#42d291b5411e40365c00e63193497e2247316e35" + version "2.0.2" + resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.2.tgz#2421b6ba939a443bb9ffebf596585a50b4c38e2e" longest@^1.0.1: version "1.0.1" @@ -5785,10 +5997,6 @@ lower-case@^1.1.1: version "1.1.4" resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac" -lru-cache@2.2.x: - version "2.2.4" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.2.4.tgz#6c658619becf14031d0d0b594b16042ce4dc063d" - lru-cache@^2.6.5: version "2.7.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952" @@ -5800,6 +6008,10 @@ lru-cache@^4.0.1, lru-cache@^4.1.1: pseudomap "^1.0.2" yallist "^2.1.2" +lru-cache@2.2.x: + version "2.2.4" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.2.4.tgz#6c658619becf14031d0d0b594b16042ce4dc063d" + lru-queue@0.1: version "0.1.0" resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" @@ -5811,10 +6023,10 @@ macaddress@^0.2.8: resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.2.8.tgz#5904dc537c39ec6dbefeae902327135fa8511f12" make-dir@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.0.0.tgz#97a011751e91dd87cfadef58832ebb04936de978" + version "1.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.1.0.tgz#19b4369fe48c116f53c2af95ad102c0e39e85d51" dependencies: - pify "^2.3.0" + pify "^3.0.0" makeerror@1.0.x: version "1.0.11" @@ -5840,10 +6052,28 @@ markdown-escapes@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.1.tgz#1994df2d3af4811de59a6714934c2b2292734518" +markdown-link@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/markdown-link/-/markdown-link-0.1.1.tgz#32c5c65199a6457316322d1e4229d13407c8c7cf" + markdown-table@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-1.1.1.tgz#4b3dd3a133d1518b8ef0dbc709bf2a1b4824bc8c" +markdown-toc@^0.11.7: + version "0.11.9" + resolved "https://registry.yarnpkg.com/markdown-toc/-/markdown-toc-0.11.9.tgz#961f34c1b2c31d282188eeefd4f907c8c07a6d4c" + dependencies: + concat-stream "^1.5.1" + gray-matter "^2.0.2" + lazy-cache "^1.0.2" + markdown-link "^0.1.1" + minimist "^1.2.0" + mixin-deep "^1.1.3" + object.pick "^1.1.1" + remarkable "^1.6.1" + repeat-string "^1.5.2" + math-expression-evaluator@^1.2.14: version "1.2.17" resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac" @@ -5990,19 +6220,19 @@ micromatch@^2.1.5, micromatch@^2.3.11, micromatch@^2.3.7: parse-glob "^3.0.4" regex-cache "^0.4.2" -micromatch@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.0.tgz#5102d4eaf20b6997d6008e3acfe1c44a3fa815e2" +micromatch@^3.0.0, micromatch@^3.1.4: + version "3.1.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.5.tgz#d05e168c206472dfbca985bfef4f57797b4cd4ba" dependencies: arr-diff "^4.0.0" array-unique "^0.3.2" - braces "^2.2.2" + braces "^2.3.0" define-property "^1.0.0" extend-shallow "^2.0.1" extglob "^2.0.2" fragment-cache "^0.2.1" - kind-of "^5.0.2" - nanomatch "^1.2.1" + kind-of "^6.0.0" + nanomatch "^1.2.5" object.pick "^1.3.0" regex-not "^1.0.0" snapdragon "^0.8.1" @@ -6015,7 +6245,11 @@ miller-rabin@^4.0.0: bn.js "^4.0.0" brorand "^1.0.1" -"mime-db@>= 1.29.0 < 2", mime-db@~1.30.0: +"mime-db@>= 1.30.0 < 2": + version "1.32.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.32.0.tgz#485b3848b01a3cda5f968b4882c0771e58e09414" + +mime-db@~1.30.0: version "1.30.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01" @@ -6025,7 +6259,11 @@ mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.16, dependencies: mime-db "~1.30.0" -mime@1.4.1, mime@^1.2.11, mime@^1.3.4: +mime@^1.2.11, mime@^1.3.4, mime@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + +mime@1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" @@ -6041,20 +6279,28 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" -"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4: +minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, "minimatch@2 || 3": version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" dependencies: brace-expansion "^1.1.7" -minimist@0.0.8, minimist@~0.0.1: - version "0.0.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" +minimist@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.1.0.tgz#99df657a52574c21c9057497df742790b2b4c0de" minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" +minimist@~0.0.1: + version "0.0.10" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" + +minimist@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + mississippi@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-1.3.0.tgz#d201583eb12327e3c5c1642a404a9cacf94e34f5" @@ -6070,14 +6316,21 @@ mississippi@^1.3.0: stream-each "^1.1.0" through2 "^2.0.0" +mixin-deep@^1.1.3: + version "1.3.1" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + mixin-deep@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.2.0.tgz#d02b8c6f8b6d4b8f5982d3fd009c4919851c3fe2" + version "1.3.0" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.0.tgz#47a8732ba97799457c8c1eca28f95132d7e8150a" dependencies: for-in "^1.0.2" - is-extendable "^0.1.1" + is-extendable "^1.0.1" -mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: +mkdirp@^0.5.0, mkdirp@^0.5.1, "mkdirp@>=0.5 0", mkdirp@~0.5.0, mkdirp@~0.5.1, mkdirp@0.5.1, mkdirp@0.5.x: version "0.5.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" dependencies: @@ -6095,13 +6348,13 @@ module-deps-sortable@4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/module-deps-sortable/-/module-deps-sortable-4.0.6.tgz#1251a4ba2c44a92df6989bd029da121a4f2109b0" dependencies: - JSONStream "^1.0.3" browser-resolve "^1.7.0" concat-stream "~1.5.0" defined "^1.0.0" detective "^4.0.0" duplexer2 "^0.1.2" inherits "^2.0.1" + JSONStream "^1.0.3" parents "^1.0.0" readable-stream "^2.0.2" resolve "^1.1.3" @@ -6110,9 +6363,13 @@ module-deps-sortable@4.0.6: through2 "^2.0.0" xtend "^4.0.0" -moment@2.14.1, moment@2.x.x: - version "2.14.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.14.1.tgz#b35b27c47e57ed2ddc70053d6b07becdb291741c" +moment@2.19.3: + version "2.19.3" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.3.tgz#bdb99d270d6d7fda78cc0fbace855e27fe7da69f" + +moment@2.x.x: + version "2.20.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.20.1.tgz#d6eb1a46cbcc14a2b2f9434112c1ff8907f313fd" move-concurrently@^1.0.1: version "1.0.1" @@ -6125,6 +6382,10 @@ move-concurrently@^1.0.1: rimraf "^2.5.4" run-queue "^1.0.3" +ms@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" + ms@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098" @@ -6133,7 +6394,7 @@ ms@0.7.2: version "0.7.2" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765" -ms@2.0.0, ms@^2.0.0: +ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -6142,8 +6403,8 @@ multicast-dns-service-types@^1.1.0: resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" multicast-dns@^6.0.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-6.1.1.tgz#6e7de86a570872ab17058adea7160bbeca814dde" + version "6.2.1" + resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-6.2.1.tgz#c5035defa9219d30640558a49298067352098060" dependencies: dns-packet "^1.0.1" thunky "^0.1.0" @@ -6161,12 +6422,12 @@ mz@^2.6.0: thenify-all "^1.0.0" nan@^2.3.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.7.0.tgz#d95bf721ec877e08db276ed3fc6eb78f9083ad46" + version "2.8.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.8.0.tgz#ed715f3fe9de02b57a5e6252d90a96675e1f085a" -nanomatch@^1.2.1: - version "1.2.3" - resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.3.tgz#15e1c02dcf990c27a283b08c0ba1801ce249a6a6" +nanomatch@^1.2.5: + version "1.2.7" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.7.tgz#53cd4aa109ff68b7f869591fdc9d10daeeea3e79" dependencies: arr-diff "^4.0.0" array-unique "^0.3.2" @@ -6194,14 +6455,14 @@ negotiator@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" -next-tick@1: - version "1.0.0" - resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" - next-tick@~0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-0.2.2.tgz#75da4a927ee5887e39065880065b7336413b310d" +next-tick@1: + version "1.0.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" + no-case@^2.2.0: version "2.3.2" resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac" @@ -6223,57 +6484,29 @@ node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" -node-libs-browser@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-1.1.1.tgz#2a38243abedd7dffcd07a97c9aca5668975a6fea" - dependencies: - assert "^1.1.1" - browserify-zlib "^0.1.4" - buffer "^4.3.0" - console-browserify "^1.1.0" - constants-browserify "^1.0.0" - crypto-browserify "^3.11.0" - domain-browser "^1.1.1" - events "^1.0.0" - https-browserify "0.0.1" - os-browserify "^0.2.0" - path-browserify "0.0.0" - process "^0.11.0" - punycode "^1.2.4" - querystring-es3 "^0.2.0" - readable-stream "^2.0.5" - stream-browserify "^2.0.1" - stream-http "^2.3.1" - string_decoder "^0.10.25" - timers-browserify "^1.4.2" - tty-browserify "0.0.0" - url "^0.11.0" - util "^0.10.3" - vm-browserify "0.0.4" - -node-libs-browser@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.0.0.tgz#a3a59ec97024985b46e958379646f96c4b616646" +"node-libs-browser@^1.0.0 || ^2.0.0", node-libs-browser@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.1.0.tgz#5f94263d404f6e44767d726901fff05478d600df" dependencies: assert "^1.1.1" - browserify-zlib "^0.1.4" + browserify-zlib "^0.2.0" buffer "^4.3.0" console-browserify "^1.1.0" constants-browserify "^1.0.0" crypto-browserify "^3.11.0" domain-browser "^1.1.1" events "^1.0.0" - https-browserify "0.0.1" - os-browserify "^0.2.0" + https-browserify "^1.0.0" + os-browserify "^0.3.0" path-browserify "0.0.0" - process "^0.11.0" + process "^0.11.10" punycode "^1.2.4" querystring-es3 "^0.2.0" - readable-stream "^2.0.5" + readable-stream "^2.3.3" stream-browserify "^2.0.1" - stream-http "^2.3.1" - string_decoder "^0.10.25" - timers-browserify "^2.0.2" + stream-http "^2.7.2" + string_decoder "^1.0.0" + timers-browserify "^2.0.4" tty-browserify "0.0.0" url "^0.11.0" util "^0.10.3" @@ -6288,10 +6521,11 @@ node-notifier@^5.0.1: shellwords "^0.1.0" which "^1.2.12" -node-pre-gyp@^0.6.36: - version "0.6.38" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.38.tgz#e92a20f83416415bb4086f6d1fb78b3da73d113d" +node-pre-gyp@^0.6.39: + version "0.6.39" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.39.tgz#c00e96860b23c0e1420ac7befc5044e1d78d8649" dependencies: + detect-libc "^1.0.2" hawk "3.1.3" mkdirp "^0.5.1" nopt "^4.0.1" @@ -6323,7 +6557,7 @@ normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" -normalize-path@^2.0.0, normalize-path@^2.0.1: +normalize-path@^2.0.0, normalize-path@^2.0.1, normalize-path@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" dependencies: @@ -6347,8 +6581,8 @@ normalizr@^3.0.2: resolved "https://registry.yarnpkg.com/normalizr/-/normalizr-3.2.4.tgz#16aafc540ca99dc1060ceaa1933556322eac4429" npm-path@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/npm-path/-/npm-path-2.0.3.tgz#15cff4e1c89a38da77f56f6055b24f975dfb2bbe" + version "2.0.4" + resolved "https://registry.yarnpkg.com/npm-path/-/npm-path-2.0.4.tgz#c641347a5ff9d6a09e4d9bce5580c4f505278e64" dependencies: which "^1.2.10" @@ -6394,25 +6628,25 @@ number-is-nan@^1.0.0: resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" number-to-locale-string@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/number-to-locale-string/-/number-to-locale-string-1.0.1.tgz#8c306fda358467bc7eb4746e078be28e21000f31" + version "1.2.0" + resolved "https://registry.yarnpkg.com/number-to-locale-string/-/number-to-locale-string-1.2.0.tgz#6c5030fb47e9c9ad14bf59a1ae81b33cc84b4038" "nwmatcher@>= 1.3.9 < 2.0.0": version "1.4.3" resolved "https://registry.yarnpkg.com/nwmatcher/-/nwmatcher-1.4.3.tgz#64348e3b3d80f035b40ac11563d278f8b72db89c" -oauth-sign@~0.8.1: +oauth-sign@~0.8.1, oauth-sign@~0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" -object-assign@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0" - object-assign@^4.0.0, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" +object-assign@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0" + object-component@0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291" @@ -6433,7 +6667,7 @@ object-is@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.0.1.tgz#0aa60ec9989a0b3ed795cf4d06f62cf1ad6539b6" -object-keys@^1.0.10, object-keys@^1.0.8: +object-keys@^1.0.11, object-keys@^1.0.8: version "1.0.11" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.11.tgz#c54601778ad560f1142ce0e01bcca8b56d13426d" @@ -6444,12 +6678,13 @@ object-visit@^1.0.0: isobject "^3.0.0" object.assign@^4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.0.4.tgz#b1c9cc044ef1b9fe63606fc141abbb32e14730cc" + version "4.1.0" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" dependencies: define-properties "^1.1.2" - function-bind "^1.1.0" - object-keys "^1.0.10" + function-bind "^1.1.1" + has-symbols "^1.0.0" + object-keys "^1.0.11" object.entries@^1.0.4: version "1.0.4" @@ -6460,6 +6695,13 @@ object.entries@^1.0.4: function-bind "^1.1.0" has "^1.0.1" +object.getownpropertydescriptors@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz#8758c846f5b407adab0f236e0986f14b051caa16" + dependencies: + define-properties "^1.1.2" + es-abstract "^1.5.1" + object.omit@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" @@ -6467,7 +6709,7 @@ object.omit@^2.0.0: for-own "^0.1.4" is-extendable "^0.1.1" -object.pick@^1.3.0: +object.pick@^1.1.1, object.pick@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" dependencies: @@ -6566,9 +6808,9 @@ original@>=0.0.5: dependencies: url-parse "1.0.x" -os-browserify@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.2.1.tgz#63fc4ccee5d2d7763d26bbf8601078e6c2e0044f" +os-browserify@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" os-homedir@^1.0.0, os-homedir@^1.0.1: version "1.0.2" @@ -6588,7 +6830,7 @@ os-locale@^2.0.0: lcid "^1.0.0" mem "^1.1.0" -os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1: +os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1, os-tmpdir@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" @@ -6612,8 +6854,10 @@ p-finally@^1.0.0: resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" p-limit@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.1.0.tgz#b07ff2d9a5d88bec806035895a2bab66a27988bc" + version "1.2.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.2.0.tgz#0e92b6bedcb59f022c13d0f1949dc82d15909f1c" + dependencies: + p-try "^1.0.0" p-locate@^2.0.0: version "2.0.0" @@ -6625,9 +6869,13 @@ p-map@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" -pako@~0.2.0: - version "0.2.9" - resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" +p-try@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" + +pako@~1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258" parallel-transform@^1.1.0: version "1.1.0" @@ -6671,10 +6919,10 @@ parse-entities@^1.0.2: is-hexadecimal "^1.0.0" parse-filepath@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.1.tgz#159d6155d43904d16c10ef698911da1e91969b73" + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.2.tgz#a632127f53aaf3d15876f5872f3ffac763d6c891" dependencies: - is-absolute "^0.2.3" + is-absolute "^1.0.0" map-cache "^0.2.0" path-root "^0.1.1" @@ -7151,8 +7399,8 @@ postcss-load-plugins@^2.3.0: object-assign "^4.1.0" postcss-loader@^2.0.8: - version "2.0.8" - resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-2.0.8.tgz#8c67ddb029407dfafe684a406cfc16bad2ce0814" + version "2.0.10" + resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-2.0.10.tgz#090db0540140bd56a7a7f717c41bc29aeef4c674" dependencies: loader-utils "^1.1.0" postcss "^6.0.0" @@ -7230,27 +7478,27 @@ postcss-minify-selectors@^2.0.4: postcss "^5.0.14" postcss-selector-parser "^2.0.0" -postcss-modules-extract-imports@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.2.0.tgz#66140ecece38ef06bf0d3e355d69bf59d141ea85" +postcss-modules-extract-imports@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.1.0.tgz#b614c9720be6816eaee35fb3a5faa1dba6a05ddb" dependencies: postcss "^6.0.1" -postcss-modules-local-by-default@^1.0.1: +postcss-modules-local-by-default@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz#f7d80c398c5a393fa7964466bd19500a7d61c069" dependencies: css-selector-tokenizer "^0.7.0" postcss "^6.0.1" -postcss-modules-scope@^1.0.0: +postcss-modules-scope@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz#d6ea64994c79f97b62a72b426fbe6056a194bb90" dependencies: css-selector-tokenizer "^0.7.0" postcss "^6.0.1" -postcss-modules-values@^1.1.0: +postcss-modules-values@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz#ecffa9d7e192518389f42ad0e83f72aec456ea20" dependencies: @@ -7403,8 +7651,8 @@ postcss@^4.1.7: source-map "~0.4.2" postcss@^5.0.0, postcss@^5.0.10, postcss@^5.0.11, postcss@^5.0.12, postcss@^5.0.13, postcss@^5.0.14, postcss@^5.0.16, postcss@^5.0.19, postcss@^5.0.2, postcss@^5.0.21, postcss@^5.0.3, postcss@^5.0.4, postcss@^5.0.5, postcss@^5.0.6, postcss@^5.0.8, postcss@^5.1.1, postcss@^5.2.0, postcss@^5.2.12, postcss@^5.2.16: - version "5.2.16" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.16.tgz#732b3100000f9ff8379a48a53839ed097376ad57" + version "5.2.18" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.18.tgz#badfa1497d46244f6390f58b319830d9107853c5" dependencies: chalk "^1.1.3" js-base64 "^2.1.9" @@ -7412,12 +7660,12 @@ postcss@^5.0.0, postcss@^5.0.10, postcss@^5.0.11, postcss@^5.0.12, postcss@^5.0. supports-color "^3.2.3" postcss@^6.0.0, postcss@^6.0.1: - version "6.0.13" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.13.tgz#b9ecab4ee00c89db3ec931145bd9590bbf3f125f" + version "6.0.16" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.16.tgz#112e2fe2a6d2109be0957687243170ea5589e146" dependencies: - chalk "^2.1.0" + chalk "^2.3.0" source-map "^0.6.1" - supports-color "^4.4.0" + supports-color "^5.1.0" prelude-ls@~1.1.2: version "1.1.2" @@ -7456,7 +7704,7 @@ process-nextick-args@^1.0.6, process-nextick-args@~1.0.6: version "1.0.7" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" -process@^0.11.0, process@~0.11.0: +process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" @@ -7488,13 +7736,7 @@ promise@^7.1.1: dependencies: asap "~2.0.3" -prop-types@15.5.8: - version "15.5.8" - resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.8.tgz#6b7b2e141083be38c8595aa51fc55775c7199394" - dependencies: - fbjs "^0.8.9" - -prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.0: +prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@15.x: version "15.6.0" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856" dependencies: @@ -7502,6 +7744,12 @@ prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, pr loose-envify "^1.3.1" object-assign "^4.1.1" +prop-types@15.5.8: + version "15.5.8" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.8.tgz#6b7b2e141083be38c8595aa51fc55775c7199394" + dependencies: + fbjs "^0.8.9" + property-information@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/property-information/-/property-information-3.2.0.tgz#fd1483c8fbac61808f5fe359e7693a1f48a58331" @@ -7517,9 +7765,9 @@ proxy-addr@~2.0.2: forwarded "~0.1.2" ipaddr.js "1.5.2" -prr@~0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/prr/-/prr-0.0.0.tgz#1a84b85908325501411853d0081ee3fa86e2926a" +prr@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" pseudomap@^1.0.2: version "1.0.2" @@ -7536,8 +7784,8 @@ public-encrypt@^4.0.0: randombytes "^2.0.1" pump@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/pump/-/pump-1.0.2.tgz#3b3ee6512f94f0e575538c17995f9f16990a5d51" + version "1.0.3" + resolved "https://registry.yarnpkg.com/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954" dependencies: end-of-stream "^1.1.0" once "^1.3.1" @@ -7550,27 +7798,23 @@ pumpify@^1.3.3: inherits "^2.0.1" pump "^1.0.0" -punycode@1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" - punycode@^1.2.4, punycode@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + q@^1.0.1, q@^1.1.2: - version "1.5.0" - resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1" + version "1.5.1" + resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" qjobs@^1.1.4: version "1.1.5" resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.1.5.tgz#659de9f2cf8dcc27a1481276f205377272382e73" -qs@6.4.0, qs@~6.4.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" - -qs@6.5.1, qs@^6.4.0: +qs@^6.4.0, qs@~6.5.1, qs@6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" @@ -7578,6 +7822,10 @@ qs@~6.3.0: version "6.3.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.2.tgz#e75bd5f6e268122a2a0e0bda630b2550c166502c" +qs@~6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" + query-string@^4.1.0, query-string@^4.2.2: version "4.3.4" resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" @@ -7593,15 +7841,15 @@ querystring@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" -querystringify@0.0.x: - version "0.0.4" - resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-0.0.4.tgz#0cf7f84f9463ff0ae51c4c4b142d95be37724d9c" - querystringify@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-1.0.0.tgz#6286242112c5b712fa654e526652bf6a13ff05cb" -raf@^3.1.0: +querystringify@0.0.x: + version "0.0.4" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-0.0.4.tgz#0cf7f84f9463ff0ae51c4c4b142d95be37724d9c" + +raf@^3.1.0, raf@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.0.tgz#a28876881b4bc2ca9117d4138163ddb80f781575" dependencies: @@ -7614,25 +7862,23 @@ randomatic@^1.1.3: is-number "^3.0.0" kind-of "^4.0.0" -randombytes@^2.0.0, randombytes@^2.0.1: +randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.0.5.tgz#dc009a246b8d09a177b4b7a0ae77bc570f4b1b79" dependencies: safe-buffer "^5.1.0" +randomfill@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/randomfill/-/randomfill-1.0.3.tgz#b96b7df587f01dd91726c418f30553b1418e3d62" + dependencies: + randombytes "^2.0.5" + safe-buffer "^5.1.0" + range-parser@^1.0.3, range-parser@^1.2.0, range-parser@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" -raw-body@2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89" - dependencies: - bytes "3.0.0" - http-errors "1.6.2" - iconv-lite "0.4.19" - unpipe "1.0.0" - raw-body@~1.1.0: version "1.1.7" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-1.1.7.tgz#1d027c2bfa116acc6623bca8f00016572a87d425" @@ -7640,36 +7886,24 @@ raw-body@~1.1.0: bytes "1" string_decoder "0.10" -raw-body@~2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.2.0.tgz#994976cf6a5096a41162840492f0bdc5d6e7fb96" +raw-body@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89" dependencies: - bytes "2.4.0" - iconv-lite "0.4.15" + bytes "3.0.0" + http-errors "1.6.2" + iconv-lite "0.4.19" unpipe "1.0.0" rc@^1.1.7: - version "1.2.2" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.2.tgz#d8ce9cb57e8d64d9c7badd9876c7c34cbe3c7077" + version "1.2.3" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.3.tgz#51575a900f8dd68381c710b4712c2154c3e2035b" dependencies: deep-extend "~0.4.0" ini "~1.3.0" minimist "^1.2.0" strip-json-comments "~2.0.1" -react-addons-css-transition-group@^15.5.2: - version "15.6.2" - resolved "https://registry.yarnpkg.com/react-addons-css-transition-group/-/react-addons-css-transition-group-15.6.2.tgz#9e4376bcf40b5217d14ec68553081cee4b08a6d6" - dependencies: - react-transition-group "^1.2.0" - -react-addons-perf@^15.2.1: - version "15.4.2" - resolved "https://registry.yarnpkg.com/react-addons-perf/-/react-addons-perf-15.4.2.tgz#110bdcf5c459c4f77cb85ed634bcd3397536383b" - dependencies: - fbjs "^0.8.4" - object-assign "^4.1.0" - react-addons-shallow-compare@^15.2.1: version "15.6.2" resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.2.tgz#198a00b91fc37623db64a28fd17b596ba362702f" @@ -7684,21 +7918,20 @@ react-ansi-style@^1.0.0: ansi-style-parser "^1.0.1" js-managed-css "^1.4.1" -react-collapse@^2.3.3: - version "2.4.1" - resolved "https://registry.yarnpkg.com/react-collapse/-/react-collapse-2.4.1.tgz#999a9969b2633752acba4847c28101edd2dcab89" +react-collapse@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/react-collapse/-/react-collapse-4.0.3.tgz#b96de959ed0092a43534630b599a4753dd76d543" dependencies: - prop-types "15.5.8" + prop-types "^15.5.8" -react-copy-to-clipboard@^4.2.3: - version "4.3.1" - resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-4.3.1.tgz#aa429ce6029077c987e2bc4af7eec9a09ba5075b" +react-copy-to-clipboard@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.1.tgz#8eae107bb400be73132ed3b6a7b4fb156090208e" dependencies: copy-to-clipboard "^3" - create-react-class "^15.5.2" prop-types "^15.5.8" -react-dom@^15.5.3, react-dom@^15.5.4: +react-dom@15: version "15.6.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.6.2.tgz#41cfadf693b757faf2708443a1d1fd5a02bef730" dependencies: @@ -7707,29 +7940,25 @@ react-dom@^15.5.3, react-dom@^15.5.4: object-assign "^4.1.0" prop-types "^15.5.10" -react-draggable@^2.2.3, "react-draggable@^2.2.6 || ^3.0.3": +react-draggable@^2.2.3: version "2.2.6" resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-2.2.6.tgz#3a806e10f2da6babfea4136be6510e89b0d76901" dependencies: classnames "^2.2.5" -react-element-to-jsx-string@^6.3.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/react-element-to-jsx-string/-/react-element-to-jsx-string-6.4.0.tgz#3a7cdbce3dc8576e0096c735e60a85d704210a23" +"react-draggable@^2.2.6 || ^3.0.3": + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.4.tgz#71cff58441e0fa2be9e1cd88f66718a8495210f7" dependencies: - collapse-white-space "^1.0.0" - is-plain-object "^2.0.1" - lodash "^4.17.4" - sortobject "^1.0.0" - stringify-object "^3.2.0" - traverse "^0.6.6" + classnames "^2.2.5" + prop-types "^15.6.0" -react-height@^2.1.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/react-height/-/react-height-2.2.1.tgz#09020d9d22a48121a7a38135e23452b47f7a987f" +react-element-to-jsx-string@^13.1.0: + version "13.1.0" + resolved "https://registry.yarnpkg.com/react-element-to-jsx-string/-/react-element-to-jsx-string-13.1.0.tgz#a8dbd1a2aeec7ab58f380644f6b5e7e8c7c79c8f" dependencies: - create-react-class "15.5.2" - prop-types "15.5.8" + is-plain-object "2.0.4" + stringify-object "3.2.1" react-hot-api@^0.4.5: version "0.4.7" @@ -7749,8 +7978,8 @@ react-lazy-cache@^3.0.1: deep-equal "^1.0.1" react-markdown@^3.0.0-rc3: - version "3.0.0-rc3" - resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-3.0.0-rc3.tgz#55da83c35e608f3dd7b7b4d4af3bcc95f904a673" + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-3.1.4.tgz#4f37aa5ac4f78f64f93b41b12ad0c08e92eb1680" dependencies: prop-types "^15.6.0" remark-parse "^4.0.0" @@ -7785,17 +8014,15 @@ react-resizable@^1.0.1: prop-types "15.x" react-draggable "^2.2.6 || ^3.0.3" -react-retina-image@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/react-retina-image/-/react-retina-image-2.0.4.tgz#c47de4697f0fa6c5f6535992a9f14a6d6ca86c79" +react-retina-image@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/react-retina-image/-/react-retina-image-2.0.5.tgz#8f92efbbd454fa688d63f35e5a21eff1ab3a062f" dependencies: array-equal "^1.0.0" image-exists "^1.1.0" is-retina "^1.0.3" object-assign "^4.1.0" prop-types "^15.5.6" - react "^15.5.3" - react-dom "^15.5.3" react-router-redux@^4.0.8: version "4.0.8" @@ -7813,24 +8040,24 @@ react-router@3: prop-types "^15.5.6" warning "^3.0.0" -react-sortable@^1.2.0: +react-sortable@1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/react-sortable/-/react-sortable-1.2.0.tgz#5acd7e1910df665408957035acb5f2354519d849" -react-test-renderer@^15.5.4: +react-test-renderer@15: version "15.6.2" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-15.6.2.tgz#d0333434fc2c438092696ca770da5ed48037efa8" dependencies: fbjs "^0.8.9" object-assign "^4.1.0" -react-textarea-autosize@^4.0.5: - version "4.3.2" - resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-4.3.2.tgz#962a52c68caceae408c18acecec29049b81e42fa" +react-textarea-autosize@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-5.2.1.tgz#2b78f9067180f41b08ac59f78f1581abadd61e54" dependencies: - prop-types "^15.5.8" + prop-types "^15.6.0" -react-transition-group@^1.2.0: +react-transition-group@1: version "1.2.1" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-1.2.1.tgz#e11f72b257f921b213229a774df46612346c7ca6" dependencies: @@ -7841,16 +8068,16 @@ react-transition-group@^1.2.0: warning "^3.0.0" react-virtualized@^9.7.2: - version "9.10.1" - resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.10.1.tgz#d32365d0edf49debbe25fbfe73b5f55f6d9d8c72" + version "9.16.1" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.16.1.tgz#2a9c8262094bc5cca309a3ee29f2b4b572ae95a1" dependencies: - babel-runtime "^6.23.0" + babel-runtime "^6.26.0" classnames "^2.2.3" dom-helpers "^2.4.0 || ^3.0.0" loose-envify "^1.3.0" prop-types "^15.5.4" -react@^15.5.3, react@^15.5.4: +react@15: version "15.6.2" resolved "https://registry.yarnpkg.com/react/-/react-15.6.2.tgz#dba0434ab439cfe82f108f0f511663908179aa72" dependencies: @@ -7896,19 +8123,19 @@ read-pkg@^2.0.0: normalize-package-data "^2.3.2" path-type "^2.0.0" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.6, readable-stream@^2.2.9: - version "2.2.9" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.2.9.tgz#cf78ec6f4a6d1eb43d26488cac97f042e74b7fc8" +readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.6, readable-stream@^2.2.9, readable-stream@^2.3.3, "readable-stream@1 || 2": + version "2.3.3" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c" dependencies: - buffer-shims "~1.0.0" core-util-is "~1.0.0" - inherits "~2.0.1" + inherits "~2.0.3" isarray "~1.0.0" process-nextick-args "~1.0.6" - string_decoder "~1.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.0.3" util-deprecate "~1.0.1" -readable-stream@1.0, "readable-stream@>=1.0.33-1 <1.1.0-0", readable-stream@~1.0.2: +"readable-stream@>=1.0.33-1 <1.1.0-0", readable-stream@~1.0.2, readable-stream@1.0: version "1.0.34" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" dependencies: @@ -7963,13 +8190,13 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" -recompose@^0.23.1: - version "0.23.5" - resolved "https://registry.yarnpkg.com/recompose/-/recompose-0.23.5.tgz#72ac8261246bec378235d187467d02a721e8b1de" +recompose@^0.26.0: + version "0.26.0" + resolved "https://registry.yarnpkg.com/recompose/-/recompose-0.26.0.tgz#9babff039cb72ba5bd17366d55d7232fbdfb2d30" dependencies: change-emitter "^0.1.2" fbjs "^0.8.1" - hoist-non-react-statics "^1.0.0" + hoist-non-react-statics "^2.3.1" symbol-observable "^1.0.4" redent@^1.0.0: @@ -8065,8 +8292,8 @@ regenerator-runtime@^0.10.5: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz#336c3efc1220adcedda2c9fab67b5a7955a33658" regenerator-runtime@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.0.tgz#7e54fe5b5ccd5d6624ea6255c3473be090b802e1" + version "0.11.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" regenerator-transform@^0.10.0: version "0.10.1" @@ -8189,6 +8416,13 @@ remark@^8.0.0: remark-stringify "^4.0.0" unified "^6.0.0" +remarkable@^1.6.1: + version "1.7.1" + resolved "https://registry.yarnpkg.com/remarkable/-/remarkable-1.7.1.tgz#aaca4972100b66a642a63a1021ca4bac1be3bff6" + dependencies: + argparse "~0.1.15" + autolinker "~0.15.0" + remote-origin-url@0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/remote-origin-url/-/remote-origin-url-0.4.0.tgz#4d3e2902f34e2d37d1c263d87710b77eb4086a30" @@ -8227,65 +8461,92 @@ repeating@^2.0.0: dependencies: is-finite "^1.0.0" +replace-ext@^1.0.0, replace-ext@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb" + replace-ext@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-0.0.1.tgz#29bbd92078a739f0bcce2b4ee41e837953522924" -replace-ext@1.0.0, replace-ext@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb" +request@^2.79.0: + version "2.83.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.83.0.tgz#ca0b65da02ed62935887808e6f510381034e3356" + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.6.0" + caseless "~0.12.0" + combined-stream "~1.0.5" + extend "~3.0.1" + forever-agent "~0.6.1" + form-data "~2.3.1" + har-validator "~5.0.3" + hawk "~6.0.2" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.17" + oauth-sign "~0.8.2" + performance-now "^2.1.0" + qs "~6.5.1" + safe-buffer "^5.1.1" + stringstream "~0.0.5" + tough-cookie "~2.3.3" + tunnel-agent "^0.6.0" + uuid "^3.1.0" -request@2.81.0, request@^2.79.0: - version "2.81.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.81.0.tgz#c6928946a0e06c5f8d6f8a9333469ffda46298a0" +"request@>=2.0.0 <2.77.0": + version "2.76.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.76.0.tgz#be44505afef70360a0436955106be3945d95560e" dependencies: aws-sign2 "~0.6.0" aws4 "^1.2.1" - caseless "~0.12.0" + caseless "~0.11.0" combined-stream "~1.0.5" extend "~3.0.0" forever-agent "~0.6.1" form-data "~2.1.1" - har-validator "~4.2.1" + har-validator "~2.0.6" hawk "~3.1.3" http-signature "~1.1.0" is-typedarray "~1.0.0" isstream "~0.1.2" json-stringify-safe "~5.0.1" mime-types "~2.1.7" + node-uuid "~1.4.7" oauth-sign "~0.8.1" - performance-now "^0.2.0" - qs "~6.4.0" - safe-buffer "^5.0.1" + qs "~6.3.0" stringstream "~0.0.4" tough-cookie "~2.3.0" - tunnel-agent "^0.6.0" - uuid "^3.0.0" + tunnel-agent "~0.4.1" -"request@>=2.0.0 <2.77.0": - version "2.76.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.76.0.tgz#be44505afef70360a0436955106be3945d95560e" +request@2.81.0: + version "2.81.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.81.0.tgz#c6928946a0e06c5f8d6f8a9333469ffda46298a0" dependencies: aws-sign2 "~0.6.0" aws4 "^1.2.1" - caseless "~0.11.0" + caseless "~0.12.0" combined-stream "~1.0.5" extend "~3.0.0" forever-agent "~0.6.1" form-data "~2.1.1" - har-validator "~2.0.6" + har-validator "~4.2.1" hawk "~3.1.3" http-signature "~1.1.0" is-typedarray "~1.0.0" isstream "~0.1.2" json-stringify-safe "~5.0.1" mime-types "~2.1.7" - node-uuid "~1.4.7" oauth-sign "~0.8.1" - qs "~6.3.0" + performance-now "^0.2.0" + qs "~6.4.0" + safe-buffer "^5.0.1" stringstream "~0.0.4" tough-cookie "~2.3.0" - tunnel-agent "~0.4.1" + tunnel-agent "^0.6.0" + uuid "^3.0.0" require-directory@^2.1.1: version "2.1.1" @@ -8306,7 +8567,7 @@ require-uncached@^1.0.2: caller-path "^0.1.0" resolve-from "^1.0.0" -requires-port@1.0.x, requires-port@1.x.x: +requires-port@~1.0.0, requires-port@1.0.x, requires-port@1.x.x: version "1.0.0" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" @@ -8315,27 +8576,37 @@ reselect@^3.0.0: resolved "https://registry.yarnpkg.com/reselect/-/reselect-3.0.1.tgz#efdaa98ea7451324d092b2b2163a6a1d7a9a2147" resize-observer-polyfill@^1.3.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.4.2.tgz#a37198e6209e888acb1532a9968e06d38b6788e5" + version "1.5.0" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.0.tgz#660ff1d9712a2382baa2cad450a4716209f9ca69" + +resolve-cwd@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" + dependencies: + resolve-from "^3.0.0" resolve-from@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226" +resolve-from@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" + resolve-url@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" +resolve@^1.1.3, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.2.0, resolve@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.5.0.tgz#1f09acce796c9a762579f31b2c1cc4c3cddf9f36" + dependencies: + path-parse "^1.0.5" + resolve@1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" -resolve@^1.1.3, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.2.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.4.0.tgz#a75be01c53da25d934a98ebd0e4c4a7312f92a86" - dependencies: - path-parse "^1.0.5" - restore-cursor@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" @@ -8361,7 +8632,7 @@ right-align@^0.1.1: dependencies: align-text "^0.1.1" -rimraf@2, rimraf@^2.2.8, rimraf@^2.5.1, rimraf@^2.5.4, rimraf@^2.6.0, rimraf@^2.6.1: +rimraf@^2.2.8, rimraf@^2.5.1, rimraf@^2.5.4, rimraf@^2.6.0, rimraf@^2.6.1, rimraf@2: version "2.6.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" dependencies: @@ -8395,12 +8666,12 @@ rx@2.3.24: resolved "https://registry.yarnpkg.com/rx/-/rx-2.3.24.tgz#14f950a4217d7e35daa71bbcbe58eff68ea4b2b7" rxjs@^5.0.0-beta.11: - version "5.4.3" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.4.3.tgz#0758cddee6033d68e0fd53676f0f3596ce3d483f" + version "5.5.6" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.5.6.tgz#e31fb96d6fd2ff1fd84bcea8ae9c02d007179c02" dependencies: - symbol-observable "^1.0.1" + symbol-observable "1.0.1" -safe-buffer@5.1.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0: +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1, safe-buffer@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" @@ -8421,8 +8692,8 @@ sane@~1.5.0: watch "~0.10.0" sauce-connect-launcher@^1.1.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/sauce-connect-launcher/-/sauce-connect-launcher-1.2.2.tgz#7346cc8fbdc443191323439b0733451f5f3521f2" + version "1.2.3" + resolved "https://registry.yarnpkg.com/sauce-connect-launcher/-/sauce-connect-launcher-1.2.3.tgz#d2f931ad7ae8fdabf1968a440e7b20417aca7f86" dependencies: adm-zip "~0.4.3" async "^2.1.2" @@ -8430,23 +8701,30 @@ sauce-connect-launcher@^1.1.1: lodash "^4.16.6" rimraf "^2.5.4" -sax@0.6.x: - version "0.6.1" - resolved "https://registry.yarnpkg.com/sax/-/sax-0.6.1.tgz#563b19c7c1de892e09bfc4f2fc30e3c27f0952b9" - sax@^1.2.1, sax@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" +sax@0.6.x: + version "0.6.1" + resolved "https://registry.yarnpkg.com/sax/-/sax-0.6.1.tgz#563b19c7c1de892e09bfc4f2fc30e3c27f0952b9" + schema-utils@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.3.0.tgz#f5877222ce3e931edae039f17eb3716e7137f8cf" dependencies: ajv "^5.0.0" +schema-utils@^0.4.2: + version "0.4.3" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.3.tgz#e2a594d3395834d5e15da22b48be13517859458e" + dependencies: + ajv "^5.0.0" + ajv-keywords "^2.1.0" + screenfull@^3.0.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-3.3.1.tgz#5eea886b412043af89e2e5cc4433f894d0ebb90b" + version "3.3.2" + resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-3.3.2.tgz#a6adf3b3f5556da812725385880600f5b39fbf25" select-hose@^2.0.0: version "2.0.0" @@ -8468,7 +8746,7 @@ selfsigned@^1.9.1: dependencies: node-forge "0.6.33" -"semver@2 || 3 || 4 || 5", semver@^5.3.0: +semver@^5.3.0, semver@^5.4.1, "semver@2 || 3 || 4 || 5": version "5.4.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.4.1.tgz#e059c09d8571f0540823733433505d3a2f00b18e" @@ -8498,6 +8776,10 @@ send@0.16.1: range-parser "~1.2.0" statuses "~1.3.1" +serialize-javascript@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.4.0.tgz#7c958514db6ac2443a8abc062dc9f7886a7f6005" + serve-index@^1.7.2: version "1.9.1" resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" @@ -8647,6 +8929,12 @@ sntp@1.x.x: dependencies: hoek "2.x.x" +sntp@2.x.x: + version "2.1.0" + resolved "https://registry.yarnpkg.com/sntp/-/sntp-2.1.0.tgz#2c6cec14fedc2222739caf9b5c3d85d1cc5a2cc8" + dependencies: + hoek "4.x.x" + socket.io-adapter@0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-0.5.0.tgz#cb6d4bb8bec81e1078b99677f9ced0046066bb8b" @@ -8702,12 +8990,12 @@ sockjs-client@1.1.4: json3 "^3.3.2" url-parse "^1.1.8" -sockjs@0.3.18: - version "0.3.18" - resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.18.tgz#d9b289316ca7df77595ef299e075f0f937eb4207" +sockjs@0.3.19: + version "0.3.19" + resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.19.tgz#d976bbe800af7bd20ae08598d582393508993c0d" dependencies: faye-websocket "^0.10.0" - uuid "^2.0.2" + uuid "^3.0.1" sort-keys@^1.0.0: version "1.1.2" @@ -8715,21 +9003,16 @@ sort-keys@^1.0.0: dependencies: is-plain-obj "^1.0.0" -sortobject@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/sortobject/-/sortobject-1.1.1.tgz#4f695d4d44ed0a4c06482c34c2582a2dcdc2ab34" - dependencies: - editions "^1.1.1" - source-list-map@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.0.tgz#aaa47403f7b245a92fbc97ea08f250d6087ed085" source-map-resolve@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.0.tgz#fcad0b64b70afb27699e425950cb5ebcd410bc20" + version "0.5.1" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.1.tgz#7ad0f593f2281598e854df80f19aae4b92d7a11a" dependencies: atob "^2.0.0" + decode-uri-component "^0.2.0" resolve-url "^0.2.1" source-map-url "^0.4.0" urix "^0.1.0" @@ -8744,22 +9027,16 @@ source-map-url@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" -source-map@0.5.x, source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.3, source-map@~0.5.6: - version "0.5.7" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" - -source-map@^0.1.41: - version "0.1.43" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346" - dependencies: - amdefine ">=0.0.4" - source-map@^0.4.4, source-map@~0.4.2: version "0.4.4" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" dependencies: amdefine ">=0.0.4" +source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.6, source-map@0.5.x: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" @@ -8771,8 +9048,8 @@ space-separated-tokens@^1.0.0: trim "0.0.1" spawn-command@^0.0.2-1: - version "0.0.2" - resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2.tgz#9544e1a43ca045f8531aac1a48cb29bdae62338e" + version "0.0.2-1" + resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2-1.tgz#62f5e9466981c1b796dc5929937e11c9c6921bd0" spdx-correct@~1.0.0: version "1.0.2" @@ -8811,17 +9088,11 @@ spdy@^3.4.1: select-hose "^2.0.0" spdy-transport "^2.0.18" -split-string@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/split-string/-/split-string-2.1.1.tgz#af4b06d821560426446c3cd931cda618940d37d0" - dependencies: - extend-shallow "^2.0.1" - -split-string@^3.0.1: - version "3.0.2" - resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.0.2.tgz#6129bc92731716e5aa1fb73c333078f0b7c114c8" +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" dependencies: - extend-shallow "^2.0.1" + extend-shallow "^3.0.0" sprintf-js@~1.0.2: version "1.0.3" @@ -8873,7 +9144,11 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" -"statuses@>= 1.3.1 < 2", statuses@~1.3.1: +"statuses@>= 1.3.1 < 2": + version "1.4.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087" + +statuses@~1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" @@ -8904,7 +9179,7 @@ stream-each@^1.1.0: end-of-stream "^1.1.0" stream-shift "^1.0.0" -stream-http@^2.3.1: +stream-http@^2.7.2: version "2.7.2" resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.7.2.tgz#40a050ec8dc3b53b33d9909415c02c0bf1abfbad" dependencies: @@ -8926,6 +9201,16 @@ strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" +string_decoder@^1.0.0, string_decoder@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab" + dependencies: + safe-buffer "~5.1.0" + +string_decoder@~0.10.x, string_decoder@0.10: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + string-length@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/string-length/-/string-length-1.0.1.tgz#56970fb1c38558e9e70b728bf3de269ac45adfac" @@ -8951,16 +9236,6 @@ string-width@^2.0.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^4.0.0" -string_decoder@0.10, string_decoder@^0.10.25, string_decoder@~0.10.x: - version "0.10.31" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" - -string_decoder@~1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab" - dependencies: - safe-buffer "~5.1.0" - stringify-entities@^1.0.1: version "1.3.1" resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-1.3.1.tgz#b150ec2d72ac4c1b5f324b51fb6b28c9cdff058c" @@ -8970,7 +9245,7 @@ stringify-entities@^1.0.1: is-alphanumerical "^1.0.0" is-hexadecimal "^1.0.0" -stringify-object@^3.2.0: +stringify-object@3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.2.1.tgz#2720c2eff940854c819f6ee252aaeb581f30624d" dependencies: @@ -8978,7 +9253,7 @@ stringify-object@^3.2.0: is-obj "^1.0.1" is-regexp "^1.0.0" -stringstream@~0.0.4: +stringstream@~0.0.4, stringstream@~0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" @@ -9007,16 +9282,16 @@ strip-bom-stream@^1.0.0: first-chunk-stream "^1.0.0" strip-bom "^2.0.0" -strip-bom@3.0.0, strip-bom@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" - strip-bom@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" dependencies: is-utf8 "^0.2.0" +strip-bom@^3.0.0, strip-bom@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + strip-eof@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" @@ -9032,8 +9307,8 @@ strip-json-comments@^2.0.0, strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" style-loader@^0.19.0: - version "0.19.0" - resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.19.0.tgz#7258e788f0fee6a42d710eaf7d6c2412a4c50759" + version "0.19.1" + resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.19.1.tgz#591ffc80bcefe268b77c5d9ebc0505d772619f85" dependencies: loader-utils "^1.0.2" schema-utils "^0.3.0" @@ -9058,12 +9333,18 @@ supports-color@^3.1.2, supports-color@^3.2.3: dependencies: has-flag "^1.0.0" -supports-color@^4.0.0, supports-color@^4.1.0, supports-color@^4.2.1, supports-color@^4.4.0: +supports-color@^4.0.0, supports-color@^4.1.0, supports-color@^4.2.1: version "4.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.5.0.tgz#be7a0de484dec5c5cddf8b3d59125044912f635b" dependencies: has-flag "^2.0.0" +supports-color@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.1.0.tgz#058a021d1b619f7ddf3980d712ea3590ce7de3d5" + dependencies: + has-flag "^2.0.0" + svgo@^0.7.0: version "0.7.2" resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.2.tgz#9f5772413952135c6fefbf40afe6a4faa88b4bb5" @@ -9076,9 +9357,13 @@ svgo@^0.7.0: sax "~1.2.1" whet.extend "~0.9.9" -symbol-observable@^1.0.1, symbol-observable@^1.0.3, symbol-observable@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.4.tgz#29bf615d4aa7121bdd898b22d4b3f9bc4e2aa03d" +symbol-observable@^1.0.3, symbol-observable@^1.0.4: + version "1.1.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.1.0.tgz#5c68fd8d54115d9dfb72a84720549222e8db9b32" + +symbol-observable@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.1.tgz#8340fc4702c3122df5d22288f88283f513d3fdd4" symbol-tree@^3.2.1: version "3.2.2" @@ -9108,8 +9393,8 @@ tapable@^0.2.7: resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.2.8.tgz#99372a5c999bf2df160afc0d74bed4f47948cd22" tar-pack@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.4.0.tgz#23be2d7f671a8339376cbdb0b8fe3fdebf317984" + version "3.4.1" + resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.4.1.tgz#e1dbc03a9b9d3ba07e896ad027317eb679a10a1f" dependencies: debug "^2.2.0" fstream "^1.0.10" @@ -9139,8 +9424,8 @@ test-exclude@^4.1.1: require-main-filename "^1.0.1" tether@^1.2.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/tether/-/tether-1.4.0.tgz#0f9fa171f75bf58485d8149e94799d7ae74d1c1a" + version "1.4.3" + resolved "https://registry.yarnpkg.com/tether/-/tether-1.4.3.tgz#fd547024c47b6e5c9b87e1880f997991a9a6ad54" text-table@~0.2.0: version "0.2.0" @@ -9162,6 +9447,10 @@ throat@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/throat/-/throat-3.2.0.tgz#50cb0670edbc40237b9e347d7e1f88e4620af836" +through@^2.3.6, "through@>=2.2.7 <3": + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + through2-filter@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-2.0.0.tgz#60bc55a0dacb76085db1f9dae99ab43f83d622ec" @@ -9183,10 +9472,6 @@ through2@^2.0.0, through2@^2.0.1, through2@~2.0.0: readable-stream "^2.1.5" xtend "~4.0.1" -"through@>=2.2.7 <3", through@^2.3.6: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - thunky@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/thunky/-/thunky-0.1.0.tgz#bf30146824e2b6e67b0f2d7a4ac8beb26908684e" @@ -9195,13 +9480,7 @@ time-stamp@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-2.0.0.tgz#95c6a44530e15ba8d6f4a3ecb8c3a3fac46da357" -timers-browserify@^1.4.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-1.4.2.tgz#c9c58b575be8407375cb5e2462dacee74359f41d" - dependencies: - process "~0.11.0" - -timers-browserify@^2.0.2: +timers-browserify@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.4.tgz#96ca53f4b794a5e7c0e1bd7cc88a372298fa01e6" dependencies: @@ -9233,12 +9512,18 @@ tmp@0.0.24: version "0.0.24" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.24.tgz#d6a5e198d14a9835cc6f2d7c3d9e302428c8cf12" -tmp@0.0.31, tmp@0.0.x: +tmp@0.0.31: version "0.0.31" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.31.tgz#8f38ab9438e17315e5dbd8b3657e8bfb277ae4a7" dependencies: os-tmpdir "~1.0.1" +tmp@0.0.x: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + dependencies: + os-tmpdir "~1.0.2" + tmpl@1.0.x: version "1.0.4" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" @@ -9286,6 +9571,10 @@ toggle-selection@^1.0.3: version "1.0.6" resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" +toml@^2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/toml/-/toml-2.3.3.tgz#8d683d729577cb286231dfc7a8affe58d31728fb" + topo@1.x.x: version "1.1.0" resolved "https://registry.yarnpkg.com/topo/-/topo-1.1.0.tgz#e9d751615d1bb87dc865db182fa1ca0a5ef536d5" @@ -9296,7 +9585,7 @@ toposort@^1.0.0: version "1.0.6" resolved "https://registry.yarnpkg.com/toposort/-/toposort-1.0.6.tgz#c31748e55d210effc00fdcdc7d6e68d7d7bb9cec" -tough-cookie@^2.3.2, tough-cookie@~2.3.0: +tough-cookie@^2.3.2, tough-cookie@~2.3.0, tough-cookie@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.3.tgz#0b618a5565b6dea90bf3425d04d55edc475a7561" dependencies: @@ -9306,10 +9595,6 @@ tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" -traverse@^0.6.6: - version "0.6.6" - resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.6.6.tgz#cbdf560fd7b9af632502fed40f918c157ea97137" - tree-kill@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.0.tgz#5846786237b4239014f05db156b643212d4c6f36" @@ -9338,10 +9623,6 @@ trough@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.1.tgz#a9fd8b0394b0ae8fff82e0633a0a36ccad5b5f86" -tryit@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb" - tty-browserify@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" @@ -9366,7 +9647,7 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" -type-is@~1.6.14, type-is@~1.6.15: +type-is@~1.6.15: version "1.6.15" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.15.tgz#cab10fb4909e441c82842eafe1ad646c81804410" dependencies: @@ -9381,20 +9662,13 @@ ua-parser-js@^0.7.9: version "0.7.17" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac" -uglify-es@^3.1.3: - version "3.1.5" - resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.1.5.tgz#63bae0fd4f9feeda417fee7c0ff685a673819683" +uglify-es@^3.3.4: + version "3.3.5" + resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.5.tgz#cf7e695da81999f85196b15e2978862f13212f88" dependencies: - commander "~2.11.0" + commander "~2.12.1" source-map "~0.6.1" -uglify-js@3.1.x: - version "3.1.3" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.1.3.tgz#d61f0453b4718cab01581f3162aa90bab7520b42" - dependencies: - commander "~2.11.0" - source-map "~0.5.1" - uglify-js@^2.6, uglify-js@^2.8.29: version "2.8.29" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd" @@ -9404,6 +9678,13 @@ uglify-js@^2.6, uglify-js@^2.8.29: optionalDependencies: uglify-to-browserify "~1.0.0" +uglify-js@3.3.x: + version "3.3.5" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.3.5.tgz#4c4143dfe08e8825746675cc49a6874a933b543e" + dependencies: + commander "~2.12.1" + source-map "~0.6.1" + uglify-to-browserify@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" @@ -9417,16 +9698,17 @@ uglifyjs-webpack-plugin@^0.4.6: webpack-sources "^1.0.1" uglifyjs-webpack-plugin@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-1.0.0.tgz#1c58b5db1ed043e024aef66f8ade25e148206264" + version "1.1.6" + resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-1.1.6.tgz#f4ba8449edcf17835c18ba6ae99b9d610857fb19" dependencies: - cacache "^10.0.0" + cacache "^10.0.1" find-cache-dir "^1.0.0" - schema-utils "^0.3.0" - source-map "^0.5.6" - uglify-es "^3.1.3" - webpack-sources "^1.0.1" - worker-farm "^1.4.1" + schema-utils "^0.4.2" + serialize-javascript "^1.4.0" + source-map "^0.6.1" + uglify-es "^3.3.4" + webpack-sources "^1.1.0" + worker-farm "^1.5.2" uid-number@^0.0.6: version "0.0.6" @@ -9436,14 +9718,22 @@ ultron@1.0.x: version "1.0.2" resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.0.2.tgz#ace116ab557cd197386a4e88f4685378c8b2e4fa" -unc-path-regex@^0.1.0: +unc-path-regex@^0.1.0, unc-path-regex@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" +underscore.string@~2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/underscore.string/-/underscore.string-2.4.0.tgz#8cdd8fbac4e2d2ea1e7e2e8097c42f442280f85b" + underscore@^1.8.3: version "1.8.3" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022" +underscore@~1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.7.0.tgz#6bbaf0877500d36be34ecaa584e0db9fef035209" + unherit@^1.0.4: version "1.1.0" resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.0.tgz#6b9aaedfbf73df1756ad9e316dd981885840cd7d" @@ -9451,19 +9741,7 @@ unherit@^1.0.4: inherits "^2.0.1" xtend "^4.0.1" -unified@^6.0.0: - version "6.1.5" - resolved "https://registry.yarnpkg.com/unified/-/unified-6.1.5.tgz#716937872621a63135e62ced2f3ac6a063c6fb87" - dependencies: - bail "^1.0.0" - extend "^3.0.0" - is-plain-obj "^1.1.0" - trough "^1.0.0" - vfile "^2.0.0" - x-is-function "^1.0.4" - x-is-string "^0.1.0" - -unified@^6.1.5: +unified@^6.0.0, unified@^6.1.5: version "6.1.6" resolved "https://registry.yarnpkg.com/unified/-/unified-6.1.6.tgz#5ea7f807a0898f1f8acdeefe5f25faa010cc42b1" dependencies: @@ -9547,17 +9825,13 @@ unist-util-remove-position@^1.0.0: dependencies: unist-util-visit "^1.1.0" -unist-util-stringify-position@^1.0.0: +unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-1.1.1.tgz#3ccbdc53679eed6ecf3777dd7f5e3229c1b6aa3c" -unist-util-visit@^1.0.0, unist-util-visit@^1.0.1, unist-util-visit@^1.1.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.1.3.tgz#ec268e731b9d277a79a5b5aa0643990e405d600b" - -unist-util-visit@^1.1.3: - version "1.2.0" - resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.2.0.tgz#9dc78d1f95cd242e865f7f93f327d3296bb9a718" +unist-util-visit@^1.0.0, unist-util-visit@^1.0.1, unist-util-visit@^1.1.0, unist-util-visit@^1.1.3: + version "1.3.0" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.3.0.tgz#41ca7c82981fd1ce6c762aac397fc24e35711444" dependencies: unist-util-is "^2.1.1" @@ -9568,7 +9842,7 @@ units-css@^0.4.0: isnumeric "^0.2.0" viewport-dimensions "^0.2.0" -unpipe@1.0.0, unpipe@~1.0.0: +unpipe@~1.0.0, unpipe@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" @@ -9580,10 +9854,14 @@ unset-value@^1.0.0: isobject "^3.0.0" unused-files-webpack-plugin@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/unused-files-webpack-plugin/-/unused-files-webpack-plugin-3.0.2.tgz#b08e68071cafc6fdf9419f8ace6f74916806222f" + version "3.2.0" + resolved "https://registry.yarnpkg.com/unused-files-webpack-plugin/-/unused-files-webpack-plugin-3.2.0.tgz#52e6a929024820da0d07d708f364176df5a3cecf" dependencies: - glob "^7.0.3" + babel-runtime "^7.0.0-beta.3" + glob-all "^3.1.0" + semver "^5.4.1" + util.promisify "^1.0.0" + warning "^3.0.0" upper-case@^1.1.1: version "1.1.3" @@ -9597,6 +9875,13 @@ url-join@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/url-join/-/url-join-0.0.1.tgz#1db48ad422d3402469a87f7d97bdebfe4fb1e3c8" +url-parse@^1.1.8: + version "1.2.0" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.2.0.tgz#3a19e8aaa6d023ddd27dcc44cb4fc8f7fec23986" + dependencies: + querystringify "~1.0.0" + requires-port "~1.0.0" + url-parse@1.0.x: version "1.0.5" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.0.5.tgz#0854860422afdcfefeb6c965c662d4800169927b" @@ -9604,13 +9889,6 @@ url-parse@1.0.x: querystringify "0.0.x" requires-port "1.0.x" -url-parse@^1.1.8: - version "1.1.9" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.1.9.tgz#c67f1d775d51f0a18911dd7b3ffad27bb9e5bd19" - dependencies: - querystringify "~1.0.0" - requires-port "1.0.x" - url@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" @@ -9647,7 +9925,14 @@ util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" -util@0.10.3, util@^0.10.3: +util.promisify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.0.tgz#440f7165a459c9a16dc145eb8e72f35687097030" + dependencies: + define-properties "^1.1.2" + object.getownpropertydescriptors "^2.0.3" + +util@^0.10.3, util@0.10.3: version "0.10.3" resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" dependencies: @@ -9665,11 +9950,7 @@ utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" -uuid@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" - -uuid@^3.0.0, uuid@^3.0.1: +uuid@^3.0.0, uuid@^3.0.1, uuid@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" @@ -9710,6 +9991,12 @@ vfile-location@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-2.0.2.tgz#d3675c59c877498e492b4756ff65e4af1a752255" +vfile-message@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-1.0.0.tgz#a6adb0474ea400fa25d929f1d673abea6a17e359" + dependencies: + unist-util-stringify-position "^1.1.1" + vfile-reporter@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/vfile-reporter/-/vfile-reporter-4.0.0.tgz#ea6f0ae1342f4841573985e05f941736f27de9da" @@ -9729,12 +10016,13 @@ vfile-statistics@^1.1.0: resolved "https://registry.yarnpkg.com/vfile-statistics/-/vfile-statistics-1.1.0.tgz#02104c60fdeed1d11b1f73ad65330b7634b3d895" vfile@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/vfile/-/vfile-2.2.0.tgz#ce47a4fb335922b233e535db0f7d8121d8fced4e" + version "2.3.0" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-2.3.0.tgz#e62d8e72b20e83c324bc6c67278ee272488bf84a" dependencies: is-buffer "^1.1.4" replace-ext "1.0.0" unist-util-stringify-position "^1.0.0" + vfile-message "^1.0.0" viewport-dimensions@^0.2.0: version "0.2.0" @@ -9838,44 +10126,47 @@ webidl-conversions@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" -webpack-dev-middleware@^1.0.11, webpack-dev-middleware@^1.11.0, webpack-dev-middleware@^1.12.0: - version "1.12.0" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-1.12.0.tgz#d34efefb2edda7e1d3b5dbe07289513219651709" +webpack-dev-middleware@^1.12.0, webpack-dev-middleware@1.12.2: + version "1.12.2" + resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-1.12.2.tgz#f8fc1120ce3b4fc5680ceecb43d777966b21105e" dependencies: memory-fs "~0.4.1" - mime "^1.3.4" + mime "^1.5.0" path-is-absolute "^1.0.0" range-parser "^1.0.3" time-stamp "^2.0.0" webpack-dev-server@^2.9.1: - version "2.9.2" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.9.2.tgz#0fbab915701d25a905a60e1e784df19727da800f" + version "2.10.0" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.10.0.tgz#6db9c77c8cf2e2d7ff85c89fb5e4de6f7227be19" dependencies: ansi-html "0.0.7" array-includes "^3.0.3" bonjour "^3.5.0" - chokidar "^1.6.0" + chokidar "^2.0.0" compression "^1.5.2" connect-history-api-fallback "^1.3.0" + debug "^3.1.0" del "^3.0.0" - express "^4.13.3" + express "^4.16.2" html-entities "^1.2.0" http-proxy-middleware "~0.17.4" + import-local "^1.0.0" internal-ip "1.2.0" ip "^1.1.5" + killable "^1.0.0" loglevel "^1.4.1" opn "^5.1.0" portfinder "^1.0.9" selfsigned "^1.9.1" serve-index "^1.7.2" - sockjs "0.3.18" + sockjs "0.3.19" sockjs-client "1.1.4" spdy "^3.4.1" - strip-ansi "^3.0.1" - supports-color "^4.2.1" - webpack-dev-middleware "^1.11.0" - yargs "^6.6.0" + strip-ansi "^4.0.0" + supports-color "^5.1.0" + webpack-dev-middleware "1.12.2" + yargs "^10.0.3" webpack-postcss-tools@^1.1.2: version "1.1.2" @@ -9885,16 +10176,16 @@ webpack-postcss-tools@^1.1.2: postcss "^4.1.7" resolve "^1.1.6" -webpack-sources@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.0.1.tgz#c7356436a4d13123be2e2426a05d1dad9cbe65cf" +webpack-sources@^1.0.1, webpack-sources@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.1.0.tgz#a101ebae59d6507354d71d8013950a3a8b7a5a54" dependencies: source-list-map "^2.0.0" - source-map "~0.5.3" + source-map "~0.6.1" webpack@^3.8.1: - version "3.8.1" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.8.1.tgz#b16968a81100abe61608b0153c9159ef8bb2bd83" + version "3.10.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.10.0.tgz#5291b875078cf2abf42bdd23afe3f8f96c17d725" dependencies: acorn "^5.0.0" acorn-dynamic-import "^2.0.0" @@ -9927,14 +10218,14 @@ websocket-driver@>=0.5.1: websocket-extensions ">=0.1.1" websocket-extensions@>=0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.2.tgz#0e18781de629a18308ce1481650f67ffa2693a5d" + version "0.1.3" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.3.tgz#5d2ff22977003ec687a4b87073dfbbac146ccf29" whatwg-encoding@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.1.tgz#3c6c451a198ee7aec55b1ec61d0920c67801a5f4" + version "1.0.3" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.3.tgz#57c235bc8657e914d24e1a397d3c82daee0a6ba3" dependencies: - iconv-lite "0.4.13" + iconv-lite "0.4.19" whatwg-fetch@>=0.10.0: version "2.0.3" @@ -9986,10 +10277,6 @@ winston@^2.1.1: isstream "0.1.x" stack-trace "0.0.x" -wordwrap@0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" - wordwrap@~0.0.2: version "0.0.3" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" @@ -9998,9 +10285,13 @@ wordwrap@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" -worker-farm@^1.3.1, worker-farm@^1.4.1: - version "1.5.0" - resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.5.0.tgz#adfdf0cd40581465ed0a1f648f9735722afd5c8d" +wordwrap@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" + +worker-farm@^1.3.1, worker-farm@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.5.2.tgz#32b312e5dc3d5d45d79ef44acc2587491cd729ae" dependencies: errno "^0.1.4" xtend "^4.0.1" @@ -10022,16 +10313,16 @@ write@^0.2.1: dependencies: mkdirp "^0.5.1" -ws@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.2.tgz#8a244fa052401e08c9886cf44a85189e1fd4067f" +ws@^1.0.1: + version "1.1.5" + resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.5.tgz#cbd9e6e75e09fc5d2c90015f21f0c40875e0dd51" dependencies: options ">=0.0.5" ultron "1.0.x" -ws@^1.0.1: - version "1.1.4" - resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.4.tgz#57f40d036832e5f5055662a397c4de76ed66bf61" +ws@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.2.tgz#8a244fa052401e08c9886cf44a85189e1fd4067f" dependencies: options ">=0.0.5" ultron "1.0.x" @@ -10063,7 +10354,11 @@ xml2js@0.4.4: sax "0.6.x" xmlbuilder ">=1.0.0" -xmlbuilder@8.2.2, xmlbuilder@>=1.0.0: +xmlbuilder@>=1.0.0: + version "9.0.4" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.4.tgz#519cb4ca686d005a8420d3496f3f0caeecca580f" + +xmlbuilder@8.2.2: version "8.2.2" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773" @@ -10079,7 +10374,7 @@ xpath-builder@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/xpath-builder/-/xpath-builder-0.0.7.tgz#67d6bbc3f6a320ec317e3e6368c5706b6111deec" -"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: +xtend@^4.0.0, xtend@^4.0.1, "xtend@>=4.0.0 <4.1.0-0", xtend@~4.0.0, xtend@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" @@ -10110,12 +10405,29 @@ yargs-parser@^7.0.0: camelcase "^4.1.0" yargs-parser@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-8.0.0.tgz#21d476330e5a82279a4b881345bf066102e219c6" + version "8.1.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-8.1.0.tgz#f1376a33b6629a5d063782944da732631e966950" dependencies: camelcase "^4.1.0" -yargs@^6.0.1, yargs@^6.3.0, yargs@^6.6.0: +yargs@^10.0.3: + version "10.0.3" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-10.0.3.tgz#6542debd9080ad517ec5048fb454efe9e4d4aaae" + dependencies: + cliui "^3.2.0" + decamelize "^1.1.1" + find-up "^2.1.0" + get-caller-file "^1.0.1" + os-locale "^2.0.0" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^2.0.0" + which-module "^2.0.0" + y18n "^3.2.1" + yargs-parser "^8.0.0" + +yargs@^6.0.1, yargs@^6.3.0: version "6.6.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-6.6.0.tgz#782ec21ef403345f830a808ca3d513af56065208" dependencies: @@ -10151,6 +10463,12 @@ yargs@^8.0.2: y18n "^3.2.1" yargs-parser "^7.0.0" +yargs@~1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-1.2.6.tgz#9c7b4a82fd5d595b2bf17ab6dcc43135432fe34b" + dependencies: + minimist "^0.1.0" + yargs@~3.10.0: version "3.10.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1" @@ -10167,3 +10485,4 @@ yeast@0.1.2: z-index@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/z-index/-/z-index-0.0.1.tgz#4f3d257a36869dabd990572b70494291cb3eab8f" +