Compare commits
321 Commits
python-run
...
move-table
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ebbbd4e5a | ||
|
|
4350a95708 | ||
|
|
26d47c1e34 | ||
|
|
b3551813b1 | ||
|
|
875601dfb4 | ||
|
|
340cf95f51 | ||
|
|
8a505fd419 | ||
|
|
26d45c26e8 | ||
|
|
53ecaaf7a5 | ||
|
|
b58bff6dd7 | ||
|
|
de077d3943 | ||
|
|
2a42f773cf | ||
|
|
aa9005b782 | ||
|
|
5d207dff7b | ||
|
|
2fbd7d3b1e | ||
|
|
d3fdca112d | ||
|
|
3784750e6d | ||
|
|
98287f0d89 | ||
|
|
e9a86902bc | ||
|
|
36fc93d4e1 | ||
|
|
ba82a8a02e | ||
|
|
3c0a312444 | ||
|
|
efccfe909d | ||
|
|
26fd776b30 | ||
|
|
f95dcb6be5 | ||
|
|
152aaef104 | ||
|
|
27c58d1157 | ||
|
|
25ed5d9b8a | ||
|
|
db62348247 | ||
|
|
17353f3dbd | ||
|
|
0444520362 | ||
|
|
51a5f4f2fc | ||
|
|
f5539cdad7 | ||
|
|
36f8ee0342 | ||
|
|
33b5e1f905 | ||
|
|
4f84e33f77 | ||
|
|
f0583eaba5 | ||
|
|
b5b58ebbcb | ||
|
|
d0f20309a1 | ||
|
|
f6154fd684 | ||
|
|
21f4335bea | ||
|
|
757b650588 | ||
|
|
9f8adeaadb | ||
|
|
7d2f608ffc | ||
|
|
32771e9841 | ||
|
|
d9c75d3c8c | ||
|
|
aa38d7d06d | ||
|
|
de589ea335 | ||
|
|
11aad82749 | ||
|
|
b2e931b612 | ||
|
|
305f102ef9 | ||
|
|
f3211e4cf2 | ||
|
|
a496f9de7d | ||
|
|
3da6ed1c81 | ||
|
|
7651509551 | ||
|
|
ad81d702ac | ||
|
|
81c056481a | ||
|
|
a1d44313a2 | ||
|
|
941e5e47f1 | ||
|
|
8777d4b850 | ||
|
|
1c2195c4ed | ||
|
|
266be7245e | ||
|
|
5facc8557c | ||
|
|
53738cdba3 | ||
|
|
3a91374f4e | ||
|
|
77da831305 | ||
|
|
34492caa8a | ||
|
|
05cd7b8ed9 | ||
|
|
7ab6f20abb | ||
|
|
6e040a3fc5 | ||
|
|
d711427901 | ||
|
|
15ab6f7131 | ||
|
|
e2bd53b114 | ||
|
|
e431d62b3d | ||
|
|
fe64dfcf63 | ||
|
|
6aabb54d0a | ||
|
|
930f9b6fa0 | ||
|
|
e83f0988c0 | ||
|
|
7b010b498a | ||
|
|
dcf05ced7f | ||
|
|
4674825133 | ||
|
|
17abfc7280 | ||
|
|
f64cf10930 | ||
|
|
47eb63c7c9 | ||
|
|
7c6bfbe30b | ||
|
|
f0aad84238 | ||
|
|
d5edfa8d07 | ||
|
|
71988bd3e3 | ||
|
|
9ad2b82c88 | ||
|
|
463e8d9d77 | ||
|
|
dee2f6bfc7 | ||
|
|
2bfd1a477a | ||
|
|
db35b1f042 | ||
|
|
8b1fa2daf7 | ||
|
|
89c23637b2 | ||
|
|
4b36020f6d | ||
|
|
3e01e467ce | ||
|
|
3d89c4633f | ||
|
|
d5733763ad | ||
|
|
c554f3a572 | ||
|
|
dd2b87ccbe | ||
|
|
4b6e6e7faa | ||
|
|
4e127f0487 | ||
|
|
2ffee29111 | ||
|
|
75d3bda5a0 | ||
|
|
27d987ef19 | ||
|
|
84904235f2 | ||
|
|
611b8a4036 | ||
|
|
ab575facbc | ||
|
|
652657666c | ||
|
|
8e668e526b | ||
|
|
6d518e9db9 | ||
|
|
5e04b07f2f | ||
|
|
531bcff427 | ||
|
|
833718d160 | ||
|
|
8661631b79 | ||
|
|
c56b10ce28 | ||
|
|
87b07be973 | ||
|
|
265ca5ab39 | ||
|
|
7b619a3efa | ||
|
|
9040d58e26 | ||
|
|
3ce75ecdf2 | ||
|
|
ddaef972cd | ||
|
|
b5280e8e3e | ||
|
|
93b04ae99c | ||
|
|
5d9a5d4d38 | ||
|
|
d28d12c2cb | ||
|
|
2c2b7a495b | ||
|
|
f15299d54a | ||
|
|
a49cab944b | ||
|
|
12f7aa1781 | ||
|
|
d1e0492261 | ||
|
|
79400e908a | ||
|
|
c172e149b9 | ||
|
|
9ded97a09c | ||
|
|
ff4112a40e | ||
|
|
25148746d1 | ||
|
|
2dca593474 | ||
|
|
682fa130c7 | ||
|
|
c28b50e49e | ||
|
|
4f852f2d17 | ||
|
|
5e0fe4bec5 | ||
|
|
53dd4236bd | ||
|
|
2e826dd116 | ||
|
|
a4cb42aed3 | ||
|
|
7a7d8fedb7 | ||
|
|
ed937d1266 | ||
|
|
d57f4df6de | ||
|
|
5aa96c0e88 | ||
|
|
e0a4702041 | ||
|
|
1822d3485a | ||
|
|
39dd7c9c5b | ||
|
|
870435eba0 | ||
|
|
b4b29dcc19 | ||
|
|
c69b53babd | ||
|
|
4453313745 | ||
|
|
8d0a374008 | ||
|
|
09b8acf6d2 | ||
|
|
d3b12475b7 | ||
|
|
6dbe326997 | ||
|
|
7d70c66324 | ||
|
|
16d2514200 | ||
|
|
08bb9ead73 | ||
|
|
5b4e18b303 | ||
|
|
62dec0bc76 | ||
|
|
c638945787 | ||
|
|
44bb373af1 | ||
|
|
a40053fda1 | ||
|
|
965d54cbc5 | ||
|
|
6552ffdc69 | ||
|
|
a25be93c6a | ||
|
|
d0c3c9a4ce | ||
|
|
df2d0d5061 | ||
|
|
7ebe65686a | ||
|
|
622553c018 | ||
|
|
bf9f135f9f | ||
|
|
7421a15eec | ||
|
|
b78b470d1f | ||
|
|
5a50378330 | ||
|
|
9e48bfa0cb | ||
|
|
812b9ec918 | ||
|
|
387c8f8829 | ||
|
|
edffc2b5b1 | ||
|
|
34fc016df0 | ||
|
|
891d15deba | ||
|
|
c388ba0c58 | ||
|
|
aecfe3eda9 | ||
|
|
01f7f5bcd9 | ||
|
|
25e21ed9bf | ||
|
|
3a5be51509 | ||
|
|
b6072b7112 | ||
|
|
649caa0e44 | ||
|
|
ea71481630 | ||
|
|
5d6dde74c5 | ||
|
|
30f59f2e87 | ||
|
|
e43228e8fc | ||
|
|
9c50d21401 | ||
|
|
805b355989 | ||
|
|
b28251f8a1 | ||
|
|
ef74b7830f | ||
|
|
ed852b65c9 | ||
|
|
94addf0055 | ||
|
|
3e3a6aba14 | ||
|
|
2273872f63 | ||
|
|
2e04af515e | ||
|
|
0bdb7c1004 | ||
|
|
f9a9c6c06c | ||
|
|
dee486d3e1 | ||
|
|
e8d96ac295 | ||
|
|
eec2b53807 | ||
|
|
569ba2a134 | ||
|
|
bf1f730534 | ||
|
|
d989e582c2 | ||
|
|
4f9077f9b9 | ||
|
|
6bf89dc316 | ||
|
|
7dc39fad70 | ||
|
|
b0189956b2 | ||
|
|
95c3693642 | ||
|
|
349d0503fc | ||
|
|
5eca4d39df | ||
|
|
5b2124da6e | ||
|
|
53873c22b1 | ||
|
|
cc84410ac3 | ||
|
|
3f5333cd22 | ||
|
|
6269563254 | ||
|
|
b061cc3b9b | ||
|
|
71e72f6645 | ||
|
|
60d8da092d | ||
|
|
fc5a81afe5 | ||
|
|
46500e2157 | ||
|
|
71437e758e | ||
|
|
a2ee4eaa22 | ||
|
|
91aa513d43 | ||
|
|
129510e174 | ||
|
|
4f997cf438 | ||
|
|
b618a16569 | ||
|
|
a1b21329ec | ||
|
|
98aaa51b26 | ||
|
|
81dee319cb | ||
|
|
e88dc44592 | ||
|
|
86dffe61d9 | ||
|
|
429ade95bd | ||
|
|
c228954ffe | ||
|
|
1f867c6e64 | ||
|
|
aef83abf34 | ||
|
|
94ca368631 | ||
|
|
da350584e8 | ||
|
|
6b3d024d9d | ||
|
|
37a9a6c0f3 | ||
|
|
ca30fb63a1 | ||
|
|
1719ebc9c7 | ||
|
|
d9b2b96474 | ||
|
|
79db162e9e | ||
|
|
5189a40ce4 | ||
|
|
51ad937543 | ||
|
|
1035825e94 | ||
|
|
6f009d56ce | ||
|
|
44d2658f13 | ||
|
|
a92fca3ef5 | ||
|
|
4e37127051 | ||
|
|
f70ca33c0a | ||
|
|
f47d072729 | ||
|
|
7e138cf216 | ||
|
|
e9de85bd37 | ||
|
|
3d489581dd | ||
|
|
ce87540743 | ||
|
|
eaf1ad0085 | ||
|
|
506c665eb6 | ||
|
|
e71e4d0ada | ||
|
|
a02d08fcfc | ||
|
|
e2fd4fc37e | ||
|
|
4262ff5a3d | ||
|
|
76d7d80ecb | ||
|
|
95c442b943 | ||
|
|
e285c32157 | ||
|
|
68927160c8 | ||
|
|
01222ff5e1 | ||
|
|
43c64db6dc | ||
|
|
a9169ea54e | ||
|
|
a5c30576c3 | ||
|
|
b2aa25a18e | ||
|
|
5ed7432f77 | ||
|
|
4ee43ee057 | ||
|
|
66e30a69c7 | ||
|
|
a4d8cc425e | ||
|
|
4bee28d643 | ||
|
|
58ac9f8dc0 | ||
|
|
ba47dca45f | ||
|
|
741d492778 | ||
|
|
4073da9974 | ||
|
|
4bdbeccdd9 | ||
|
|
2fff1e2c4a | ||
|
|
defba4bc0e | ||
|
|
4cdbafc70f | ||
|
|
5fb6bcb4d5 | ||
|
|
d04b760b32 | ||
|
|
b257e020f6 | ||
|
|
9f2e1cb040 | ||
|
|
e5023e9e11 | ||
|
|
b817729e1d | ||
|
|
759da5a5c8 | ||
|
|
398bacf6ed | ||
|
|
b7cb067b6c | ||
|
|
cccf5a8348 | ||
|
|
b4b84e778a | ||
|
|
06c9675131 | ||
|
|
e67139aa3f | ||
|
|
7696b5e16c | ||
|
|
177cc26508 | ||
|
|
b158037771 | ||
|
|
5c162716e5 | ||
|
|
b52a3b6d34 | ||
|
|
57bbf5b224 | ||
|
|
e235783fc4 | ||
|
|
3c954431f9 | ||
|
|
5e406269a4 | ||
|
|
a68c2f475e | ||
|
|
b26d24c90f | ||
|
|
3beb60de0b | ||
|
|
430d3ab213 | ||
|
|
59dd7e7dc3 |
@@ -455,6 +455,9 @@
|
||||
metabase.test/with-temp-file clojure.core/let
|
||||
metabase.test/with-user-in-groups clojure.core/let
|
||||
metabase.upload.impl-test/with-upload-table! clojure.core/let
|
||||
metabase.upload-test/with-upload-table! clojure.core/let
|
||||
metabase.util/for-map clojure.core/for
|
||||
metabase.util/with-timer-ms clojure.core/fn
|
||||
metabase.util.files/with-open-path-to-resource clojure.core/let
|
||||
metabase.util.malli.defn/defn schema.core/defn
|
||||
metabase.util.malli.defn/defn- schema.core/defn
|
||||
@@ -464,7 +467,6 @@
|
||||
metabase.util.malli/defn- schema.core/defn
|
||||
metabase.util.malli/fn schema.core/fn
|
||||
metabase.util.namespaces/import-fns potemkin/import-vars
|
||||
metabase.util/with-timer-ms clojure.core/fn
|
||||
metabase.xrays.domain-entities.malli/defn schema.core/defn
|
||||
metabuild-common.core/with-open-jar-file-system clojure.core/let
|
||||
metabuild-common.files/with-open-jar-file-system clojure.core/let
|
||||
|
||||
@@ -36,11 +36,13 @@
|
||||
{actions
|
||||
{:api #{metabase.actions.api
|
||||
metabase.actions.core
|
||||
metabase.actions.init}
|
||||
metabase.actions.init
|
||||
metabase.actions.types}
|
||||
:uses #{analytics
|
||||
api
|
||||
collections
|
||||
driver
|
||||
events
|
||||
legacy-mbql
|
||||
lib
|
||||
model-persistence
|
||||
@@ -449,8 +451,16 @@
|
||||
events
|
||||
{:api #{metabase.events.core
|
||||
metabase.events.init}
|
||||
:uses #{models
|
||||
util}}
|
||||
:uses #{api
|
||||
audit-app
|
||||
config
|
||||
channel
|
||||
driver
|
||||
models
|
||||
notification
|
||||
premium-features
|
||||
util
|
||||
view-log}}
|
||||
|
||||
formatter
|
||||
{:api #{metabase.formatter.core}
|
||||
@@ -630,7 +640,8 @@
|
||||
metabase.notification.init
|
||||
metabase.notification.models
|
||||
metabase.notification.payload.core}
|
||||
:uses #{analytics
|
||||
:uses #{actions
|
||||
analytics
|
||||
api
|
||||
appearance
|
||||
channel
|
||||
@@ -639,6 +650,7 @@
|
||||
driver
|
||||
events
|
||||
models
|
||||
lib
|
||||
parameters
|
||||
permissions
|
||||
premium-features
|
||||
@@ -650,8 +662,10 @@
|
||||
system
|
||||
task
|
||||
task-history
|
||||
types
|
||||
users
|
||||
util}}
|
||||
util
|
||||
warehouse-schema}}
|
||||
|
||||
;; TODO -- too many API namespaces
|
||||
parameters
|
||||
@@ -1449,16 +1463,48 @@
|
||||
|
||||
enterprise/core
|
||||
{:api #{metabase-enterprise.core.init}
|
||||
:uses :any}
|
||||
:uses #{enterprise/advanced-config
|
||||
enterprise/audit-app
|
||||
enterprise/cache
|
||||
enterprise/data-editing
|
||||
enterprise/enhancements
|
||||
enterprise/llm
|
||||
enterprise/metabot-v3
|
||||
enterprise/gsheets
|
||||
enterprise/scim
|
||||
enterprise/sso
|
||||
enterprise/stale}}
|
||||
|
||||
enterprise/dashboard-subscription-filters
|
||||
{:api #{}
|
||||
:uses #{premium-features}}
|
||||
|
||||
enterprise/data-editing
|
||||
{:api #{metabase-enterprise.data-editing.api}
|
||||
{:api #{metabase-enterprise.data-editing.core
|
||||
metabase-enterprise.data-editing.api
|
||||
metabase-enterprise.data-editing.init}
|
||||
:uses #{api
|
||||
actions
|
||||
driver
|
||||
events
|
||||
lib
|
||||
models
|
||||
notification
|
||||
query-processor
|
||||
upload
|
||||
util
|
||||
warehouse-schema}}
|
||||
|
||||
enterprise/data-editing-public
|
||||
{:api #{}
|
||||
:uses #{api
|
||||
actions
|
||||
enterprise/data-editing
|
||||
driver
|
||||
events
|
||||
lib
|
||||
query-processor
|
||||
upload
|
||||
util}}
|
||||
|
||||
enterprise/database-routing
|
||||
|
||||
@@ -278,6 +278,7 @@
|
||||
metabase.upload.impl-test/with-upload-table! [[:inner 0]]
|
||||
metabase.upload.types/with-currency [[:default]]
|
||||
metabase.upload.types/with-parens [[:default]]
|
||||
metabase.util/for-map [[:block 1]]
|
||||
metabase.util.async/cancelable-thread [[:block 0]]
|
||||
metabase.util.date-2/with-time-zone-same-instant [[:default]]
|
||||
metabase.util.encryption-test/with-secret-key [[:block 1]]
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -146,5 +146,6 @@ llm-payloads
|
||||
.aider*
|
||||
/config.yml
|
||||
CLAUDE.local.md
|
||||
.claude/settings.local.json
|
||||
|
||||
.eslintcache
|
||||
|
||||
22
deps.edn
22
deps.edn
@@ -36,7 +36,7 @@
|
||||
com.clearspring.analytics/stream {:mvn/version "2.9.8" ; Various sketching algorithms
|
||||
:exclusions [it.unimi.dsi/fastutil
|
||||
org.slf4j/slf4j-api]}
|
||||
com.clojure-goes-fast/jvm-alloc-rate-meter{:mvn/version "0.1.4"} ; Monitor heap allocation rate.
|
||||
com.clojure-goes-fast/jvm-alloc-rate-meter {:mvn/version "0.1.4"} ; Monitor heap allocation rate.
|
||||
com.clojure-goes-fast/jvm-hiccup-meter {:mvn/version "0.1.1"} ; Monitor GC pauses and other system-induced pauses.
|
||||
com.draines/postal {:mvn/version "2.0.5"} ; SMTP library
|
||||
com.fasterxml.woodstox/woodstox-core {:mvn/version "7.1.0"} ; trans dep of commons-codec
|
||||
@@ -44,7 +44,7 @@
|
||||
com.github.albfernandez/juniversalchardet {:mvn/version "2.5.0"}
|
||||
com.github.blagerweij/liquibase-sessionlock {:mvn/version "1.6.9"} ; session lock for liquibase migrations
|
||||
com.github.jknack/handlebars ^:antq/exclude ; 4.4.0 requires java 17+
|
||||
{:mvn/version "4.3.1"} ; templating engine
|
||||
{:mvn/version "4.3.1"} ; templating engine
|
||||
com.github.pangloss/transducers {:git/sha "c91d39046c8c64dc31b84ebb27154a8084fcda4c" ; like medley for transducers
|
||||
:git/url "https://github.com/pangloss/transducers"}
|
||||
com.github.seancorfield/honeysql {:mvn/version "2.7.1310"} ; Honey SQL 2. SQL generation from Clojure data maps
|
||||
@@ -53,15 +53,14 @@
|
||||
com.github.vertical-blank/sql-formatter {:mvn/version "2.0.5"} ; Java SQL formatting library https://github.com/vertical-blank/sql-formatter
|
||||
com.google.guava/guava {:mvn/version "33.4.8-jre"} ; dep for BigQuery, Spark, and GA. Require here rather than letting different dep versions stomp on each other — see comments on #9697
|
||||
com.h2database/h2 ^:antq/exclude ; https://metaboat.slack.com/archives/CKZEMT1MJ/p1727191010259979
|
||||
{:mvn/version "2.1.214"} ; embedded SQL database
|
||||
{:mvn/version "2.1.214"} ; embedded SQL database
|
||||
com.ibm.icu/icu4j {:mvn/version "77.1"} ; Used to transliterate Unicode characters into Latin characters
|
||||
com.mchange/c3p0 {:mvn/version "0.10.1"} ; DB connection pool
|
||||
com.snowplowanalytics/snowplow-java-tracker ^:antq/exclude ; 2+ depend on apache httpclient 5.x, but saml and clj-http depend on 4.x
|
||||
{:mvn/version "1.0.1"} ; Snowplow analytics
|
||||
com.taoensso/nippy {:mvn/version "3.5.0"} ; Fast serialization (i.e., GZIP) library for Clojure
|
||||
com.vladsch.flexmark/flexmark {:mvn/version "0.64.8"} ; Markdown parsing
|
||||
com.vladsch.flexmark/flexmark-ext-autolink
|
||||
{:mvn/version "0.64.8"} ; Flexmark extension for auto-linking bare URLs
|
||||
com.vladsch.flexmark/flexmark-ext-autolink {:mvn/version "0.64.8"} ; Flexmark extension for auto-linking bare URLs
|
||||
commons-codec/commons-codec {:mvn/version "1.18.0"} ; Apache Commons -- useful codec util fns
|
||||
commons-io/commons-io {:mvn/version "2.19.0"} ; Apache Commons -- useful IO util fns
|
||||
commons-net/commons-net {:mvn/version "3.11.1"} ; Apache Commons -- useful network utils. Transitive dep of Snowplow, pinned due to CVE-2021-37533
|
||||
@@ -86,7 +85,7 @@
|
||||
:git/url "https://github.com/eerohele/pp"}
|
||||
io.github.metabase/macaw {:mvn/version "0.2.25"} ; Parse native SQL queries
|
||||
io.github.resilience4j/resilience4j-retry ^:antq/exclude ; 2.x do not support Java 8
|
||||
{:mvn/version "1.7.1"} ; Support for retrying operations
|
||||
{:mvn/version "1.7.1"} ; Support for retrying operations
|
||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} ; prometheus jvm collector
|
||||
junegunn/grouper {:mvn/version "0.1.1"} ; Batch processing helper
|
||||
kixi/stats {:mvn/version "0.5.7" ; Various statistic measures implemented as transducers
|
||||
@@ -843,7 +842,16 @@
|
||||
;; clj -X:dev:ee:ee-dev:test:test/notification
|
||||
:test/notification
|
||||
{:exec-args {:only ["test/metabase/notification"
|
||||
"test/metabase/channel"]}}
|
||||
"test/metabase/channel"
|
||||
"test/metabase/pulse"
|
||||
"enterprise/backend/test/metabase_enterprise/notification"
|
||||
metabase-enterprise.data-editing.notification-test]}}
|
||||
|
||||
;; clj -X:dev:ee:ee-dev:test:test/workflow
|
||||
:test/workflow
|
||||
{:exec-args {:only ["test/metabase/actions"
|
||||
"enterprise/backend/test/metabase_enterprise/data_editing"
|
||||
metabase.driver.sql-jdbc.actions-test]}}
|
||||
|
||||
;; test all MBQL related stuff: MLv2, the legacy shared `metabase.legacy-mbql` code, and the QP
|
||||
;;
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
[clojure.test]
|
||||
[dev.debug-qp :as debug-qp]
|
||||
[dev.explain :as dev.explain]
|
||||
[dev.malli :as dev.malli]
|
||||
[dev.memory :as dev.memory]
|
||||
[dev.migrate :as dev.migrate]
|
||||
[dev.model-tracking :as model-tracking]
|
||||
@@ -105,6 +106,8 @@
|
||||
pprint-sql]
|
||||
[dev.explain
|
||||
explain-query]
|
||||
[dev.malli
|
||||
visualize-schema!]
|
||||
[dev.migrate
|
||||
migrate!
|
||||
rollback!
|
||||
|
||||
19
dev/src/dev/malli.clj
Normal file
19
dev/src/dev/malli.clj
Normal file
@@ -0,0 +1,19 @@
|
||||
(ns dev.malli
|
||||
(:require
|
||||
[clojure.java.shell :as sh]
|
||||
[malli.dot :as md]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[ring.util.codec :as codec]))
|
||||
|
||||
(defn- graphviz-visualizer
|
||||
[schema]
|
||||
(format "https://dreampuf.github.io/GraphvizOnline/?engine=dot#%s"
|
||||
(-> schema mr/resolve-schema md/transform codec/url-encode)))
|
||||
|
||||
(defn visualize-schema!
|
||||
"Given a schema, visualize it using Graphviz.
|
||||
(visualize-schema! [:map [:a :int] [:b :string]])
|
||||
(visualize-schema! ::my/schema)"
|
||||
[schema]
|
||||
(let [url (graphviz-visualizer schema)]
|
||||
(sh/sh "open" url)))
|
||||
@@ -38,7 +38,6 @@ export const createQuestionAlert = ({
|
||||
subscriptions: [
|
||||
{
|
||||
type: "notification-subscription/cron",
|
||||
event_name: null,
|
||||
cron_schedule,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -58,7 +58,9 @@ export const viewEmailPage = (emailSubject) => {
|
||||
const webmailInterface = `http://localhost:${WEB_PORT}`;
|
||||
|
||||
cy.window().then((win) => (win.location.href = webmailInterface));
|
||||
cy.findByText(emailSubject).click();
|
||||
if (emailSubject) {
|
||||
cy.findByText(emailSubject).click();
|
||||
}
|
||||
};
|
||||
|
||||
export const openEmailPage = (emailSubject) => {
|
||||
@@ -128,6 +130,24 @@ export function sendEmailAndVisitIt() {
|
||||
});
|
||||
}
|
||||
|
||||
export function checkEmailContent(subject, body) {
|
||||
viewEmailPage(subject);
|
||||
|
||||
cy.get(".main-container").within(() => {
|
||||
cy.get(".subject").should("contain", subject);
|
||||
cy.get(".preview-iframe")
|
||||
.its("0.contentDocument.body")
|
||||
.should("not.be.empty")
|
||||
.then(($body) => {
|
||||
if (Array.isArray(body)) {
|
||||
body.forEach((str) => expect($body).to.contain(str));
|
||||
} else {
|
||||
expect($body).to.contain(body);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function sendAlertAndVisitIt() {
|
||||
cy.intercept("POST", "/api/notification/send").as("testAlert");
|
||||
cy.findByLabelText("New alert")
|
||||
|
||||
7
e2e/support/helpers/e2e-internal-tools-helpers.ts
Normal file
7
e2e/support/helpers/e2e-internal-tools-helpers.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function setTableEditingEnabledForDB(dbId: number, enabled = true) {
|
||||
return cy.request("PUT", `/api/database/${dbId}`, {
|
||||
settings: {
|
||||
"database-enable-table-editing": enabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -25,7 +25,11 @@ export const addNotificationHandlerChannel = (
|
||||
if (hasNoChannelsAdded) {
|
||||
modal().findByText("Add a destination").should("be.visible").click();
|
||||
} else {
|
||||
modal().findByText("Add another destination").should("be.visible").click();
|
||||
modal()
|
||||
.findByText("Add another destination")
|
||||
.scrollIntoView()
|
||||
.should("be.visible")
|
||||
.click();
|
||||
}
|
||||
|
||||
popover().findByText(channel).click();
|
||||
|
||||
@@ -22,6 +22,7 @@ export * from "./e2e-embedding-sdk-assertion-helpers";
|
||||
export * from "./e2e-element-visibility-helpers";
|
||||
export * from "./e2e-enterprise-helpers";
|
||||
export * from "./e2e-filter-helpers";
|
||||
export * from "./e2e-internal-tools-helpers";
|
||||
export * from "./e2e-ldap-helpers";
|
||||
export * from "./e2e-misc-helpers";
|
||||
export * from "./e2e-mock-app-settings-helpers";
|
||||
|
||||
@@ -18,7 +18,7 @@ const MODEL_NAME = "Test Action Model";
|
||||
() => {
|
||||
beforeEach(() => {
|
||||
cy.intercept("GET", /\/api\/card\/\d+/).as("getModel");
|
||||
cy.intercept("GET", "/api/action").as("getActions");
|
||||
cy.intercept("GET", "/api/ee/data-editing/tmp-action").as("getActions");
|
||||
cy.intercept("PUT", "/api/action/*").as("updateAction");
|
||||
cy.intercept("GET", "/api/action?model-id=*").as("getModelActions");
|
||||
|
||||
@@ -1376,7 +1376,9 @@ function getActionParametersInputModal() {
|
||||
|
||||
function waitForValidActions() {
|
||||
cy.wait("@getActions").then(({ response }) => {
|
||||
const { body: actions } = response;
|
||||
const {
|
||||
body: { actions },
|
||||
} = response;
|
||||
|
||||
actions.forEach((action) => {
|
||||
expect(action.parameters).to.have.length.gt(0);
|
||||
|
||||
@@ -343,10 +343,11 @@ describe("issues 55617, 55618", () => {
|
||||
cy.findAllByPlaceholderText("Select a target")
|
||||
.should("have.length", 2)
|
||||
.eq(0)
|
||||
.should("have.value", "People → ID");
|
||||
.should("have.value", "Products → ID");
|
||||
cy.findAllByPlaceholderText("Select a target")
|
||||
.eq(1)
|
||||
.should("have.value", "Products → ID");
|
||||
.should("have.value", "People → ID");
|
||||
|
||||
cy.findAllByPlaceholderText("Select a target").eq(0).click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Orders → ID").should("be.visible");
|
||||
@@ -378,10 +379,10 @@ describe("issues 55617, 55618", () => {
|
||||
.should("have.value", "People → ID")
|
||||
.click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Orders → ID").should("be.visible");
|
||||
cy.findByText("People → ID").should("be.visible");
|
||||
cy.findByText("Products → ID").should("be.visible");
|
||||
cy.findByText("Reviews → ID").should("be.visible").click();
|
||||
cy.findByText("Orders → ID").should("exist");
|
||||
cy.findByText("People → ID").should("exist");
|
||||
cy.findByText("Products → ID").should("exist");
|
||||
cy.findByText("Reviews → ID").should("exist").click();
|
||||
});
|
||||
cy.findByPlaceholderText("Select a target").should(
|
||||
"have.value",
|
||||
|
||||
@@ -2176,12 +2176,12 @@ describe("scenarios > dashboard > dashboard cards > click behavior", () => {
|
||||
],
|
||||
expressions: ["Net"],
|
||||
filters: [
|
||||
"Product → Title is Doohickey",
|
||||
"Product → Vendor is Doohickey",
|
||||
"ID is 7021",
|
||||
"Net is equal to -80",
|
||||
"Reviews - Product → Reviewer is Doohickey",
|
||||
"User → Longitude is equal to -80",
|
||||
"Reviews - Product → Reviewer is Doohickey",
|
||||
"ID is 7021",
|
||||
"Product → Vendor is Doohickey",
|
||||
"Product → Title is Doohickey",
|
||||
"Net is equal to -80",
|
||||
],
|
||||
aggregations: ["Count", "Sum of Total"],
|
||||
breakouts: [
|
||||
|
||||
636
e2e/test/scenarios/data-editing/data-editing-alerts.cy.spec.ts
Normal file
636
e2e/test/scenarios/data-editing/data-editing-alerts.cy.spec.ts
Normal file
@@ -0,0 +1,636 @@
|
||||
import { USERS, WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
|
||||
import {
|
||||
createNotificationWithCustomTemplate,
|
||||
fillInCustomTemplate,
|
||||
verifyNotificationTemplateResponse,
|
||||
} from "./e2e-helpers/notification-helpers";
|
||||
import { setupAlert } from "./e2e-helpers/setup";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
const TABLE_NAME = "colors27745";
|
||||
const ADMIN_NAME = `${USERS.admin.first_name} ${USERS.admin.last_name}`;
|
||||
|
||||
describe("scenarios > data editing > setting alerts", () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept("GET", "/api/notification").as("getNotification");
|
||||
cy.intercept("POST", "/api/notification").as("createNotification");
|
||||
|
||||
cy.intercept("POST", "/api/ee/data-editing/action/v2/execute-bulk").as(
|
||||
"executeBulk",
|
||||
);
|
||||
|
||||
cy.log("Setting up writable PostgresDB");
|
||||
H.restore("postgres-writable");
|
||||
H.resetTestTable({
|
||||
type: "postgres",
|
||||
table: TABLE_NAME,
|
||||
});
|
||||
cy.signInAsAdmin();
|
||||
H.resyncDatabase({
|
||||
dbId: WRITABLE_DB_ID,
|
||||
tableName: TABLE_NAME,
|
||||
});
|
||||
|
||||
H.setTokenFeatures("all");
|
||||
H.setupSMTP();
|
||||
|
||||
H.setTableEditingEnabledForDB(WRITABLE_DB_ID);
|
||||
});
|
||||
|
||||
it("should have icon to set alerts", () => {
|
||||
openDatabaseTable();
|
||||
cy.findByTestId("table-notifications-trigger").should("be.visible");
|
||||
});
|
||||
|
||||
describe("Show list of existing alerts", () => {
|
||||
it("should show list of existing alerts", () => {
|
||||
H.getTableId({
|
||||
name: TABLE_NAME,
|
||||
}).then((tableId) => {
|
||||
setupAlert(tableId, "event/row.created");
|
||||
setupAlert(tableId, "event/row.updated");
|
||||
setupAlert(tableId, "event/row.deleted");
|
||||
});
|
||||
|
||||
openDatabaseTable();
|
||||
|
||||
cy.then(() => {
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("alert-list-modal")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("Notify when new records are created").should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.findByText("Notify when records are updated").should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.findByText("Notify when records are deleted").should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Trigger an alert for each event type", () => {
|
||||
describe("create event", () => {
|
||||
it("should create an alert for 'row created' events with default template", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when new records are created/i,
|
||||
})
|
||||
.click();
|
||||
cy.findByRole("button", { name: "Done" }).click();
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-data-view-header").within(() => {
|
||||
cy.findByText("New record").click();
|
||||
});
|
||||
|
||||
H.modal().within(() => {
|
||||
// Focus on second input, omitting "ID" field which is auto-populated.
|
||||
cy.get("input").eq(1).type("black");
|
||||
|
||||
cy.findByRole("button", { name: "Create new record" }).click();
|
||||
cy.wait("@executeBulk");
|
||||
});
|
||||
|
||||
cy.log("Testing default email template");
|
||||
|
||||
H.checkEmailContent(
|
||||
`A new record was added to "${TABLE_NAME}" by ${ADMIN_NAME}`,
|
||||
`A new record was created in Table ${TABLE_NAME}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should create an alert for 'row created' events with custom template", () => {
|
||||
const CUSTOM_SUBJECT = "My custom subject for {{table.name}}";
|
||||
const CUSTOM_BODY = "{{#each record}} {{@key}}: {{@value}} {{/each}}";
|
||||
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
createNotificationWithCustomTemplate(
|
||||
/when new records are created/i,
|
||||
CUSTOM_SUBJECT,
|
||||
CUSTOM_BODY,
|
||||
).then((interception) => {
|
||||
verifyNotificationTemplateResponse(
|
||||
interception,
|
||||
CUSTOM_SUBJECT,
|
||||
CUSTOM_BODY,
|
||||
);
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-data-view-header").within(() => {
|
||||
cy.findByText("New record").click();
|
||||
});
|
||||
|
||||
H.modal().within(() => {
|
||||
// Focus on second input, omitting "ID" field which is auto-populated.
|
||||
cy.get("input").eq(1).type("black");
|
||||
|
||||
cy.findByRole("button", { name: "Create new record" }).click();
|
||||
});
|
||||
|
||||
cy.wait("@executeBulk");
|
||||
|
||||
cy.log("Testing default email template");
|
||||
|
||||
H.checkEmailContent(`My custom subject for ${TABLE_NAME}`, [
|
||||
"id",
|
||||
"black",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update event", () => {
|
||||
it("should trigger an alert for 'row updated' events with default template", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when any cell changes it's value/i,
|
||||
})
|
||||
.click();
|
||||
cy.findByRole("button", { name: "Done" }).click();
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-body").within(() => {
|
||||
cy.findByText("red").click();
|
||||
cy.focused().type("blue").blur();
|
||||
});
|
||||
|
||||
cy.wait("@executeBulk");
|
||||
|
||||
cy.log("Testing default email template");
|
||||
|
||||
H.checkEmailContent(
|
||||
`A record was updated in "${TABLE_NAME}" by ${ADMIN_NAME}`,
|
||||
[`A record was updated in Table ${TABLE_NAME}`, "redblue"],
|
||||
);
|
||||
});
|
||||
|
||||
it("should trigger an alert for 'row updated' events with custom template", () => {
|
||||
const CUSTOM_SUBJECT = "My custom subject for {{table.name}}";
|
||||
const CUSTOM_BODY = "{{#each record}} {{@key}}: {{@value}} {{/each}}";
|
||||
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
createNotificationWithCustomTemplate(
|
||||
/when any cell changes it's value/i,
|
||||
CUSTOM_SUBJECT,
|
||||
CUSTOM_BODY,
|
||||
).then((interception) => {
|
||||
verifyNotificationTemplateResponse(
|
||||
interception,
|
||||
CUSTOM_SUBJECT,
|
||||
CUSTOM_BODY,
|
||||
);
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-body").within(() => {
|
||||
cy.findByText("red").click();
|
||||
cy.focused().type("blue").blur();
|
||||
});
|
||||
|
||||
cy.wait("@executeBulk");
|
||||
|
||||
cy.log("Testing custom email template");
|
||||
|
||||
H.checkEmailContent(`My custom subject for ${TABLE_NAME}`, [
|
||||
"id",
|
||||
"name",
|
||||
"redblue",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should trigger an alert for 'row updated' events when condition is met", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when any cell changes it's value/i,
|
||||
})
|
||||
.click();
|
||||
|
||||
cy.findByText("Add conditions for alerts").click();
|
||||
cy.findByPlaceholderText("Select column").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /name/i,
|
||||
})
|
||||
.click();
|
||||
cy.findByPlaceholderText("Select value").click().type("white").blur();
|
||||
|
||||
cy.findByRole("button", { name: "Done" }).click();
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-body").within(() => {
|
||||
cy.findByText("red").click();
|
||||
cy.focused().type("{selectall}{backspace}white").blur();
|
||||
});
|
||||
|
||||
cy.wait("@executeBulk");
|
||||
|
||||
cy.log("Testing custom email template");
|
||||
|
||||
H.checkEmailContent(
|
||||
`A record was updated in "${TABLE_NAME}" by ${ADMIN_NAME}`,
|
||||
[`A record was updated in Table ${TABLE_NAME}`, "white"],
|
||||
);
|
||||
});
|
||||
|
||||
it("should not trigger an alert for 'row updated' events when condition is not met", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when any cell changes it's value/i,
|
||||
})
|
||||
.click();
|
||||
|
||||
cy.findByText("Add conditions for alerts").click();
|
||||
cy.findByPlaceholderText("Select column").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /name/i,
|
||||
})
|
||||
.click();
|
||||
cy.findByPlaceholderText("Select value").click().type("white").blur();
|
||||
|
||||
cy.findByRole("button", { name: "Done" }).click();
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-body").within(() => {
|
||||
cy.findByText("red").click();
|
||||
cy.focused().type("{selectall}{backspace}black").blur();
|
||||
});
|
||||
|
||||
cy.wait("@executeBulk");
|
||||
|
||||
// Alert on updated value was not sent, since it didn't meet the condition.
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body.length).to.equal(1);
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete event", () => {
|
||||
it("should trigger an alert for 'row deleted' events with default template", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when records are deleted/i,
|
||||
})
|
||||
.click();
|
||||
cy.findByRole("button", { name: "Done" }).click();
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-body").within(() => {
|
||||
cy.get("[data-index=0]").within(() => {
|
||||
cy.findByRole("checkbox").click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.findByTestId("table-data-view-header").within(() => {
|
||||
cy.findByText("Delete").click();
|
||||
cy.document().findByText("Delete 1 record").click();
|
||||
cy.wait("@executeBulk");
|
||||
});
|
||||
|
||||
cy.log("Testing default email template");
|
||||
|
||||
H.checkEmailContent(
|
||||
`A record was deleted from "${TABLE_NAME}" by ${ADMIN_NAME}`,
|
||||
[`A record was deleted in Table ${TABLE_NAME}`],
|
||||
);
|
||||
});
|
||||
|
||||
it("should trigger an alert for 'row deleted' events with custom template", () => {
|
||||
const CUSTOM_SUBJECT = "My custom subject for {{table.name}}";
|
||||
const CUSTOM_BODY = "{{#each record}} {{@key}}: {{@value}} {{/each}}";
|
||||
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
createNotificationWithCustomTemplate(
|
||||
/when records are deleted/i,
|
||||
CUSTOM_SUBJECT,
|
||||
CUSTOM_BODY,
|
||||
).then((interception) => {
|
||||
verifyNotificationTemplateResponse(
|
||||
interception,
|
||||
CUSTOM_SUBJECT,
|
||||
CUSTOM_BODY,
|
||||
);
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-body").within(() => {
|
||||
cy.get("[data-index=0]").within(() => {
|
||||
cy.findByRole("checkbox").click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.findByTestId("table-data-view-header").within(() => {
|
||||
cy.findByText("Delete").click();
|
||||
cy.document().findByText("Delete 1 record").click();
|
||||
cy.wait("@executeBulk");
|
||||
});
|
||||
|
||||
cy.log("Testing custom email template");
|
||||
|
||||
H.checkEmailContent(`My custom subject for ${TABLE_NAME}`, [
|
||||
"id",
|
||||
"name",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Update an existing alert", () => {
|
||||
it("should update an existing alert", () => {
|
||||
H.getTableId({
|
||||
name: TABLE_NAME,
|
||||
}).then((tableId) => {
|
||||
setupAlert(tableId, "event/row.created");
|
||||
});
|
||||
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
cy.findByTestId("alert-list-modal")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("Notify when new records are created")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByText("Edit alert").should("be.visible");
|
||||
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when any cell changes it's value/i,
|
||||
})
|
||||
.click();
|
||||
|
||||
cy.findByRole("button", { name: "Save changes" }).click();
|
||||
});
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert updated.").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Delete an existing alert", () => {
|
||||
it("should delete an existing alert", () => {
|
||||
H.getTableId({
|
||||
name: TABLE_NAME,
|
||||
}).then((tableId) => {
|
||||
setupAlert(tableId, "event/row.created");
|
||||
});
|
||||
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
cy.findByTestId("alert-list-modal")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("Notify when new records are created").realHover();
|
||||
cy.root().findByLabelText("Delete this alert").click();
|
||||
cy.document()
|
||||
.findByTestId("delete-confirm")
|
||||
.findByText("Delete it")
|
||||
.click();
|
||||
});
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("The alert was successfully deleted.").should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Template preview", () => {
|
||||
it("should display default template preview", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when new records are created/i,
|
||||
})
|
||||
.click();
|
||||
|
||||
cy.intercept("POST", "/api/notification/preview_template").as(
|
||||
"previewTemplate",
|
||||
);
|
||||
|
||||
cy.findByLabelText("Show preview").click();
|
||||
|
||||
cy.wait("@previewTemplate");
|
||||
cy.findByTestId("preview-template-panel").should("be.visible");
|
||||
cy.findByTestId("preview-template-panel").within(() => {
|
||||
cy.root().contains(`A new record was added to "${TABLE_NAME}"`);
|
||||
cy.root().contains(`A new record was created in Table ${TABLE_NAME}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should display custom template preview", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when new records are created/i,
|
||||
})
|
||||
.click();
|
||||
|
||||
fillInCustomTemplate(
|
||||
"My custom subject for {{table.name}}",
|
||||
"{{#each record}} {{@key}}: {{@value}} {{/each}}",
|
||||
);
|
||||
|
||||
cy.intercept("POST", "/api/notification/preview_template").as(
|
||||
"previewTemplate",
|
||||
);
|
||||
|
||||
cy.findByLabelText("Show preview").click();
|
||||
|
||||
cy.wait("@previewTemplate");
|
||||
cy.findByTestId("preview-template-panel").should("be.visible");
|
||||
cy.findByTestId("preview-template-panel").within(() => {
|
||||
cy.root().contains(`My custom subject for ${TABLE_NAME}`);
|
||||
cy.root().should("not.contain", "a new record was created");
|
||||
cy.root().contains(/id.*name/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error message if template is incorrect", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when new records are created/i,
|
||||
})
|
||||
.click();
|
||||
|
||||
fillInCustomTemplate(
|
||||
"My custom subject for {{table.name}}",
|
||||
"{{/each}}",
|
||||
);
|
||||
|
||||
cy.intercept("POST", "/api/notification/preview_template").as(
|
||||
"previewTemplate",
|
||||
);
|
||||
|
||||
cy.findByLabelText("Show preview").click();
|
||||
|
||||
cy.wait("@previewTemplate");
|
||||
cy.findByTestId("preview-template-panel").should("be.visible");
|
||||
cy.findByTestId("preview-template-panel").within(() => {
|
||||
cy.root().should("not.contain", "My custom subject for");
|
||||
cy.root().should("contain", "Failed to render template");
|
||||
cy.root().contains(/found:.*expected:/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function openDatabaseTable() {
|
||||
H.getTableId({
|
||||
name: TABLE_NAME,
|
||||
}).then((tableId) => {
|
||||
cy.visit(`/browse/databases/${WRITABLE_DB_ID}/tables/${tableId}/edit`);
|
||||
});
|
||||
}
|
||||
97
e2e/test/scenarios/data-editing/data-editing.cy.spec.ts
Normal file
97
e2e/test/scenarios/data-editing/data-editing.cy.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("scenarios > embedding > dashboard parameters", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.setTokenFeatures("all");
|
||||
|
||||
H.setTableEditingEnabledForDB(SAMPLE_DB_ID);
|
||||
|
||||
cy.intercept("GET", "/api/database").as("getDatabases");
|
||||
cy.intercept("GET", "/api/table/*").as("getTable");
|
||||
});
|
||||
|
||||
it("should allow to open table data page", () => {
|
||||
openDatabaseTable("People");
|
||||
|
||||
cy.wait("@getTable");
|
||||
|
||||
cy.findByTestId("main-navbar-root").should("not.be.visible");
|
||||
|
||||
cy.findByTestId("table-data-view-root")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("People").should("be.visible");
|
||||
|
||||
cy.findByText("Explore").should("be.visible");
|
||||
cy.findByText("Edit").should("be.visible");
|
||||
|
||||
cy.findByTestId("table-root").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow to open table data edit mode", () => {
|
||||
openDatabaseTable("People");
|
||||
|
||||
cy.wait("@getTable");
|
||||
|
||||
cy.findByTestId("table-data-view-root").findByText("Edit").click();
|
||||
|
||||
cy.findByTestId("edit-table-data-root")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("People").should("be.visible");
|
||||
cy.findByTestId("head-crumbs-container")
|
||||
.findByText("Edit")
|
||||
.should("be.visible");
|
||||
|
||||
cy.findByTestId("table-root").should("be.visible");
|
||||
});
|
||||
|
||||
cy.findByTestId("head-crumbs-container").findByText("People").click();
|
||||
|
||||
cy.findByTestId("table-data-view-root").should("be.visible");
|
||||
});
|
||||
|
||||
it("should allow to open table data explore mode - query builder", () => {
|
||||
openDatabaseTable("People");
|
||||
|
||||
cy.wait("@getTable");
|
||||
|
||||
cy.findByTestId("table-data-view-root").findByText("Explore").click();
|
||||
|
||||
cy.findByTestId("query-builder-root")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("Sample Database");
|
||||
cy.findByText("People");
|
||||
});
|
||||
});
|
||||
|
||||
describe("db setting is disabled", () => {
|
||||
it("should not allow to open table data view by default", () => {
|
||||
H.setTableEditingEnabledForDB(SAMPLE_DB_ID, false);
|
||||
|
||||
openDatabaseTable("People");
|
||||
|
||||
cy.findByTestId("query-builder-root")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("Sample Database");
|
||||
cy.findByText("People");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function openDatabaseTable(tableName: string) {
|
||||
cy.visit("/browse/databases");
|
||||
|
||||
cy.wait("@getDatabases");
|
||||
|
||||
cy.findByTestId("database-browser").findByText("Sample Database").click();
|
||||
cy.findByTestId("browse-schemas").findByText(tableName).click();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Using type import to avoid runtime dependency on cypress types
|
||||
type Interception = {
|
||||
response?: {
|
||||
statusCode?: number;
|
||||
body?: {
|
||||
handlers?: Array<{
|
||||
template?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const fillInCustomTemplate = (subject: string, body: string) => {
|
||||
cy.findByTestId("email-template-subject")
|
||||
.find(".cm-content")
|
||||
.then(([contentElement]) => {
|
||||
const cm = contentElement.cmView?.view;
|
||||
if (cm) {
|
||||
cm.contentDOM.focus();
|
||||
cm.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: cm.state.doc.length,
|
||||
insert: subject,
|
||||
},
|
||||
});
|
||||
cm.contentDOM.blur();
|
||||
}
|
||||
|
||||
cy.wait(200);
|
||||
|
||||
cy.document()
|
||||
.findByTestId("email-template-body")
|
||||
.find(".cm-content")
|
||||
.then(([contentElement]) => {
|
||||
const cm = contentElement.cmView?.view;
|
||||
if (cm) {
|
||||
cm.contentDOM.focus();
|
||||
cm.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: cm.state.doc.length,
|
||||
insert: body,
|
||||
},
|
||||
});
|
||||
cm.contentDOM.blur();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const verifyNotificationTemplateResponse = (
|
||||
response: Interception,
|
||||
expectedSubject: string,
|
||||
expectedBody: string,
|
||||
) => {
|
||||
expect(response?.response?.statusCode).to.equal(200);
|
||||
const stringifiedTemplate = JSON.stringify(
|
||||
response?.response?.body?.handlers?.[0]?.template,
|
||||
);
|
||||
expect(stringifiedTemplate).to.contain(expectedSubject);
|
||||
expect(stringifiedTemplate).to.contain(expectedBody);
|
||||
};
|
||||
|
||||
export const createNotificationWithCustomTemplate = (
|
||||
eventName: string | RegExp,
|
||||
subject: string,
|
||||
body: string,
|
||||
) => {
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: eventName,
|
||||
})
|
||||
.click();
|
||||
|
||||
fillInCustomTemplate(subject, body);
|
||||
|
||||
cy.findByRole("button", { name: "Done" }).click();
|
||||
});
|
||||
|
||||
// Wait for the notification to be created and return the response
|
||||
return cy.wait("@createNotification");
|
||||
};
|
||||
26
e2e/test/scenarios/data-editing/e2e-helpers/setup.ts
Normal file
26
e2e/test/scenarios/data-editing/e2e-helpers/setup.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ADMIN_USER_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
|
||||
export const setupAlert = (tableId: number, eventName: string) => {
|
||||
return cy.request("POST", "/api/notification", {
|
||||
payload_type: "notification/system-event",
|
||||
payload: { event_name: eventName, table_id: tableId },
|
||||
payload_id: null,
|
||||
handlers: [
|
||||
{
|
||||
channel_type: "channel/email",
|
||||
recipients: [
|
||||
{
|
||||
type: "notification-recipient/user",
|
||||
user_id: ADMIN_USER_ID,
|
||||
details: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
condition: [
|
||||
"and",
|
||||
["=", ["context", "table_id"], tableId],
|
||||
["=", ["context", "event_name"], eventName],
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -126,7 +126,7 @@ describe("scenarios > account > notifications", () => {
|
||||
.findByText("Successfully unsubscribed.")
|
||||
.should("exist");
|
||||
|
||||
cy.findByTestId("alert-delete").within(() => {
|
||||
cy.findByTestId("delete-confirm").within(() => {
|
||||
cy.findByText("You’re unsubscribed. Delete this alert as well?");
|
||||
cy.findByText("Delete it").click();
|
||||
});
|
||||
|
||||
@@ -53,7 +53,8 @@ describe("impersonated permission", { tags: "@external" }, () => {
|
||||
setImpersonatedPermission();
|
||||
});
|
||||
|
||||
it("have limited access", () => {
|
||||
// TODO [WRK]: refactor this test to check data editing
|
||||
it.skip("have limited access", () => {
|
||||
cy.signInAsImpersonatedUser();
|
||||
|
||||
cy.visit(`/browse/databases/${PG_DB_ID}`);
|
||||
|
||||
@@ -53,7 +53,7 @@ const SUM_OF_TOTAL_MONTH_EXCLUDE_MAY_AUG = {
|
||||
"!=",
|
||||
[
|
||||
"field",
|
||||
"CREATED_AT",
|
||||
ORDERS.CREATED_AT,
|
||||
{
|
||||
"base-type": "type/DateTime",
|
||||
"temporal-unit": "month-of-year",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
[metabase-enterprise.audit-app.api.routes]
|
||||
[metabase-enterprise.billing.api.routes]
|
||||
[metabase-enterprise.content-verification.api.routes]
|
||||
[metabase-enterprise.data-editing-public.api]
|
||||
[metabase-enterprise.data-editing.api]
|
||||
[metabase-enterprise.database-routing.api]
|
||||
[metabase-enterprise.gsheets.api :as gsheets.api]
|
||||
@@ -78,6 +79,7 @@
|
||||
"/autodescribe" (premium-handler 'metabase-enterprise.llm.api :llm-autodescription)
|
||||
"/billing" metabase-enterprise.billing.api.routes/routes
|
||||
"/data-editing" (premium-handler metabase-enterprise.data-editing.api/routes :table-data-editing)
|
||||
"/data-editing-public" (premium-handler metabase-enterprise.data-editing-public.api/routes :table-data-editing)
|
||||
"/gsheets" (-> gsheets.api/routes ;; gsheets requires both features.
|
||||
(premium-handler :attached-dwh)
|
||||
(premium-handler :etl-connections))
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
[metabase-enterprise.advanced-config.init]
|
||||
[metabase-enterprise.audit-app.init]
|
||||
[metabase-enterprise.cache.init]
|
||||
[metabase-enterprise.data-editing.init]
|
||||
[metabase-enterprise.gsheets.init]
|
||||
[metabase-enterprise.llm.init]
|
||||
[metabase-enterprise.metabot-v3.init]
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
(ns metabase-enterprise.data-editing.actions
|
||||
(:require
|
||||
[clojure.spec.alpha :as s]
|
||||
[metabase-enterprise.data-editing.data-editing :as data-editing]
|
||||
[metabase-enterprise.data-editing.undo :as undo]
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.lib.schema.actions :as lib.schema.actions]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr])
|
||||
(:import
|
||||
(clojure.lang ExceptionInfo)))
|
||||
|
||||
(derive :data-grid.row/create :data-grid.row/common)
|
||||
(derive :data-grid.row/update :data-grid.row/common)
|
||||
(derive :data-grid.row/delete :data-grid.row/common)
|
||||
|
||||
(s/def :actions.args.data-grid/common
|
||||
:actions.args.crud.table/row)
|
||||
|
||||
(defmethod actions/action-arg-map-spec :data-grid.row/common
|
||||
[_action]
|
||||
:actions.args.data-grid/common)
|
||||
|
||||
(defmethod actions/normalize-action-arg-map :data-grid.row/common
|
||||
[_action input]
|
||||
(update-keys input u/qualified-name))
|
||||
|
||||
(defn- scope->table-id [context]
|
||||
(u/prog1 (-> context :scope :table-id)
|
||||
(when-not <>
|
||||
(throw (ex-info "Cannot perform data-grid actions without a table in scope."
|
||||
{:error :data-grid.row/unknown-table})))))
|
||||
|
||||
(defn- map-inputs [table-id inputs]
|
||||
(for [row (data-editing/apply-coercions table-id inputs)]
|
||||
{:table-id table-id :row row}))
|
||||
|
||||
(defn- perform-table-row-action!
|
||||
"Perform an action directly, skipping permissions checks etc, and generate outputs based on the table modifications."
|
||||
[action-kw context inputs xform-outputs]
|
||||
(update (actions/perform-nested-action! action-kw context inputs)
|
||||
:outputs
|
||||
xform-outputs))
|
||||
|
||||
(defn- post-process [outputs]
|
||||
(for [[table-id diffs] (u/group-by :table-id identity outputs)
|
||||
:let [pretty-rows (data-editing/invalidate-and-present! table-id (map :row diffs))]
|
||||
[op pretty-row] (map vector (map :op diffs) pretty-rows)]
|
||||
{:op op, :table-id table-id, :row pretty-row}))
|
||||
|
||||
(defn- perform! [action-kw context inputs]
|
||||
;; We could enhance this in future to work more richly with multi-table editable models.
|
||||
(let [table-id (scope->table-id context)
|
||||
next-inputs (map-inputs table-id inputs)]
|
||||
(perform-table-row-action! action-kw context next-inputs
|
||||
(if (= :table.row/delete action-kw)
|
||||
identity
|
||||
post-process))))
|
||||
|
||||
(mu/defmethod actions/perform-action!* [:sql-jdbc :data-grid.row/create]
|
||||
[_action context inputs :- [:sequential ::lib.schema.actions/row]]
|
||||
(perform! :table.row/create context inputs))
|
||||
|
||||
(mu/defmethod actions/perform-action!* [:sql-jdbc :data-grid.row/update]
|
||||
[_action context inputs :- [:sequential ::lib.schema.actions/row]]
|
||||
(perform! :table.row/update context inputs))
|
||||
|
||||
(mu/defmethod actions/perform-action!* [:sql-jdbc :data-grid.row/delete]
|
||||
[_action context inputs :- [:sequential ::lib.schema.actions/row]]
|
||||
(perform! :table.row/delete context inputs))
|
||||
|
||||
;; undo
|
||||
|
||||
(s/def :actions.args/none map?)
|
||||
|
||||
(defmethod actions/action-arg-map-spec :data-editing/undo [_action] :actions.args/none)
|
||||
(defmethod actions/action-arg-map-spec :data-editing/redo [_action] :actions.args/none)
|
||||
|
||||
(defmethod actions/normalize-action-arg-map :data-editing/undo [_action _input] {})
|
||||
(defmethod actions/normalize-action-arg-map :data-editing/redo [_action _input] {})
|
||||
|
||||
(mr/def ::lib.schema.actions/nothing [:map {:closed true}])
|
||||
|
||||
(defn- translate-undo-error [e]
|
||||
(case (:error (ex-data e))
|
||||
:undo/none (ex-info (tru "Nothing to do") {:status-code 204} e)
|
||||
:undo/cannot-undelete (ex-info (tru "You cannot undo your previous change.") {:status-code 405} e)
|
||||
:undo/conflict (ex-info (tru "Your previous change has a conflict with another edit") {:status-code 409} e)
|
||||
e))
|
||||
|
||||
(mu/defmethod actions/perform-action!* [:sql-jdbc :data-editing/undo]
|
||||
[_action context _inputs :- [:sequential ::lib.schema.actions/nothing]]
|
||||
(try
|
||||
{:context context
|
||||
:outputs (undo/undo! context (:user-id context) (:scope context))}
|
||||
(catch ExceptionInfo e
|
||||
(throw (translate-undo-error e)))))
|
||||
|
||||
(mu/defmethod actions/perform-action!* [:sql-jdbc :data-editing/redo]
|
||||
[_action context _inputs :- [:sequential ::lib.schema.actions/nothing]]
|
||||
(try
|
||||
{:context context
|
||||
:outputs (undo/redo! context (:user-id context) (:scope context))}
|
||||
(catch ExceptionInfo e
|
||||
(throw (translate-undo-error e)))))
|
||||
@@ -1,38 +1,547 @@
|
||||
(ns metabase-enterprise.data-editing.api
|
||||
(:require
|
||||
[clojure.walk :as walk]
|
||||
[metabase-enterprise.data-editing.data-editing :as data-editing]
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.actions.types :as types]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :as i18n]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- perform-bulk-action! [action-kw table-id rows]
|
||||
(actions/perform-action! action-kw
|
||||
{:database (api/check-404 (t2/select-one-fn :db_id [:model/Table :db_id] table-id))
|
||||
:table-id table-id
|
||||
:arg rows}))
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn require-authz?
|
||||
"Temporary hack to have auth be off by default, only on if MB_DATA_EDITING_AUTHZ=true.
|
||||
Remove once auth policy is fixed for dashboards."
|
||||
[]
|
||||
(= "true" (System/getenv "MB_DATA_EDITING_AUTHZ")))
|
||||
|
||||
(defn- check-permissions []
|
||||
(when (require-authz?)
|
||||
(api/check-superuser)))
|
||||
|
||||
(api.macros/defendpoint :post "/table/:table-id"
|
||||
"Insert row(s) into the given table."
|
||||
[{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]
|
||||
{}
|
||||
{:keys [rows]}]
|
||||
(perform-bulk-action! :bulk/create table-id rows))
|
||||
{:keys [rows scope]} :- [:map
|
||||
[:rows [:sequential {:min 1} :map]]
|
||||
;; TODO make this non-optional in the future
|
||||
[:scope {:optional true} ::types/scope.raw]]]
|
||||
(check-permissions)
|
||||
(let [;; This is a bit unfortunate... we ignore the table-id in the path when called with a custom scope...
|
||||
;; The solution is to stop accepting custom scope once we migrate the data grid to action/execute
|
||||
scope (or scope {:table-id table-id})]
|
||||
{:created-rows (map :row (:outputs (actions/perform-action! :data-grid.row/create scope rows)))}))
|
||||
|
||||
(api.macros/defendpoint :put "/table/:table-id"
|
||||
"Update row(s) within the given table."
|
||||
[{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]
|
||||
{}
|
||||
{:keys [rows]}]
|
||||
(perform-bulk-action! :bulk/update table-id rows))
|
||||
{:keys [rows pks updates scope]}
|
||||
:- [:multi {:dispatch #(cond
|
||||
(:rows %) :mixed-updates
|
||||
(:pks %) :uniform-updates)}
|
||||
[:mixed-updates [:map
|
||||
[:rows [:sequential {:min 1} :map]]
|
||||
;; TODO make :scope required
|
||||
[:scope {:optional true} ::types/scope.raw]]]
|
||||
[:uniform-updates [:map
|
||||
[:pks [:sequential {:min 1} :map]]
|
||||
[:updates :map]
|
||||
;; TODO make :scope required
|
||||
[:scope {:optional true} ::types/scope.raw]]]]]
|
||||
(check-permissions)
|
||||
(if (empty? (or rows pks))
|
||||
{:updated []}
|
||||
(let [;; This is a bit unfortunate... we ignore the table-id in the path when called with a custom scope...
|
||||
;; The solution is to stop accepting custom scope once we migrate the data grid to action/execute
|
||||
scope (or scope {:table-id table-id})
|
||||
rows (or rows
|
||||
;; For now, it's just a shim, because we haven't implemented an efficient bulk update action yet.
|
||||
;; This is a dumb shim; we're not checking that the pk maps are really (just) the pks.
|
||||
(map #(merge % updates) pks))]
|
||||
{:updated (map :row (:outputs (actions/perform-action! :data-grid.row/update scope rows)))})))
|
||||
|
||||
(api.macros/defendpoint :delete "/table/:table-id"
|
||||
;; This is a POST instead of DELETE as not all web proxies pass on the body of DELETE requests.
|
||||
(api.macros/defendpoint :post "/table/:table-id/delete"
|
||||
"Delete row(s) from the given table"
|
||||
[{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]
|
||||
{}
|
||||
{:keys [rows]}]
|
||||
(perform-bulk-action! :bulk/delete table-id rows))
|
||||
{:keys [rows scope]} :- [:map
|
||||
[:rows [:sequential {:min 1} :map]]
|
||||
;; make this non-optional in the future
|
||||
[:scope {:optional true} ::types/scope.raw]]]
|
||||
(check-permissions)
|
||||
;; This is a bit unfortunate... we ignore the table-id in the path when called with a custom scope...
|
||||
;; The solution is to stop accepting custom scope once we migrate the data grid to action/execute
|
||||
(let [scope (or scope {:table-id table-id})]
|
||||
(actions/perform-action! :data-grid.row/delete scope rows)
|
||||
{:success true}))
|
||||
|
||||
;; might later be changed, or made driver specific, we might later drop the requirement depending on admin trust
|
||||
;; model (e.g are admins trusted with writing arbitrary SQL cases anyway, will non admins ever call this?)
|
||||
(def ^:private Identifier
|
||||
"A malli schema for strings that can be used as SQL identifiers"
|
||||
[:re #"^[\w\- ]+$"])
|
||||
|
||||
;; upload types are used temporarily, I expect this to change
|
||||
(def ^:private column-type->upload-type
|
||||
{"auto_incrementing_int_pk" :metabase.upload/auto-incrementing-int-pk
|
||||
"boolean" :metabase.upload/boolean
|
||||
"int" :metabase.upload/int
|
||||
"float" :metabase.upload/float
|
||||
"varchar255" :metabase.upload/varchar-255
|
||||
"text" :metabase.upload/text
|
||||
"date" :metabase.upload/date
|
||||
"datetime" :metabase.upload/datetime
|
||||
"offset_datetime" :metabase.upload/timestamp-with-time-zone})
|
||||
|
||||
(def ^:private ColumnType
|
||||
(into [:enum] (keys column-type->upload-type)))
|
||||
|
||||
(defn- ensure-database-type [driver column-type]
|
||||
(if-some [upload-type (column-type->upload-type column-type)]
|
||||
(driver/upload-type->database-type driver upload-type)
|
||||
(throw (ex-info (i18n/tru "Not a supported column type: {0}" column-type)
|
||||
{:status 400, :column-type column-type}))))
|
||||
|
||||
(api.macros/defendpoint :post "/database/:db-id/table"
|
||||
"Creates a new table in the given database"
|
||||
[{:keys [db-id]} :- [:map [:db-id ms/PositiveInt]]
|
||||
_
|
||||
{table-name :name
|
||||
:keys [primary_key columns]}
|
||||
:-
|
||||
[:map
|
||||
[:name Identifier]
|
||||
[:primary_key [:seqable {:min-count 1} Identifier]]
|
||||
[:columns [:seqable
|
||||
[:map
|
||||
[:name Identifier]
|
||||
[:type ColumnType]]]]]]
|
||||
(check-permissions)
|
||||
(let [{driver :engine :as database} (api/check-404 (t2/select-one :model/Database db-id))
|
||||
_ (actions/check-data-editing-enabled-for-database! database)
|
||||
column-map (->> (for [{column-name :name
|
||||
column-type :type} columns]
|
||||
[column-name (ensure-database-type driver column-type)])
|
||||
(into {}))]
|
||||
(driver/create-table! driver db-id table-name column-map :primary-key (map keyword primary_key))))
|
||||
|
||||
(api.macros/defendpoint :post "/webhook"
|
||||
"Creates a new webhook endpoint token.
|
||||
The token can be used with the unauthenticated ingestion endpoint.
|
||||
POST /ee/data-editing-public/webhook/{token} to insert rows."
|
||||
[_
|
||||
_
|
||||
{:keys [table-id]}] :- [:map [:table-id ms/PositiveInt]]
|
||||
(check-permissions)
|
||||
(let [_ (api/check-404 (t2/select-one :model/Table table-id))
|
||||
token (u/generate-nano-id)
|
||||
user-id api/*current-user-id*]
|
||||
(t2/insert! :table_webhook_token {:token token, :table_id table-id, :creator_id user-id})
|
||||
{:table_id table-id
|
||||
:token token}))
|
||||
|
||||
(api.macros/defendpoint :delete "/webhook/:token"
|
||||
"Deletes a webhook endpoint token."
|
||||
[{:keys [token]}
|
||||
_
|
||||
_]
|
||||
(check-permissions)
|
||||
(let [deleted-count (t2/delete! :table_webhook_token :token token)]
|
||||
(api/check-404 (pos? deleted-count)))
|
||||
{})
|
||||
|
||||
(api.macros/defendpoint :get "/webhook"
|
||||
"Lists webhook endpoints tokens for a table.
|
||||
Behaviour is currently undefined if no table-id parameter is specified"
|
||||
[_
|
||||
{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]]
|
||||
(check-permissions)
|
||||
(api/check-404 (t2/select-one :model/Table table-id))
|
||||
(let [include-cols [:token :table_id :creator_id]]
|
||||
{:tokens (t2/select (into [:table_webhook_token] include-cols) :table_id table-id)}))
|
||||
|
||||
(api.macros/defendpoint :get "/row-action/:action-id"
|
||||
"Like /api/action but specialises the parameter requirements for a dashcard context where selected rows
|
||||
maybe provided values for e.g primary key values.
|
||||
Parameter names that intersect with the tables fields will be omitted from the parameter list.
|
||||
Later when evaluated as a row action using /row-action/:action-id/execute, you will also be able to omit these parameters."
|
||||
[{:keys [action-id]} :- [:map [:action-id :string]]
|
||||
{:keys [dashcard-id]} :- [:map [:dashcard-id ms/PositiveInt]]]
|
||||
(let [action (-> (actions/select-action :id (parse-long action-id) :archived false)
|
||||
(t2/hydrate :creator)
|
||||
api/read-check)
|
||||
;; TODO flatten this into a single query
|
||||
card-id (api/check-404 (t2/select-one-fn :card_id [:model/DashboardCard :card_id] dashcard-id))
|
||||
table-id (api/check-404 (t2/select-one-fn :table_id [:model/Card :table_id] card-id))
|
||||
fields (t2/select [:model/Field :name] :table_id table-id)
|
||||
field-names (set (map :name fields))
|
||||
include? #(not (contains? field-names (:slug %)))]
|
||||
(update action :parameters #(some->> % (filterv include?)))))
|
||||
|
||||
(defn- input->parameters
|
||||
"We support either named or id based parameters for invoking legacy actions. This converts keys to ids, if necessary."
|
||||
[parameters input]
|
||||
(let [slug-or-id->id (merge (u/index-by :slug :id parameters)
|
||||
(u/index-by :id :id parameters))]
|
||||
(if (not-every? #(contains? slug-or-id->id (name %)) (keys input))
|
||||
(throw (ex-info
|
||||
"Unexpected parameter(s) provided"
|
||||
{:status-code 400
|
||||
:input input
|
||||
:parameters parameters}))
|
||||
(update-keys input (comp slug-or-id->id name)))))
|
||||
|
||||
(defn- execute-saved-action!
|
||||
"Implementation handling a sub-sub-case."
|
||||
[action input]
|
||||
(actions/execute-action! action (input->parameters (:parameters action) input)))
|
||||
|
||||
(defn- execute-dashcard-row-action-on-saved-action!
|
||||
"Implementation handling a sub-sub-case."
|
||||
[action-id dashcard-id pks inputs & [_mapping]]
|
||||
(let [action (-> (actions/select-action :id action-id :archived false)
|
||||
(t2/hydrate :creator)
|
||||
api/read-check)
|
||||
;; TODO flatten this into a single query
|
||||
card-id (api/check-404 (t2/select-one-fn :card_id [:model/DashboardCard :card_id] dashcard-id))
|
||||
table-id (api/check-404 (t2/select-one-fn :table_id [:model/Card :table_id] card-id))
|
||||
fields (t2/select [:model/Field :id :name :semantic_type] :table_id table-id)
|
||||
field-names (set (map :name fields))
|
||||
pk-fields (filter #(= :type/PK (:semantic_type %)) fields)
|
||||
_ (assert (= 1 (count pks)) "Further work needed to handle matching up database to input rows")
|
||||
params (first inputs)
|
||||
[row] (data-editing/query-db-rows table-id pk-fields pks)
|
||||
_ (api/check-404 row)
|
||||
row-params (->> (:parameters action)
|
||||
(keep (fn [{:keys [id slug]}]
|
||||
;; TODO handle custom mapping
|
||||
(when (contains? field-names (or slug id))
|
||||
[id (row (keyword (or slug id)))])))
|
||||
(into {}))
|
||||
provided (input->parameters (:parameters action) params)]
|
||||
[(actions/execute-action! action (merge row-params provided))]))
|
||||
|
||||
(defn- execute-dashcard-row-action-on-primitive-action!
|
||||
"Implementation handling a sub-sub-case."
|
||||
[action-kw scope dashcard-id pks inputs _mapping]
|
||||
;; TODO flatten this into a single query
|
||||
(let [card-id (api/check-404 (t2/select-one-fn :card_id [:model/DashboardCard :card_id] dashcard-id))
|
||||
table-id (api/check-404 (t2/select-one-fn :table_id [:model/Card :table_id] card-id))
|
||||
pk-fields (t2/select [:model/Field :id :name :semantic_type] :table_id table-id :semantic_type :type/PK)
|
||||
_ (assert (= 1 (count pks)) "Further work needed to handle matching up database to input rows")
|
||||
[row] (data-editing/query-db-rows table-id pk-fields pks)
|
||||
_ (api/check-404 row)
|
||||
;; TODO handle custom mapping
|
||||
inputs (for [input inputs]
|
||||
(if (:row input)
|
||||
(assoc input :row (merge row (:row input)))
|
||||
(merge row input)))]
|
||||
(:outputs (actions/perform-action! action-kw scope inputs))))
|
||||
|
||||
(api.macros/defendpoint :post "/row-action/:action-id/execute"
|
||||
"Executes an action as a row action. The allows action parameters sharing a name with column names to be derived from a specific row.
|
||||
The caller is still able to supply parameters, which will be preferred to those derived from the row.
|
||||
Discovers the table via the provided dashcard-id, assumes a model/editable for now."
|
||||
[{:keys [action-id]} :- [:map [:action-id :string]]
|
||||
{:keys [dashcard-id]} :- [:map [:dashcard-id ms/PositiveInt]]
|
||||
{:keys [pk params]} :- [:map
|
||||
[:pk :any]
|
||||
[:params :any]]]
|
||||
(first (execute-dashcard-row-action-on-saved-action! (parse-long action-id) dashcard-id [pk] [params])))
|
||||
|
||||
(api.macros/defendpoint :get "/tmp-action"
|
||||
"Returns all actions across all tables and models"
|
||||
[_
|
||||
_
|
||||
_]
|
||||
(api/check-superuser)
|
||||
(let [databases (t2/select [:model/Database :id :settings])
|
||||
editable-database? (comp boolean :database-enable-table-editing :settings)
|
||||
editable-databases (filter editable-database? databases)
|
||||
|
||||
editable-tables
|
||||
(when (seq editable-databases)
|
||||
(t2/select :model/Table
|
||||
:db_id [:in (map :id editable-databases)]
|
||||
:active true))
|
||||
|
||||
fields
|
||||
(when (seq editable-tables)
|
||||
(t2/select :model/Field :table_id [:in (map :id editable-tables)]))
|
||||
|
||||
fields-by-table
|
||||
(group-by :table_id fields)
|
||||
|
||||
table-actions
|
||||
(for [t editable-tables
|
||||
op [:table.row/create :table.row/update :table.row/delete]
|
||||
:let [fields (fields-by-table (:id t))
|
||||
action (actions/table-primitive-action t fields op)]]
|
||||
(assoc action :table_name (:name t)))
|
||||
|
||||
saved-actions
|
||||
(for [a (actions/select-actions nil :archived false)]
|
||||
(select-keys a [:name
|
||||
:model_id
|
||||
:type
|
||||
:database_id
|
||||
:id
|
||||
:visualization_settings
|
||||
:parameters]))]
|
||||
{:actions (vec (concat saved-actions table-actions))}))
|
||||
|
||||
(mr/def ::unified-action.base
|
||||
[:or
|
||||
[:map {:closed true}
|
||||
[:action-id ms/PositiveInt]]
|
||||
[:map {:closed true}
|
||||
[:action-kw :keyword]
|
||||
[:table-id {:optional true} ms/PositiveInt]]])
|
||||
|
||||
(mr/def ::unified-action
|
||||
[:or
|
||||
::unified-action.base
|
||||
[:map {:closed true}
|
||||
[:dashboard-action ms/PositiveInt]]
|
||||
[:map {:closed true}
|
||||
[:row-action ::unified-action.base]
|
||||
;; TODO type our mappings
|
||||
[:mapping :map]
|
||||
;; TODO generalize so we can support grids outside of dashboards
|
||||
[:dashcard-id ms/PositiveInt]]])
|
||||
|
||||
(mu/defn- fetch-unified-action :- ::unified-action
|
||||
"Resolve various types of action id into a semantic map which is easier to dispatch on."
|
||||
[scope :- ::types/scope.hydrated
|
||||
raw-id :- [:or :string ms/NegativeInt ms/PositiveInt]]
|
||||
(cond
|
||||
(pos-int? raw-id) {:action-id raw-id}
|
||||
(neg-int? raw-id) (let [[op param] (actions/unpack-encoded-action-id raw-id)]
|
||||
(cond
|
||||
(isa? op :table.row/common)
|
||||
{:action-kw op, :table-id param}
|
||||
:else
|
||||
(throw (ex-info "Execution not supported for given encoded action" {:status 400
|
||||
:action-id raw-id
|
||||
:op op
|
||||
:param param}))))
|
||||
(string? raw-id) (if-let [[_ dashcard-id _nested-id] (re-matches #"^dashcard:([^:]+):(.*)$" raw-id)]
|
||||
(let [dashcard-id (if (= "unknown" dashcard-id) (:dashcard-id scope) (parse-long dashcard-id))
|
||||
dashcard (api/check-404 (some->> dashcard-id (t2/select-one [:model/DashboardCard :visualization_settings])))
|
||||
actions (-> dashcard :visualization_settings :editableTable.enabledActions)
|
||||
;; TODO actual_id should get renamed to id at some point in the FE
|
||||
viz-action (api/check-404 (first (filter (comp #{raw-id} #(or (:actual_id %) (:id %))) actions)))
|
||||
;; TODO id should get renamed to action_id at some point as well
|
||||
inner-id (or (:action_id viz-action) (:id viz-action))
|
||||
unified (fetch-unified-action scope inner-id)
|
||||
action-type (:type viz-action "row-action")
|
||||
mapping (:parameterMappings viz-action {})]
|
||||
(assert (:enabled viz-action) "Cannot call disabled actions")
|
||||
(case action-type
|
||||
"row-action" {:row-action unified, :mapping mapping, :dashcard-id dashcard-id}))
|
||||
(if-let [[_ dashcard-id] (re-matches #"^dashcard:(\d+)$" raw-id)]
|
||||
;; Dashboard buttons can only be invoked from dashboards
|
||||
;; We're not checking that the scope has the correct dashboard, but if it's incorrect, there
|
||||
;; will be a 404 thrown when we try to execute the action.
|
||||
;; This 404 here is not the best error to return, but we can polish this later.
|
||||
(let [dashboard-id (api/check-404 (:dashboard-id scope))]
|
||||
(api/read-check :model/Dashboard dashboard-id)
|
||||
{:dashboard-action (parse-long dashcard-id)})
|
||||
;; Not a fancy encoded string, it must refer directly to a primitive.
|
||||
{:action-kw (keyword raw-id)}))
|
||||
:else
|
||||
(throw (ex-info "Unexpected id value" {:status 400, :action-id raw-id}))))
|
||||
|
||||
(defn- execute!* [action-id scope inputs]
|
||||
(let [scope (actions/hydrate-scope scope)
|
||||
unified (fetch-unified-action scope action-id)]
|
||||
(cond
|
||||
(:action-id unified)
|
||||
(let [action (api/read-check (actions/select-action :id (:action-id unified) :archived false))]
|
||||
(api/check-400 (= 1 (count inputs)) "Saved actions currently only support a single input")
|
||||
[(execute-saved-action! action (first inputs))])
|
||||
(:action-kw unified)
|
||||
(let [action-kw (keyword (:action-kw unified))]
|
||||
(:outputs
|
||||
;; Weird magic currying we've been doing implicitly.
|
||||
(if (and (isa? action-kw :table.row/common) (:table-id unified))
|
||||
(actions/perform-action! action-kw scope (for [i inputs] {:table-id (:table-id unified), :row i}))
|
||||
(actions/perform-action! action-kw scope inputs))))
|
||||
(:dashboard-action unified)
|
||||
(do
|
||||
(api/check-400 (= 1 (count inputs)) "Saved actions currently only support a single input")
|
||||
[(actions/execute-dashcard! (:dashboard-id scope)
|
||||
(:dashboard-action unified)
|
||||
(walk/stringify-keys (first inputs)))])
|
||||
(:row-action unified)
|
||||
;; use flat namespace for now, probably want to separate form inputs from pks
|
||||
(let [row-action (:row-action unified)
|
||||
mapping (:mapping unified)
|
||||
;; will need to generalize this once we can use actions on fullscreen tables / editables / questions.
|
||||
dashcard-id (:dashcard-id unified)
|
||||
saved-id (:action-id row-action)
|
||||
action-kw (:action-kw row-action)
|
||||
;; TODO probably take the row separately from inputs
|
||||
pks inputs
|
||||
inputs (for [input inputs]
|
||||
(if (seq mapping)
|
||||
(walk/postwalk-replace {"::root" input} mapping)
|
||||
input))]
|
||||
(cond
|
||||
saved-id
|
||||
(execute-dashcard-row-action-on-saved-action! saved-id dashcard-id pks inputs mapping)
|
||||
action-kw
|
||||
(let [table-id (:table-id row-action)]
|
||||
;; Weird magic currying we've been doing implicitly.
|
||||
(if (and table-id (isa? action-kw :table.row/common))
|
||||
(let [inputs (for [input inputs] {:table-id table-id, :row input})]
|
||||
(execute-dashcard-row-action-on-primitive-action! action-kw scope dashcard-id pks inputs mapping))
|
||||
(execute-dashcard-row-action-on-primitive-action! action-kw scope dashcard-id pks inputs mapping)))))
|
||||
:else
|
||||
(throw (ex-info "Not able to execute given action yet" {:status-code 500, :scope scope, :unified unified})))))
|
||||
|
||||
(api.macros/defendpoint :post "/action/v2/execute"
|
||||
"The One True API for invoking actions.
|
||||
It doesn't care whether the action is saved or primitive, and whether it has been placed.
|
||||
In particular, it supports:
|
||||
- Custom model actions as well as primitive actions, and encoded hack actions which use negative ids.
|
||||
- Stand-alone actions, Dashboard actions, Row actions, and whatever else comes along.
|
||||
Since actions are free to return multiple outputs even for a single output, the response is always plural."
|
||||
[{}
|
||||
{}
|
||||
{:keys [action_id scope input]}
|
||||
:- [:map
|
||||
[:action_id [:or :string ms/NegativeInt ms/PositiveInt]]
|
||||
[:scope ::types/scope.raw]
|
||||
[:input :map]]]
|
||||
{:outputs (execute!* action_id scope [input])})
|
||||
|
||||
(api.macros/defendpoint :post "/action/v2/execute-bulk"
|
||||
"The *other* One True API for invoking actions. The only difference is that it accepts multiple inputs."
|
||||
[{}
|
||||
{}
|
||||
{:keys [action_id scope inputs]}
|
||||
:- [:map
|
||||
[:action_id [:or :string ms/NegativeInt ms/PositiveInt]]
|
||||
[:scope ::types/scope.raw]
|
||||
[:inputs [:sequential :map]]]]
|
||||
{:outputs (execute!* action_id scope inputs)})
|
||||
|
||||
(api.macros/defendpoint :post "/tmp-modal"
|
||||
"Temporary endpoint for describing an actions parameters
|
||||
such that they can be presented correctly in a modal ahead of execution."
|
||||
[{}
|
||||
{}
|
||||
{:keys [action_id scope #_input]}]
|
||||
(let [scope (actions/hydrate-scope scope)
|
||||
unified (fetch-unified-action scope action_id)
|
||||
|
||||
;; todo mapping support
|
||||
describe-saved-action
|
||||
(fn [& {:keys [action-id _row-action-dashcard-id _mapping]}]
|
||||
(let [action (-> (actions/select-action :id action-id
|
||||
:archived false
|
||||
{:where [:not [:= nil :model_id]]})
|
||||
|
||||
api/read-check
|
||||
api/check-404)
|
||||
param-id->viz-field (-> action :visualization_settings (:fields {}))]
|
||||
{:title (:name action)
|
||||
:parameters
|
||||
(->> (for [param (:parameters action)
|
||||
;; query type actions store most stuff in viz settings rather than the
|
||||
;; parameter
|
||||
:let [viz-field (param-id->viz-field (:id param))]
|
||||
:when (not (:hidden viz-field))]
|
||||
(u/remove-nils
|
||||
;; todo dropdown options
|
||||
{:id (:id param)
|
||||
:display_name (or (:display-name param) (:name param))
|
||||
:type (case (:type param)
|
||||
:string/= :type/Text
|
||||
:number/= :type/Number
|
||||
:date/single :type/Date
|
||||
(if (= "type" (namespace (:type param)))
|
||||
(:type param)
|
||||
(throw
|
||||
(ex-info "Unsupported query action parameter type"
|
||||
{:status-code 500
|
||||
:param-type (:type param)
|
||||
:scope scope
|
||||
:unified unified}))))
|
||||
:optional (and (not (:required param)) (not (:required viz-field)))}))
|
||||
vec)}))
|
||||
|
||||
;; todo mapping support
|
||||
describe-table-action
|
||||
(fn [& {:keys [action-kw table-id _mapping]}]
|
||||
(let [table (api/read-check (t2/select-one :model/Table :id table-id :active true))]
|
||||
{:title (format "%s: %s" (:display_name table) (u/capitalize-en (name action-kw)))
|
||||
:parameters
|
||||
(->> (for [field (->> (t2/select :model/Field :table_id table-id)
|
||||
(sort-by :position))
|
||||
:let [pk (= :type/PK (:semantic_type field))]
|
||||
:when (case action-kw
|
||||
;; create does not take pk cols if auto increment, todo generated cols?
|
||||
:table.row/create (not (:database_is_auto_increment field))
|
||||
;; delete only requires pk cols
|
||||
:table.row/delete pk
|
||||
;; update takes both the pk and field (if not a row action)
|
||||
:table.row/update true)
|
||||
:let [required (or pk (:database_required field))]]
|
||||
(u/remove-nils
|
||||
{:id (:name field)
|
||||
:display_name (:display_name field)
|
||||
:type (:base_type field)
|
||||
:optional (not required)
|
||||
:nullable (:database_is_nullable field)}))
|
||||
|
||||
vec)}))]
|
||||
|
||||
(cond
|
||||
;; saved action
|
||||
(:action-id unified)
|
||||
(describe-saved-action :action-id (:action-id unified))
|
||||
|
||||
;; table action
|
||||
(:action-kw unified)
|
||||
(let [action-kw (keyword (:action-kw unified))
|
||||
table-id (:table-id unified)]
|
||||
(describe-table-action :action-kw action-kw
|
||||
:table-id table-id))
|
||||
|
||||
(:row-action unified)
|
||||
(let [row-action (:row-action unified)
|
||||
mapping (:mapping unified)
|
||||
dashcard-id (:dashcard-id unified)
|
||||
saved-id (:action-id row-action)
|
||||
action-kw (:action-kw row-action)]
|
||||
(cond
|
||||
saved-id
|
||||
(describe-saved-action :action-id saved-id
|
||||
:row-action-dashcard-id dashcard-id
|
||||
:row-action-mapping mapping)
|
||||
|
||||
action-kw
|
||||
(let [table-id (:table-id row-action)]
|
||||
(describe-table-action :action-kw action-kw
|
||||
:table-id table-id
|
||||
:row-action-mapping mapping))
|
||||
|
||||
:else (ex-info "Not a supported row action" {:status-code 500, :scope scope, :unified unified})))
|
||||
:else
|
||||
(throw (ex-info "Not able to execute given action yet" {:status-code 500, :scope scope, :unified unified})))))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/data-editing routes."
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
(ns metabase-enterprise.data-editing.coerce
|
||||
(:require
|
||||
[metabase.util.date-2 :as u.date])
|
||||
(:import
|
||||
(clojure.lang BigInt)
|
||||
(java.time LocalDateTime ZonedDateTime ZoneId)
|
||||
(java.time.format DateTimeFormatter)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- json-zdt->unix-millis ^long [s]
|
||||
(let [zdt->milli (fn [^ZonedDateTime zdt]
|
||||
(-> zdt .toInstant .toEpochMilli))]
|
||||
(-> s u.date/parse zdt->milli)))
|
||||
|
||||
(defn- json-zdt->unix-seconds [s]
|
||||
(-> s json-zdt->unix-millis (quot 1000)))
|
||||
|
||||
(defn- json-zdt->unix-micros [s]
|
||||
(-> s json-zdt->unix-millis ^BigInt (* 1000N) .toBigInteger))
|
||||
|
||||
(defn- json-zdt->unix-nanos [s]
|
||||
(-> s json-zdt->unix-millis ^BigInt (* 1000000N) .toBigInteger))
|
||||
|
||||
(let [^DateTimeFormatter formatter (DateTimeFormatter/ofPattern "yyyyMMddHHmmss")
|
||||
format (fn [t]
|
||||
(.format ^DateTimeFormatter formatter t))]
|
||||
(defn- json-zdt->yyyymmddhhmmss [s]
|
||||
(-> s u.date/parse format))
|
||||
|
||||
(defn- yyyymmddhhmmss->json-zdt
|
||||
[yyyymmddhhmmss]
|
||||
(-> (LocalDateTime/parse yyyymmddhhmmss formatter)
|
||||
(.atZone (ZoneId/of "UTC"))
|
||||
str)))
|
||||
|
||||
;; the date/time coercions are highly ambigious
|
||||
;; in one direction one can make a decision (take the local date, or clock time)
|
||||
;; but how do you reverse it?
|
||||
;; Often the original SQL string might have been a full date, a zoned or unzoned ISO string,
|
||||
;; Needs to be considered properly at some later time.
|
||||
;; Idea: make the coercion dependent on previous database string, so if lossy - modify *just* the time, or the date component
|
||||
;; * still somewhat ambiguous, e.g did the user *intend* to discard the previous date/time components?
|
||||
|
||||
(defn- json-zdt->date [s]
|
||||
(-> s ZonedDateTime/parse .toLocalDate str))
|
||||
|
||||
(defn- json-zdt->time [s]
|
||||
(-> s ZonedDateTime/parse .toLocalTime str))
|
||||
|
||||
(defn- millis->json-zdt
|
||||
[millis]
|
||||
(-> millis
|
||||
java.time.Instant/ofEpochMilli
|
||||
(java.time.ZonedDateTime/ofInstant (java.time.ZoneId/systemDefault))
|
||||
str))
|
||||
|
||||
(defn- unix-seconds->json-zdt
|
||||
[unix-seconds]
|
||||
(millis->json-zdt (* unix-seconds 1000)))
|
||||
|
||||
(defn- unix-millis->json-zdt
|
||||
[unix-millis]
|
||||
(millis->json-zdt unix-millis))
|
||||
|
||||
(defn- unix-micros->json-zdt
|
||||
[unix-micros]
|
||||
(millis->json-zdt (quot unix-micros 1000)))
|
||||
|
||||
(defn- unix-nanos->json-zdt
|
||||
[unix-nanos]
|
||||
(millis->json-zdt (quot unix-nanos 1000000)))
|
||||
|
||||
(defn- date->json-zdt
|
||||
[date]
|
||||
(-> date
|
||||
java.time.LocalDate/parse
|
||||
(.atStartOfDay (java.time.ZoneId/systemDefault))
|
||||
str))
|
||||
|
||||
(defn- time->json-zdt
|
||||
[time]
|
||||
(-> time
|
||||
java.time.LocalTime/parse
|
||||
(.atDate (java.time.LocalDate/now))
|
||||
(.atZone (java.time.ZoneId/systemDefault))
|
||||
str))
|
||||
|
||||
(def coercion-fns
|
||||
"Maps a coercion strategy to a map containing both input and output conversion functions.
|
||||
|
||||
:in - Function to convert from input JSON to database format
|
||||
:out - Function to convert from database format to JSON output
|
||||
|
||||
Assumes the input/output values are valid for the coercion strategy and can fit within bounds."
|
||||
;; TODO: missing (second (clojure.data/diff (into #{} (keys coercion-fns)) (descendants :Coercion/*)))
|
||||
;;{:Coercion/Bytes->Temporal
|
||||
;; :Coercion/UNIXTime->Temporal
|
||||
;; :Coercion/String->Float
|
||||
;; :Coercion/Number->Temporal
|
||||
;; :Coercion/String->Number
|
||||
;; :Coercion/YYYYMMDDHHMMSSBytes->Temporal
|
||||
;; :Coercion/String->Temporal
|
||||
;; :Coercion/ISO8601->Temporal}
|
||||
{:Coercion/UNIXSeconds->DateTime {:in #'json-zdt->unix-seconds
|
||||
:out #'unix-seconds->json-zdt}
|
||||
:Coercion/UNIXMilliSeconds->DateTime {:in #'json-zdt->unix-millis
|
||||
:out #'unix-millis->json-zdt}
|
||||
:Coercion/UNIXMicroSeconds->DateTime {:in #'json-zdt->unix-micros
|
||||
:out #'unix-micros->json-zdt}
|
||||
:Coercion/UNIXNanoSeconds->DateTime {:in #'json-zdt->unix-nanos
|
||||
:out #'unix-nanos->json-zdt}
|
||||
:Coercion/YYYYMMDDHHMMSSString->Temporal {:in #'json-zdt->yyyymmddhhmmss
|
||||
:out #'yyyymmddhhmmss->json-zdt}
|
||||
:Coercion/ISO8601->DateTime {:in identity
|
||||
:out identity}
|
||||
:Coercion/ISO8601->Date {:in #'json-zdt->date
|
||||
:out #'date->json-zdt}
|
||||
:Coercion/ISO8601->Time {:in #'json-zdt->time
|
||||
:out #'time->json-zdt}})
|
||||
@@ -0,0 +1,6 @@
|
||||
(ns metabase-enterprise.data-editing.core
|
||||
(:require
|
||||
[potemkin :as p]))
|
||||
|
||||
(comment
|
||||
p/keep-me)
|
||||
@@ -0,0 +1,203 @@
|
||||
(ns metabase-enterprise.data-editing.data-editing
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[java-time.api :as t]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.data-editing.coerce :as data-editing.coerce]
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.query-processor :as qp]
|
||||
[metabase.query-processor.store :as qp.store]
|
||||
[metabase.util :as u]
|
||||
[metabase.warehouse-schema.models.field-values :as field-values]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn select-table-pk-fields
|
||||
"Given a table-id, return the :model/Field instances corresponding to its PK columns. Do not assume any ordering."
|
||||
[table-id]
|
||||
(u/prog1 (api/check-404 (t2/select :model/Field :table_id table-id :semantic_type :type/PK :active true))
|
||||
(api/check-500 (pos? (count <>)))))
|
||||
|
||||
(defn get-row-pks
|
||||
"Given a row, strip it down to just its primary keys."
|
||||
[pk-fields row]
|
||||
(->> (map (comp keyword :name) pk-fields)
|
||||
;; Hideous workaround for QP and direct JDBC disagreeing on case
|
||||
(select-keys (merge (update-keys row (comp keyword u/upper-case-en name))
|
||||
(u/lower-case-map-keys row)
|
||||
row))
|
||||
;; Hack for now, pending discussion of the ideal fix
|
||||
;; https://linear.app/metabase/issue/WRK-281/undo-deletes-a-record-instead-of-reverting-the-edits
|
||||
;; See https://metaboat.slack.com/archives/C0641E4PB9B/p1744978660610899
|
||||
(m/map-vals #(if (uuid? %) (str %) %))))
|
||||
|
||||
(defn- valid-pks [pks]
|
||||
(every? some? (vals pks)))
|
||||
|
||||
(defn- apply*
|
||||
"Work around the fact that the lib logical operators don't have a 0 or 1 arity."
|
||||
[f clauses]
|
||||
(if (<= (count clauses) 1)
|
||||
(first clauses)
|
||||
(apply f clauses)))
|
||||
|
||||
(defn- qp-result->row-map
|
||||
[{:keys [rows cols]}]
|
||||
;; rows from the request are keywordized
|
||||
(let [col-names (map (comp keyword :name) cols)]
|
||||
(map #(zipmap col-names %) rows)))
|
||||
|
||||
(defn query-db-rows
|
||||
"Return the current representation of the given rows that we would return to the frontend, indexed by their pks."
|
||||
[table-id pk-fields rows]
|
||||
(assert (seq pk-fields) "Table must have at least on primary key column")
|
||||
;; TODO pass in the db-id from above rather
|
||||
(let [{:keys [db_id]} (api/check-404 (t2/select-one :model/Table table-id))
|
||||
row-pks (seq (map (partial get-row-pks pk-fields) rows))]
|
||||
(assert (every? valid-pks row-pks) "All rows must have valid primary keys")
|
||||
(when row-pks
|
||||
(qp.store/with-metadata-provider db_id
|
||||
(let [mp (qp.store/metadata-provider)
|
||||
query (lib/query mp (lib.metadata/table mp table-id))
|
||||
query (lib/filter
|
||||
query
|
||||
;; We can optimize the most common case considerably.
|
||||
(if (= 1 (count pk-fields))
|
||||
(apply lib/in
|
||||
(lib.metadata/field mp (:id (first pk-fields)))
|
||||
(map (comp first vals) row-pks))
|
||||
;; Optimizing this could be done in many cases, but it would be complex.
|
||||
(apply* lib/or
|
||||
(for [row-pk row-pks]
|
||||
(apply* lib/and
|
||||
(for [field pk-fields]
|
||||
(lib/= (lib.metadata/field mp (:id field))
|
||||
(get row-pk (keyword (:name field))))))))))]
|
||||
(->> query
|
||||
qp/userland-query-with-default-constraints
|
||||
qp/process-query
|
||||
:data
|
||||
qp-result->row-map))))))
|
||||
|
||||
(defn apply-coercions
|
||||
"For fields that have a coercion_strategy, apply the coercion function (defined in data-editing.coerce) to the corresponding value in each row.
|
||||
Intentionally does not coerce primary key values (behaviour for pks with coercion strategies is undefined)."
|
||||
[table-id input-rows]
|
||||
(let [input-keys (into #{} (mapcat keys) input-rows)
|
||||
field-names (map name input-keys)
|
||||
fields (t2/select :model/Field :table_id table-id :name [:in field-names])
|
||||
coerce-fn (->> (for [{field-name :name, :keys [coercion_strategy, semantic_type]} fields
|
||||
:when (not (isa? semantic_type :type/PK))]
|
||||
[(keyword field-name)
|
||||
(or (when (nil? coercion_strategy) identity)
|
||||
(:in (data-editing.coerce/coercion-fns coercion_strategy))
|
||||
(throw (ex-info "Coercion strategy has no defined coercion function"
|
||||
{:status 400
|
||||
:field field-name
|
||||
:coercion_strategy coercion_strategy})))])
|
||||
(into {}))
|
||||
coerce (fn [k v] (some-> v ((coerce-fn (keyword k) identity))))]
|
||||
(for [row input-rows]
|
||||
(m/map-kv-vals coerce row))))
|
||||
|
||||
(defn- row-update-event
|
||||
"Given a :effect/row.modified diff, figure out what kind of mutation it was."
|
||||
[{:keys [before after]}]
|
||||
(case [(some? before) (some? after)]
|
||||
[false true] :event/row.created
|
||||
[true true] :event/row.updated
|
||||
[true false] :event/row.deleted
|
||||
;; should not happen
|
||||
[false false] ::no-op))
|
||||
|
||||
(defmethod actions/handle-effects!* :effect/row.modified
|
||||
[_ {:keys [user-id invocation-stack scope]} diffs]
|
||||
(let [table-ids (distinct (map :table-id diffs))
|
||||
table->pk-fields (u/group-by identity select-table-pk-fields concat table-ids)
|
||||
table->keymap (u/for-map [table-id table-ids
|
||||
:let [fields (t2/select-fn-vec :name [:model/Field :name] :table_id table-id)]]
|
||||
[table-id (merge (u/for-map [f fields]
|
||||
[(keyword (u/lower-case-en f)) (keyword f)])
|
||||
(u/for-map [f fields]
|
||||
[(keyword (u/upper-case-en f)) (keyword f)])
|
||||
(let [kws (map keyword fields)]
|
||||
(zipmap kws kws)))])
|
||||
diff->pk-diff (u/for-map [{:keys [table-id before after] :as diff} diffs
|
||||
:when (or before after)
|
||||
:let [keymap (table->keymap table-id)]]
|
||||
[diff {:pk (get-row-pks (table->pk-fields table-id) (or after before))
|
||||
:before (when before (update-keys before keymap))
|
||||
:after (when after (update-keys after keymap))}])]
|
||||
;; undo snapshots, but only if we're not executing an undo
|
||||
;; TODO fix tests that execute actions without a user scope
|
||||
(when user-id
|
||||
(when-not (some (comp #{:data-editing/undo :data-editing/redo} first) invocation-stack)
|
||||
((requiring-resolve 'metabase-enterprise.data-editing.undo/track-change!)
|
||||
user-id
|
||||
scope
|
||||
(u/for-map [[table-id diffs] (group-by :table-id diffs)]
|
||||
[table-id (u/for-map [{:keys [before after] :as diff} diffs
|
||||
:when (or before after)
|
||||
:let [{:keys [pk before after]} (diff->pk-diff diff)]]
|
||||
[pk [before after]])]))))
|
||||
;; table notification system events
|
||||
(doseq [[event payloads] (->> diffs
|
||||
(group-by row-update-event)
|
||||
(remove (comp #{::no-op} key)))]
|
||||
(doseq [[table-id payloads] (group-by :table-id payloads)
|
||||
:let [db-id (:db-id (first payloads))
|
||||
row-changes (for [diff payloads]
|
||||
(diff->pk-diff diff))]]
|
||||
(doseq [row-change row-changes]
|
||||
(events/publish-event! event {:actor_id user-id
|
||||
:row_change row-change
|
||||
:scope (u/snake-keys (actions/hydrate-scope scope))
|
||||
:args {:table_id table-id
|
||||
:db_id db-id
|
||||
:timestamp (t/zoned-date-time (t/zone-id "UTC"))}}))))))
|
||||
|
||||
(defn- invalidate-field-values! [table-id rows]
|
||||
;; Be conservative with respect to case sensitivity, invalidate every field when there is ambiguity.
|
||||
(let [ln->values (u/group-by first second (for [row rows [k v] row] [(u/lower-case-en (name k)) v]))
|
||||
lower-names (keys ln->values)
|
||||
ln->ids (when (seq lower-names)
|
||||
(u/group-by
|
||||
:lower_name :id
|
||||
(t2/query {:select [:id [[:lower :name] :lower_name]]
|
||||
:from [(t2/table-name :model/Field)]
|
||||
:where [:and
|
||||
[:= :table_id table-id]
|
||||
[:in [:lower :name] lower-names]
|
||||
[:in :has_field_values ["list" "auto-list"]]
|
||||
[:= :semantic_type "type/Category"]]})))
|
||||
stale-fields (->> (for [[lower-name field-ids] ln->ids
|
||||
:let [new-values (into #{} (filter some?) (ln->values lower-name))
|
||||
old-values (into #{} cat (t2/select-fn-vec :values :model/FieldValues
|
||||
:field_id [:in field-ids]))]]
|
||||
(when (seq (set/difference new-values old-values))
|
||||
field-ids))
|
||||
(apply concat))]
|
||||
;; Note that for now we only rescan field values when values are *added* and not when they are *removed*.
|
||||
(when (seq stale-fields)
|
||||
;; Using a future is not ideal, it would be better to use a queue and a single worker, to avoid tying up threads.
|
||||
(future
|
||||
(->> (t2/select :model/Field :id [:in stale-fields])
|
||||
(run! field-values/create-or-update-full-field-values!))))))
|
||||
|
||||
(defn invalidate-and-present!
|
||||
"We invalidate the field-values, in case any new category values were added.
|
||||
The FE also expects data to be formatted according to PQ logic, e.g. considering semantic types.
|
||||
Actions, however, return raw values, since lossy coercions would limit composition.
|
||||
So, we apply the coercions here."
|
||||
[table-id rows]
|
||||
;; We could optimize this significantly:
|
||||
;; 1. Skip if no category fields were changes on update.
|
||||
;; 2. Check whether all corresponding categorical field values are already in the database.
|
||||
(invalidate-field-values! table-id rows)
|
||||
;; right now the FE works off qp outputs, which coerce output row data
|
||||
;; still feels messy, revisit this
|
||||
(let [pk-fields (select-table-pk-fields table-id)]
|
||||
(query-db-rows table-id pk-fields rows)))
|
||||
@@ -0,0 +1,17 @@
|
||||
(ns metabase-enterprise.data-editing.events
|
||||
(:require
|
||||
[metabase.notification.core :as notification]))
|
||||
|
||||
(derive ::event :metabase/event)
|
||||
|
||||
(derive :event/rows.created ::event)
|
||||
(derive :event/rows.updated ::event)
|
||||
(derive :event/rows.deleted ::event)
|
||||
(derive :event/row.created ::event)
|
||||
(derive :event/row.updated ::event)
|
||||
(derive :event/row.deleted ::event)
|
||||
|
||||
(doseq [event (descendants ::event)]
|
||||
(defmethod notification/notification-filter-for-topic event
|
||||
[_topic event-info]
|
||||
[:= :table_id (-> event-info :args :table_id)]))
|
||||
@@ -0,0 +1,81 @@
|
||||
(ns metabase-enterprise.data-editing.foreign-keys
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[metabase.util :as u])
|
||||
(:import (clojure.lang PersistentQueue)))
|
||||
|
||||
#_(defn lookup-children-in-db [{:keys [table fk pk]} parents]
|
||||
(jdbc/query
|
||||
'db
|
||||
{:select pk
|
||||
:from [table]
|
||||
:where (if (= 1 (count fk))
|
||||
[:in (key (first fk)) (map (val (first fk)) parents)]
|
||||
(into [:or] (for [p parents]
|
||||
(into [:and] (for [[fk-col pk-col] fk]
|
||||
[:= fk-col (get p pk-col)])))))
|
||||
:limit 501}))
|
||||
|
||||
(defn- pop-queue [{:keys [queue] :as state}]
|
||||
(if-let [nxt (peek queue)]
|
||||
[nxt (update state :queue pop)]
|
||||
[nil state]))
|
||||
|
||||
(defn- queue-items [state item-type items]
|
||||
(if-not (seq items)
|
||||
state
|
||||
(let [new-items (remove (set (get-in state [:results item-type])) items)]
|
||||
(-> state
|
||||
(update :queue conj [item-type new-items])
|
||||
(update-in [:results item-type] (fnil into #{}) new-items)))))
|
||||
|
||||
(defn- step [metadata children-fn state]
|
||||
(let [[[parent-type items] state'] (pop-queue state)]
|
||||
(if-not parent-type
|
||||
state'
|
||||
(let [type-metadata (get metadata parent-type)
|
||||
child-keys (for [relationship type-metadata]
|
||||
(children-fn relationship items))]
|
||||
(reduce (fn [state [item-type items]]
|
||||
(queue-items state item-type items))
|
||||
state'
|
||||
child-keys)))))
|
||||
|
||||
(defn- state->results [{:keys [results queue]}]
|
||||
;; This is not precise; we should check whether there is at least one child for what's in the queue.
|
||||
{:complete? (empty? queue)
|
||||
:items results})
|
||||
|
||||
(defn- walk* [item-type items metadata children-fn {:keys [max-queries]
|
||||
:or {max-queries 100}}]
|
||||
(reduce
|
||||
(fn [state _]
|
||||
(step metadata children-fn state))
|
||||
{:queue (conj PersistentQueue/EMPTY [item-type items])
|
||||
:results {item-type (set items)}}
|
||||
(range max-queries)))
|
||||
|
||||
(defn walk
|
||||
"Given some starting items, return their descendants."
|
||||
[item-type items metadata children-fn & {:as opts}]
|
||||
(-> (walk* item-type items metadata children-fn opts)
|
||||
(update :results (fn [results]
|
||||
(u/remove-nils
|
||||
(update results item-type (comp not-empty set/difference) items))))
|
||||
(state->results)))
|
||||
|
||||
(defn count-descendants
|
||||
"Given some starting items, count the number of descendants they have, according to their types."
|
||||
[item-type items metadata children-fn & {:as opts}]
|
||||
(let [{:keys [complete? items]} (walk item-type items metadata children-fn opts)]
|
||||
{:complete? complete?
|
||||
:counts (update-vals items count)}))
|
||||
|
||||
(defn delete-recursively
|
||||
"Delete the given items, along with all their descendants."
|
||||
[item-type items metadata children-fn delete-fn & {:as opts}]
|
||||
(let [{:keys [queue results]} (walk* item-type items metadata children-fn opts)]
|
||||
(if (seq queue)
|
||||
(throw (ex-info "Cannot delete all descendants, as we could not enumerate them" {:queue queue}))
|
||||
(do (delete-fn results)
|
||||
(update-vals results count)))))
|
||||
@@ -0,0 +1,4 @@
|
||||
(ns metabase-enterprise.data-editing.init
|
||||
(:require
|
||||
[metabase-enterprise.data-editing.actions]
|
||||
[metabase-enterprise.data-editing.events]))
|
||||
242
enterprise/backend/src/metabase_enterprise/data_editing/undo.clj
Normal file
242
enterprise/backend/src/metabase_enterprise/data_editing/undo.clj
Normal file
@@ -0,0 +1,242 @@
|
||||
(ns metabase-enterprise.data-editing.undo
|
||||
(:require
|
||||
[clojure.walk :as walk]
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.util :as u]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def ^:private retention-total-batches 100)
|
||||
(def ^:private retention-total-rows 1000)
|
||||
(def ^:private retention-batches-per-scope 100)
|
||||
(def ^:private retention-batches-per-user 50)
|
||||
|
||||
(methodical/defmethod t2/table-name :model/Undo [_model] :data_edit_undo_chain)
|
||||
|
||||
(t2/deftransforms :model/Undo
|
||||
{:row_pk mi/transform-json
|
||||
:raw_before mi/transform-json
|
||||
:raw_after mi/transform-json})
|
||||
|
||||
(doto :model/Undo
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
|
||||
(defn- nested-sort
|
||||
"Sort every map within the data structure."
|
||||
[x]
|
||||
(walk/postwalk #(if-not (map? %) % (apply sorted-map (apply concat %))) x))
|
||||
|
||||
(defn- serialize-scope
|
||||
"Convert the scope map or string into a stable string we can do example matches on in the database. Idempotent."
|
||||
[scope]
|
||||
(if (string? scope)
|
||||
scope
|
||||
;; For now :type is ignored for undo scope, but once the FE starts passing it in, we'll probably change that.
|
||||
(or (some-> scope actions/normalize-scope (dissoc :type) nested-sort pr-str) "unknown")))
|
||||
|
||||
(defn- next-batch [undo? user-id scope]
|
||||
;; For now, we assume all the changes are to the same table.
|
||||
;; In the future, we might want to skip multi-table changes when using cmd-Z.
|
||||
;; We may also want to filter based on the type of interaction that caused the change (e.g., grid, workflow, etc)
|
||||
(t2/select :model/Undo
|
||||
:batch_num [:in
|
||||
{:select [[[(if undo? :max :min) :batch_num]]]
|
||||
:from [(t2/table-name :model/Undo)]
|
||||
:where [:and
|
||||
[:= :user_id user-id]
|
||||
[:= :scope (serialize-scope scope)]
|
||||
(if undo?
|
||||
[:not :undone]
|
||||
:undone)]}]))
|
||||
|
||||
(defn- batch-to-prune-from
|
||||
"Return the largest batch_num that we should no longer retain, if we only want to keep a certain number of batches
|
||||
matching the given where clause. Returns 0 if we do not need to prune anything."
|
||||
[batches-to-keep & [where]]
|
||||
(-> {:select [:batch_num]
|
||||
:from [(t2/table-name :model/Undo)]
|
||||
:where (or where true)
|
||||
:group-by :batch_num
|
||||
:order-by [[:batch_num :desc]]
|
||||
:limit 1
|
||||
:offset batches-to-keep}
|
||||
t2/query
|
||||
first
|
||||
:batch_num
|
||||
(or 0)))
|
||||
|
||||
(defn- batch-to-prune-from-for-rows
|
||||
"This is like [[batch-to-prune-from], except that we're enforcing a max on the row count."
|
||||
[rows-to-keep & [where]]
|
||||
(-> {:select [:batch_num]
|
||||
:from [(t2/table-name :model/Undo)]
|
||||
:where (or where true)
|
||||
:order-by [[:id :desc]]
|
||||
:limit 1
|
||||
:offset rows-to-keep}
|
||||
t2/query
|
||||
first
|
||||
:batch_num
|
||||
(or 0)))
|
||||
|
||||
(defn- prune-from-batch! [batch-num & [where]]
|
||||
(t2/delete! :model/Undo
|
||||
:batch_num [:<= batch-num]
|
||||
{:where (or where true)}))
|
||||
|
||||
(defn- prune-batches! [batches-to-keep & [where]]
|
||||
(prune-from-batch! (batch-to-prune-from batches-to-keep where) where))
|
||||
|
||||
(defn track-change!
|
||||
"Insert some snapshot data based on edits made to the given table."
|
||||
[user-id scope table-id->row-pk->old-new-values]
|
||||
(let [scope (serialize-scope scope)]
|
||||
(t2/with-transaction [_conn]
|
||||
(let [seq-name "undo_batch_num"
|
||||
next-batch-num (or (t2/select-one-fn :next_val [:sequences :next_val] :name seq-name {:for :update}) 1)]
|
||||
(t2/update! :sequences {:name seq-name} {:next_val (inc next-batch-num)})
|
||||
(t2/insert!
|
||||
:model/Undo
|
||||
(for [[table-id table-updates] table-id->row-pk->old-new-values
|
||||
[row-pk [old new]] table-updates]
|
||||
{:batch_num next-batch-num
|
||||
:table_id table-id
|
||||
:user_id user-id
|
||||
:row_pk row-pk
|
||||
:scope scope
|
||||
:raw_before old
|
||||
:raw_after new}))))
|
||||
|
||||
;; Delete snapshots that have been undone, as we keep a linear history and will no longer be able to "redo" them.
|
||||
(when-let [{:keys [batch_num]} (first (next-batch false user-id scope))]
|
||||
(t2/delete! :model/Undo
|
||||
:batch_num [:>= batch_num]
|
||||
:scope scope
|
||||
:undone true))
|
||||
|
||||
;; Pruning. Fairly naive implementation. Doesn't assume we were fully pruned before this update.
|
||||
|
||||
(prune-from-batch! (max (batch-to-prune-from-for-rows retention-total-rows)
|
||||
(batch-to-prune-from retention-total-batches)))
|
||||
|
||||
(let [where-scope [:= :scope scope]]
|
||||
(prune-batches! retention-batches-per-scope where-scope))
|
||||
|
||||
(let [where-user [:= :user_id user-id]]
|
||||
(prune-batches! retention-batches-per-user where-user))))
|
||||
|
||||
(defn next-batch-num
|
||||
"Return the batch number of the new change that we would (un-)revert.
|
||||
NOTE: this does not check whether there is a conflict preventing us from actually performing it."
|
||||
[undo-or-redo user-id scope]
|
||||
(:batch_num (first (next-batch (= :undo undo-or-redo) user-id scope))))
|
||||
|
||||
;; This will be used to fix conflict false positives
|
||||
#_{:clj-kondo/ignore [:unused-private-var]}
|
||||
(defn- diff-keys
|
||||
"Give the sorted list of keys on which m1 and m2 differ."
|
||||
[m1 m2]
|
||||
(->> (into (apply sorted-set (keys m1)) (keys m2))
|
||||
(filter #(not= (get m1 %) (get m2 %)))))
|
||||
|
||||
(defn- conflict?
|
||||
"Has another change superseded this batch, making it non-undoable? Typically, will mean someone else has edited it."
|
||||
[undo? batch]
|
||||
(let [row-pks (into #{} (map :row_pk) batch)
|
||||
table-ids (into #{} (map :table_id) batch)
|
||||
{:keys [batch_num]} (first batch)]
|
||||
;; TODO fix false positives in multi-table case where row-pks are ambiguous
|
||||
;; PRODUCT: We probably also want to fix false positive conflicts when different columns were changed
|
||||
;; Idea: Select all potential conflicting undos, check for any whose changed keys overlap with ours.
|
||||
;; Can store a precalculated [[diff-keys]] on each :model/Undo row, or even normalize into its own table and do
|
||||
;; conflict detection completely in SQL.
|
||||
;; This normal form may make maintenance of the history a bit too expensive however.
|
||||
(t2/exists? :model/Undo
|
||||
:table_id [:in table-ids]
|
||||
:row_pk [:in row-pks]
|
||||
:batch_num [(if undo? :> :<) batch_num]
|
||||
:undone (not undo?))))
|
||||
|
||||
(defn- categorize [{:keys [raw_before raw_after]}]
|
||||
(cond
|
||||
(nil? raw_before) :create
|
||||
(nil? raw_after) :delete
|
||||
:else :update))
|
||||
|
||||
(def ^:private invert
|
||||
{:create :delete
|
||||
:update :update
|
||||
:delete :create})
|
||||
|
||||
(def ^:private category->op
|
||||
{:create :created
|
||||
:update :updated
|
||||
:delete :deleted})
|
||||
|
||||
(defn- batch->rows
|
||||
"Given the undo snapshots for some batch update, return the corresponding row updates."
|
||||
[undo? batch]
|
||||
(let [k (if undo? :raw_before :raw_after)]
|
||||
(for [b batch]
|
||||
(merge (:row_pk b) (k b)))))
|
||||
|
||||
(defn- update-table-data!
|
||||
"Revert the underlying table data."
|
||||
[undo-or-redo context batch]
|
||||
(let [undo? (= :undo undo-or-redo)]
|
||||
;; Do a single perform-nested-action! call
|
||||
(doseq [[[table-id category] sub-batch] (u/group-by (juxt :table_id categorize) batch)
|
||||
:let [rows (batch->rows undo? sub-batch)
|
||||
inputs (map #(array-map :table-id table-id :row (update-keys % u/qualified-name)) rows)]]
|
||||
(case (if undo? (invert category) category)
|
||||
:create (try (actions/perform-nested-action! :table.row/create context inputs)
|
||||
(catch Exception e
|
||||
;; Sometimes cols don't support a custom value being provided, e.g., GENERATED ALWAYS AS IDENTITY
|
||||
(throw (ex-info "Failed to un-delete row(s)"
|
||||
{:error :undo/cannot-undelete
|
||||
:batch-num (:batch_num (first batch))
|
||||
:table-id table-id
|
||||
:pks (map :row_pk batch)}
|
||||
e))))
|
||||
:update (actions/perform-nested-action! :table.row/update context inputs)
|
||||
:delete (actions/perform-nested-action! :table.row/delete context inputs)))
|
||||
;; don't leak action outputs, we don't want to accidentally create more coupling to them.
|
||||
true))
|
||||
|
||||
(defn- undo*! [undo-or-redo context user-id scope]
|
||||
(let [undo? (= :undo undo-or-redo)
|
||||
batch (next-batch undo? user-id scope)]
|
||||
(cond
|
||||
(not (seq batch)) (throw (ex-info (if undo?
|
||||
"No previous versions found"
|
||||
"No subsequent versions found")
|
||||
{:error :undo/none
|
||||
:user-id user-id
|
||||
:scope scope}))
|
||||
(conflict? undo? batch) (throw (ex-info "Blocked by other changes"
|
||||
;; It would be nice if we gave the batch_num for the first conflict.
|
||||
{:error :undo/conflict
|
||||
:user-id user-id
|
||||
:scope scope}))
|
||||
:else
|
||||
(do (update-table-data! undo-or-redo context batch)
|
||||
(t2/update! :model/Undo
|
||||
{:batch_num (:batch_num (first batch))}
|
||||
{:undone undo?})
|
||||
(for [[table-id sub-batch] (u/group-by :table_id batch)
|
||||
:let [rows (batch->rows undo? sub-batch)]
|
||||
[delta row] (map vector sub-batch rows)
|
||||
:let [category ((if undo? invert identity) (categorize delta))]]
|
||||
{:op (category->op category), :table-id table-id, :row row})))))
|
||||
|
||||
(defn undo!
|
||||
"Rollback the given user's last change to the given table."
|
||||
[context user-id scope]
|
||||
(undo*! :undo context user-id scope))
|
||||
|
||||
(defn redo!
|
||||
"Rollback the given user's last change to the given table."
|
||||
[context user-id scope]
|
||||
(undo*! :redo context user-id scope))
|
||||
@@ -0,0 +1,35 @@
|
||||
(ns metabase-enterprise.data-editing-public.api
|
||||
(:require
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(api.macros/defendpoint :post "/webhook/:token/data"
|
||||
"Inserts rows into the table associated with the token. See routes for ee/data-editing/webhook for token CRUD.
|
||||
|
||||
Accepts either a single row or multiple rows in an array.
|
||||
Expects all required columns to be provided.
|
||||
|
||||
The provided row JSON must be compatible with the underlying types of the table.
|
||||
e.g if you have set up a casting rule for a unix epoch seconds field to present it as a datetime, callers must provide the integer value to this API.
|
||||
|
||||
Callers should expect 400 errors on constraint violation (e.g the primary key already exists) or the schema is not met."
|
||||
[{:keys [token]}
|
||||
_
|
||||
row-or-rows]
|
||||
(let [hook (api/check-404 (t2/select-one :table_webhook_token :token token))
|
||||
rows (if (map? row-or-rows) [row-or-rows] row-or-rows)]
|
||||
(api/check-400 (seq rows) "Please supply at least one row.")
|
||||
(api/check-400 (every? seq rows) "Every row should not be empty.")
|
||||
(let [user-id (:creator_id hook)
|
||||
scope {:webhook-id (:id hook)}
|
||||
table-id (:table_id hook)
|
||||
inputs (for [r rows]
|
||||
{:table-id table-id, :row r})]
|
||||
{:created (count (:outputs (actions/perform-action! :table.row/create scope inputs {:user-id user-id
|
||||
:policy :data-editing})))})))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/data-editing routes."
|
||||
(api.macros/ns-handler *ns*))
|
||||
@@ -64,6 +64,7 @@
|
||||
"Notification"
|
||||
"NotificationCard"
|
||||
"NotificationSubscription"
|
||||
"NotificationSystemEvent"
|
||||
"NotificationHandler"
|
||||
"NotificationRecipient"
|
||||
"Permissions"
|
||||
@@ -88,6 +89,7 @@
|
||||
"Secret"
|
||||
"Session"
|
||||
"TaskHistory"
|
||||
"Undo"
|
||||
"User"
|
||||
"UserKeyValue"
|
||||
"UserParameterValue"
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
:sso_jwt true
|
||||
:sso_ldap true
|
||||
:sso_saml true
|
||||
:table_data_editing false
|
||||
:upload_management true
|
||||
:whitelabel true
|
||||
:collection_cleanup true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
(ns metabase-enterprise.data-editing.coerce-test
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.data-editing.coerce :as coerce]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest coercion-conversions-test
|
||||
(mt/with-clock #t "2000-01-01T00:00:00Z"
|
||||
(let [test-cases
|
||||
[{:strategy :Coercion/UNIXSeconds->DateTime
|
||||
:input "2024-03-20T15:30:45Z[UTC]"
|
||||
:output 1710948645}
|
||||
|
||||
{:strategy :Coercion/UNIXMilliSeconds->DateTime
|
||||
:input "2024-03-20T15:30:45Z[UTC]"
|
||||
:output 1710948645000}
|
||||
|
||||
{:strategy :Coercion/UNIXMicroSeconds->DateTime
|
||||
:input "2024-03-20T15:30:45Z[UTC]"
|
||||
:output 1710948645000000}
|
||||
|
||||
{:strategy :Coercion/UNIXNanoSeconds->DateTime
|
||||
:input "2024-03-20T15:30:45Z[UTC]"
|
||||
:output 1710948645000000000}
|
||||
|
||||
{:strategy :Coercion/YYYYMMDDHHMMSSString->Temporal
|
||||
:input "2024-03-20T15:30Z[UTC]" ;; Note: seconds set to 00 since the format doesn't preserve seconds
|
||||
:output "20240320153000"}
|
||||
|
||||
{:strategy :Coercion/ISO8601->DateTime
|
||||
:input "2024-03-20T15:30:45Z[UTC]"
|
||||
:output "2024-03-20T15:30:45Z[UTC]"}
|
||||
|
||||
{:strategy :Coercion/ISO8601->Date
|
||||
:input "2024-03-20T00:00Z[UTC]"
|
||||
:output "2024-03-20"}
|
||||
|
||||
{:strategy :Coercion/ISO8601->Time
|
||||
:input "2025-05-01T15:30:45Z[UTC]"
|
||||
:reversed #(str/ends-with? % "T15:30:45Z[UTC]")
|
||||
:output "15:30:45"}]]
|
||||
|
||||
(doseq [{:keys [strategy input reversed output]} test-cases]
|
||||
(testing (format "Testing %s conversions" strategy)
|
||||
(let [reversed? (or reversed #{input})
|
||||
{:keys [in out]} (get coerce/coercion-fns strategy)]
|
||||
;; Test input conversion (JSON -> Database format)
|
||||
(testing "Input conversion"
|
||||
(is (= output (in input))
|
||||
(format "Input conversion failed for %s: expected %s, got %s"
|
||||
strategy output (in input))))
|
||||
|
||||
;; Test output conversion (Database format -> JSON)
|
||||
(testing "Output conversion"
|
||||
(is (reversed? (out output))
|
||||
(format "Output conversion failed for %s: expected %s, got %s"
|
||||
strategy
|
||||
(if-not reversed
|
||||
input
|
||||
(str "something like " input))
|
||||
(out output))))
|
||||
|
||||
;; Test roundtrip conversion
|
||||
(testing "Roundtrip conversion"
|
||||
(is (reversed? (-> input in out))
|
||||
(format "Roundtrip conversion failed for %s: %s -> %s -> %s"
|
||||
strategy input (in input) (out (in input)))))))))))
|
||||
|
||||
(deftest coercion-fns-static-test
|
||||
(testing "all coercion pair have to have an in and out function"
|
||||
(testing (every? #(and (fn? (:in %)) (fn? (:out %))) coerce/coercion-fns)))
|
||||
|
||||
;; TODO: fix this test by implementing all strategies
|
||||
#_(testing "all coercion strategies are covered"
|
||||
(is (empty? (second (diff (into #{} (keys coerce/coercion-fns)) (descendants :Coercion/*)))))))
|
||||
@@ -0,0 +1,150 @@
|
||||
(ns metabase-enterprise.data-editing.foreign-keys-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.data-editing.foreign-keys :as fks]))
|
||||
|
||||
(def ^:private metadata
|
||||
{:orders [{:table :order-items, :fk {:order-id :id}, :pk [:id]}]
|
||||
|
||||
;; family tree
|
||||
|
||||
:people [{:table :people, :fk {:father_id :id}, :pk [:id]}
|
||||
{:table :people, :fk {:mother_id :id}, :pk [:id]}]
|
||||
|
||||
;; project tracker
|
||||
|
||||
:users [{:table :teams, :fk {:managed-by :id}, :pk [:id]}
|
||||
{:table :users, :fk {:reports-to :id}, :pk [:id]}
|
||||
{:table :programmes, :fk {:guided-by :id}, :pk [:id]}
|
||||
{:table :tasks, :fk {:assigned-to :id}, :pk [:id]}]
|
||||
|
||||
:teams [{:table :users, :fk {:member-of :id}, :pk [:id]}]
|
||||
|
||||
:programmes [{:table :projects, :fk {:part-of :id}, :pk [:id]}
|
||||
{:table :teams, :fk {:focus :id}, :pk [:id]}]
|
||||
|
||||
:projects [{:table :tasks, :fk {:project :id} :pk [:id]}]})
|
||||
|
||||
(def db
|
||||
{:orders [{:id 42}
|
||||
{:id 43}]
|
||||
:order-items [{:id 1337, :order-id 42}
|
||||
{:id 1338, :order-id 42}
|
||||
{:id 1339, :order-id 43}]
|
||||
|
||||
;; family tree
|
||||
|
||||
:people [{:id 1}
|
||||
{:id 2}
|
||||
{:id 3 :father_id 1, :mother_id 2}
|
||||
{:id 4 :father_id 1, :mother_id 3}
|
||||
{:id 5 :father_id nil, :mother_id 3}]
|
||||
|
||||
;; project tracker
|
||||
|
||||
:users [{:id :user/ceo}
|
||||
{:id :user/cto, :reports-to :user/ceo}
|
||||
{:id :user/cpo, :reports-to :user/ceo}
|
||||
{:id :user/em, :reports-to :user/cto, :member-of :team/alpha}
|
||||
{:id :user/pm, :reports-to :user/em, :member-of :team/alpha}
|
||||
{:id :user/alice, :reports-to :user/em, :member-of :team/alpha}
|
||||
{:id :user/bob, :reports-to :user/em, :member-of :team/alpha}
|
||||
{:id :user/clarence, :reports-to :user/em, :member-of :team/alpha}]
|
||||
|
||||
:teams [{:id :team/alpha, :managed-by :user/em, :focus :programme/skunk-works}
|
||||
{:id :team/bravo, :managed-by :user/cto}]
|
||||
|
||||
:programmes [{:id :programme/skunk-works, :guided-by :user/cpo}]
|
||||
|
||||
:projects [{:id :project/gamma, :part-of :programme/skunk-works}]
|
||||
|
||||
:tasks [{:id :task/foo, :project :project/gamma, :assigned-to :user/alice}]})
|
||||
|
||||
(defn matches? [parent fks child]
|
||||
(= (map parent (vals fks))
|
||||
(map child (keys fks))))
|
||||
|
||||
(defn lookup-children
|
||||
([relationship parents]
|
||||
(lookup-children db relationship parents))
|
||||
([db {:keys [table fk pk]} parents]
|
||||
[table
|
||||
(for [child (get db table)
|
||||
parent parents
|
||||
:when (matches? parent fk child)]
|
||||
(select-keys child pk))]))
|
||||
|
||||
(deftest walk-test
|
||||
(is (= {:complete? true
|
||||
:items {:order-items #{{:id 1338} {:id 1337}}}}
|
||||
(fks/walk :orders [{:id 42}] metadata lookup-children)))
|
||||
(is (= {:complete? true
|
||||
:items {:people #{{:id 5} {:id 4} {:id 3}}}}
|
||||
(fks/walk :people [{:id 1}] metadata lookup-children)))
|
||||
(is (= {:complete? true
|
||||
:items {:users #{{:id :user/cto}
|
||||
{:id :user/cpo}
|
||||
{:id :user/em}
|
||||
{:id :user/pm}
|
||||
{:id :user/bob}
|
||||
{:id :user/clarence}
|
||||
{:id :user/alice}}
|
||||
:teams #{{:id :team/alpha}
|
||||
{:id :team/bravo}}
|
||||
:programmes #{{:id :programme/skunk-works}}
|
||||
:projects #{{:id :project/gamma}}
|
||||
:tasks #{{:id :task/foo}}}}
|
||||
(fks/walk :users [{:id :user/ceo}] metadata lookup-children))))
|
||||
|
||||
(deftest count-descendants-test
|
||||
(is (= {:complete? true
|
||||
:counts {:order-items 2}}
|
||||
(fks/count-descendants :orders [{:id 42}] metadata lookup-children)))
|
||||
(is (= {:complete? true
|
||||
:counts {:people 3}}
|
||||
(fks/count-descendants :people [{:id 1}] metadata lookup-children)))
|
||||
(is (= {:complete? true
|
||||
:counts {:users 7
|
||||
:teams 2
|
||||
:programmes 1
|
||||
:projects 1
|
||||
:tasks 1}}
|
||||
(fks/count-descendants :users [{:id :user/ceo}] metadata lookup-children))))
|
||||
|
||||
(defn- delete-items [db items]
|
||||
(reduce
|
||||
(fn [db [item-type items]]
|
||||
(update db item-type (fn [existing]
|
||||
(filterv #(not (items (select-keys % (keys (first items)))))
|
||||
existing))))
|
||||
db
|
||||
items))
|
||||
|
||||
(defn- after-delete [table rows]
|
||||
(let [*db (atom db)
|
||||
lookup-fn #(apply lookup-children @*db %&)
|
||||
delete-fn #(swap! *db delete-items %)]
|
||||
(fks/delete-recursively table rows metadata lookup-fn delete-fn)
|
||||
(select-keys @*db
|
||||
(case table
|
||||
:orders [:orders :order-items]
|
||||
:people [:people]
|
||||
:users (remove #{:orders :order-items :people} (keys db))))))
|
||||
|
||||
(deftest delete-recursively-test
|
||||
(is (= {:orders [{:id 43}], :order-items [{:id 1339, :order-id 43}]}
|
||||
(after-delete :orders [{:id 42}])))
|
||||
|
||||
(is (= {:people [{:id 2}]}
|
||||
(after-delete :people [{:id 1}])))
|
||||
|
||||
(is (= {:programmes [], :projects [], :tasks [], :teams [], :users []}
|
||||
(after-delete :users [{:id :user/ceo}])))
|
||||
(is (= {:users [{:id :user/ceo}
|
||||
{:id :user/cto, :reports-to :user/ceo}
|
||||
{:id :user/cpo, :reports-to :user/ceo}]
|
||||
:teams [{:id :team/bravo, :managed-by :user/cto}],
|
||||
:programmes [{:guided-by :user/cpo, :id :programme/skunk-works}],
|
||||
:projects [{:id :project/gamma, :part-of :programme/skunk-works}],
|
||||
:tasks []}
|
||||
(after-delete :users [{:id :user/em}]))))
|
||||
@@ -0,0 +1,520 @@
|
||||
(ns metabase-enterprise.data-editing.notification-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
|
||||
[metabase.actions.test-util :as actions.tu]
|
||||
[metabase.channel.urls :as urls]
|
||||
[metabase.notification.events.notification :as events.notification]
|
||||
[metabase.notification.test-util :as notification.tu]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :each (fn [thunk]
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(thunk))))
|
||||
|
||||
(defn- assert-email-contains
|
||||
[email expected-subject body-patterns]
|
||||
(let [expected-body-map (zipmap body-patterns (repeat (count body-patterns) true))]
|
||||
(is (=? {:subject expected-subject
|
||||
:body [(update-keys expected-body-map str)]}
|
||||
(apply mt/summarize-multipart-single-email email body-patterns)))))
|
||||
|
||||
(defn all-handlers
|
||||
[http-channel-id]
|
||||
[{:channel_type :channel/email
|
||||
:recipients [{:type :notification-recipient/user
|
||||
:user_id (mt/user->id :crowberto)}]}
|
||||
{:channel_type :channel/slack
|
||||
:recipients [{:type :notification-recipient/raw-value
|
||||
:details {:value "#test-pulse"}}]}
|
||||
{:channel_type :channel/http
|
||||
:channel_id http-channel-id}])
|
||||
|
||||
(defn test-row-notification!
|
||||
[prop request-fn channel-type->assert-fns]
|
||||
(data-editing.tu/with-temp-test-db!
|
||||
(mt/with-temp [:model/Channel chn {:type :channel/http}]
|
||||
(let [table-id (mt/id (or (:table prop) :categories))]
|
||||
(notification.tu/with-system-event-notification!
|
||||
[notification {:notification-system-event {:event_name (:event_name prop)
|
||||
:table_id table-id}
|
||||
:notification {:condition [:and
|
||||
[:= [:context :table_id] table-id]
|
||||
[:= [:context :event_name] (u/qualified-name (:event_name prop))]]}
|
||||
:handlers (all-handlers (:id chn))}]
|
||||
(notification.tu/with-channel-fixtures [:channel/email :channel/slack]
|
||||
(let [channel-type->captured-message (notification.tu/with-captured-channel-send!
|
||||
(request-fn notification))]
|
||||
(doseq [[channel-type assert-fn] channel-type->assert-fns]
|
||||
(testing (format "channel-type = %s" channel-type)
|
||||
(assert-fn (get channel-type->captured-message channel-type)))))))))))
|
||||
|
||||
(deftest create-row-notification-test
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.created}
|
||||
(fn [_notification]
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:post
|
||||
(data-editing.tu/table-url (mt/id :categories))
|
||||
{:rows [{:NAME "New Category"}]}))
|
||||
|
||||
{:channel/slack (fn [[message :as msgs]]
|
||||
(is (= 1 (count msgs)))
|
||||
(is (=? {:blocks [{:type "section"
|
||||
:text
|
||||
{:type "mrkdwn"
|
||||
:text (format (str
|
||||
"*A new record was _created_* in <%1$s|Table CATEGORIES> by Crowberto Corv.\n"
|
||||
"• *ID*: 76\n"
|
||||
"• *NAME*: New Category\n"
|
||||
"<%1$s|View table in Metabase>")
|
||||
(urls/table-url (mt/id) (mt/id :categories)))}}]
|
||||
:channel "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(assert-email-contains
|
||||
email
|
||||
"A new record was added to \"CATEGORIES\" by Crowberto Corv"
|
||||
[(re-pattern (format "<strong>A new record was <i>created</i></strong> in <a href=\"%s\" target=\"_blank\">Table CATEGORIES</a> by Crowberto Corv"
|
||||
(urls/table-url (mt/id) (mt/id :categories))))
|
||||
#"Table CATEGORIES"
|
||||
#"Crowberto Corv"
|
||||
#"Field"
|
||||
#"Value"
|
||||
#"NAME"
|
||||
#"New Category"]))
|
||||
:channel/http (fn [[req :as reqs]]
|
||||
(is (= 1 (count reqs)))
|
||||
(is (=? {:body (mt/malli=? :map)} req)))}))
|
||||
|
||||
(deftest update-row-notification-test
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.updated}
|
||||
(fn [_notification]
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:put
|
||||
(data-editing.tu/table-url (mt/id :categories))
|
||||
{:rows [{:ID 1 :NAME "Updated Category"}]}))
|
||||
|
||||
{:channel/slack (fn [[message :as msgs]]
|
||||
(is (= 1 (count msgs)))
|
||||
(is (=? {:blocks [{:type "section"
|
||||
:text {:type "mrkdwn"
|
||||
:text (format (str "*A record was _updated_* in <%s|Table CATEGORIES> by Crowberto Corv\n"
|
||||
"*Changed Fields*\n"
|
||||
"• *NAME*: ~African~ → Updated Category\n"
|
||||
"\n"
|
||||
"*Current Record Details*\n"
|
||||
"• *ID*: 1\n"
|
||||
"• *NAME*: Updated Category\n"
|
||||
"<%1$s|View table in Metabase>")
|
||||
(urls/table-url (mt/id) (mt/id :categories)))}}]
|
||||
:channel "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(assert-email-contains
|
||||
email
|
||||
"A record was updated in \"CATEGORIES\" by Crowberto Corv"
|
||||
[(re-pattern (format "<strong>A record was <i>updated</i></strong> in <a href=\"%s\" target=\"_blank\">Table CATEGORIES</a> by Crowberto Corv"
|
||||
(urls/table-url (mt/id) (mt/id :categories))))
|
||||
#"Table CATEGORIES"
|
||||
#"Crowberto Corv"
|
||||
#"Changed Fields:"
|
||||
#"NAME"
|
||||
#"African"
|
||||
#"Updated Category"
|
||||
#"Current Record Details"
|
||||
#"Field"
|
||||
#"Value"]))
|
||||
:channel/http (fn [[req :as reqs]]
|
||||
(is (= 1 (count reqs)))
|
||||
(is (=? {:body (mt/malli=? :map)} req)))}))
|
||||
|
||||
(deftest delete-row-notification-test
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.deleted}
|
||||
(fn [_notification]
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:post
|
||||
(format "%s/delete" (data-editing.tu/table-url (mt/id :categories)))
|
||||
{:rows [{:ID 1}]}))
|
||||
|
||||
{:channel/slack (fn [[message :as msgs]]
|
||||
(is (= 1 (count msgs)))
|
||||
(is (=? {:blocks [{:type "section"
|
||||
:text {:type "mrkdwn"
|
||||
:text (format (str
|
||||
"*A record was _deleted_* in <%1$s|Table CATEGORIES> by Crowberto Corv.\n"
|
||||
"• ~*ID*~: 1\n"
|
||||
"• ~*NAME*~: African\n\n"
|
||||
"This record is no longer available\n"
|
||||
"<%1$s|View table in Metabase>")
|
||||
(urls/table-url (mt/id) (mt/id :categories)))}}]
|
||||
:channel "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(assert-email-contains
|
||||
email
|
||||
"A record was deleted from \"CATEGORIES\" by Crowberto Corv"
|
||||
[(re-pattern (format "<strong>A record was <i>deleted</i></strong> in <a href=\"%s\" target=\"_blank\">Table CATEGORIES</a> by Crowberto Corv"
|
||||
(urls/table-url (mt/id) (mt/id :categories))))
|
||||
#"Table CATEGORIES"
|
||||
#"Crowberto Corv"
|
||||
#"Field"
|
||||
#"Value"
|
||||
#"NAME"
|
||||
#"African"
|
||||
#"This record is no longer available"]))
|
||||
:channel/http (fn [[req :as reqs]]
|
||||
(is (= 1 (count reqs)))
|
||||
(is (=? {:body (mt/malli=? :map)} req)))}))
|
||||
|
||||
(deftest default-slack-notification-is-escaped
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.updated}
|
||||
(fn [_notification]
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:put
|
||||
(data-editing.tu/table-url (mt/id :categories))
|
||||
{:rows [{:ID 1 :NAME "Metabase's Notification"}]}))
|
||||
|
||||
{:channel/slack (fn [[message :as msgs]]
|
||||
(is (= 1 (count msgs)))
|
||||
(is (=? {:blocks [{:type "section"
|
||||
:text {:type "mrkdwn"
|
||||
:text (format
|
||||
(str "*A record was _updated_* in <%1$s|Table CATEGORIES> by Crowberto Corv\n"
|
||||
"*Changed Fields*\n"
|
||||
"• *NAME*: ~African~ → Metabase's Notification\n"
|
||||
"\n"
|
||||
"*Current Record Details*\n"
|
||||
"• *ID*: 1\n"
|
||||
"• *NAME*: Metabase's Notification\n"
|
||||
"<%1$s|View table in Metabase>")
|
||||
(urls/table-url (mt/id) (mt/id :categories)))}}]
|
||||
:channel "#test-pulse"}
|
||||
message)))}))
|
||||
|
||||
(deftest record-and-changes-in-template-context-are-ordered
|
||||
(actions.tu/with-actions-test-data-tables #{"people"}
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.updated
|
||||
:table :people}
|
||||
(fn [notification]
|
||||
(mt/with-temp [:model/ChannelTemplate {tmpl-id :id} {:name "My Custom template"
|
||||
:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "Hello"
|
||||
:body (str "Row: {{#each record}}{{@key}},{{/each}}\n"
|
||||
"Changes: {{#each changes}}{{@key}},{{/each}}")}}]
|
||||
(t2/update! :model/NotificationHandler (->> notification :handlers first :id) {:template_id tmpl-id})
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:put
|
||||
(data-editing.tu/table-url (mt/id :people))
|
||||
{:rows [{:ID 1 :NAME "Ngoc Khuat" :CITY "Ha Noi" :EMAIL "ngoc@metabase.com"}]})))
|
||||
{:channel/email (fn [[email :as _emails]]
|
||||
(is (=? {:body [{:content (str "Row: ID,ADDRESS,EMAIL,PASSWORD,NAME,CITY,LONGITUDE,STATE,SOURCE,BIRTH_DATE,ZIP,LATITUDE,CREATED_AT,\n"
|
||||
"Changes: EMAIL,NAME,CITY,")
|
||||
:type "text/html; charset=utf-8"}]}
|
||||
email)))})
|
||||
|
||||
(testing "respect field-order"
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.updated
|
||||
:table :people}
|
||||
(fn [notification]
|
||||
(t2/update! :model/Table (mt/id :people) {:field_order :alphabetical})
|
||||
(mt/with-temp [:model/ChannelTemplate {tmpl-id :id} {:name "My Custom template"
|
||||
:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "Hello"
|
||||
:body (str "Row: {{#each record}}{{@key}},{{/each}}\n"
|
||||
"Changes: {{#each changes}}{{@key}},{{/each}}")}}]
|
||||
(t2/update! :model/NotificationHandler (->> notification :handlers first :id) {:template_id tmpl-id})
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:put
|
||||
(data-editing.tu/table-url (mt/id :people))
|
||||
{:rows [{:ID 1 :NAME "Ngoc Khuat" :CITY "Ha Noi" :EMAIL "ngoc@metabase.com"}]})))
|
||||
{:channel/email (fn [[email :as _emails]]
|
||||
(is (=? {:body [{:content (str "Row: ADDRESS,BIRTH_DATE,CITY,CREATED_AT,EMAIL,ID,LATITUDE,LONGITUDE,NAME,PASSWORD,SOURCE,STATE,ZIP,\n"
|
||||
"Changes: CITY,EMAIL,NAME,")
|
||||
:type "text/html; charset=utf-8"}]}
|
||||
email)))}))))
|
||||
|
||||
(deftest record-and-changes-is-coerced-properly
|
||||
(actions.tu/with-actions-test-data-tables #{"categories"}
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.updated}
|
||||
(fn [notification]
|
||||
;; why testing coercion against the name column?
|
||||
;; for one it's quite hard to setup custom test data so I decided to just roll with it
|
||||
;; but also this test that failure in coercion is ignored (e.g: failed to convert name to datetime)
|
||||
(mt/with-temp [:model/ChannelTemplate {tmpl-id :id} {:name "My Custom template"
|
||||
:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "Hello"
|
||||
:body (str "Name: {{record.NAME}}\n"
|
||||
"Name: {{changes.NAME.before}}\n"
|
||||
"Name: {{changes.NAME.after}}")}}]
|
||||
(t2/update! :model/NotificationHandler (->> notification :handlers first :id) {:template_id tmpl-id})
|
||||
(t2/update! :model/Field (mt/id :categories :name) {:coercion_strategy :Coercion/ISO8601->Date})
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:put
|
||||
(data-editing.tu/table-url (mt/id :categories))
|
||||
{:rows [{:ID 1 :NAME "2025-03-25T00:00:00Z"}]})))
|
||||
{:channel/email (fn [[email :as _emails]]
|
||||
(is (=? {:body [{:content "Name: 2025-03-25T00:00Z[UTC]\nName: African\nName: 2025-03-25T00:00Z[UTC]"
|
||||
:type "text/html; charset=utf-8"}]}
|
||||
email)))})))
|
||||
|
||||
(deftest create-row-notification-webhook-test
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.created}
|
||||
(fn [_notification]
|
||||
(let [token (:token (mt/user-http-request :crowberto
|
||||
:post "ee/data-editing/webhook"
|
||||
{:table-id (mt/id :categories)}))]
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:post
|
||||
(data-editing.tu/webhook-ingest-url token)
|
||||
[{:NAME "New Category"}])))
|
||||
|
||||
{:channel/slack (fn [[message :as msgs]]
|
||||
(is (= 1 (count msgs)))
|
||||
(is (=? {:blocks [{:type "section"
|
||||
:text {:type "mrkdwn"
|
||||
:text (format (str
|
||||
"*A new record was _created_* in <%1$s|Table CATEGORIES> by Crowberto Corv.\n"
|
||||
"• *ID*: 76\n"
|
||||
"• *NAME*: New Category\n"
|
||||
"<%1$s|View table in Metabase>")
|
||||
(urls/table-url (mt/id) (mt/id :categories)))}}]
|
||||
:channel "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(assert-email-contains
|
||||
email
|
||||
"A new record was added to \"CATEGORIES\" by Crowberto Corv"
|
||||
[#"<strong>A new record was <i>created</i></strong> in <a[^>]*>Table CATEGORIES</a> by Crowberto Corv"
|
||||
#"Field"
|
||||
#"Value"
|
||||
#"NAME"
|
||||
#"New Category"]))
|
||||
:channel/http (fn [[req :as reqs]]
|
||||
(is (= 1 (count reqs)))
|
||||
(is (=? {:body (mt/malli=? :map)} req)))}))
|
||||
|
||||
(deftest filter-notifications-test
|
||||
(testing "getting table notifications will return only notifications of a table and action"
|
||||
(doseq [event [:event/row.created
|
||||
:event/row.updated
|
||||
:event/row.deleted]]
|
||||
(notification.tu/with-system-event-notification!
|
||||
[_create-categories {:notification-system-event {:event_name event
|
||||
:table_id (mt/id :categories)}}]
|
||||
|
||||
(notification.tu/with-system-event-notification!
|
||||
[create-orders {:notification-system-event {:event_name event
|
||||
:table_id (mt/id :orders)}}]
|
||||
(is (= [(:id create-orders)]
|
||||
(map :id (#'events.notification/notifications-for-topic
|
||||
event
|
||||
{:args {:table_id (mt/id :orders)}})))))))))
|
||||
|
||||
(deftest example-payload-row-create-test
|
||||
(doseq [channel-type [:channel/slack :channel/email]]
|
||||
(is (=? {:context {:event_name "event/row.created"}
|
||||
:creator {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
|
||||
:editor {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
|
||||
:table {:id (mt/id :orders) :name "ORDERS"}
|
||||
:settings {}
|
||||
:record (mt/malli=? [:fn #(= #{:ID :PRODUCT_ID :QUANTITY :SUBTOTAL :DISCOUNT :TOTAL
|
||||
:USER_ID :TAX :CREATED_AT}
|
||||
(set (keys %)))])}
|
||||
(get-in (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.created
|
||||
:table_id (mt/id :orders)}
|
||||
:creator_id (mt/user->id :crowberto)}
|
||||
:channel_types [channel-type]})
|
||||
|
||||
[channel-type
|
||||
:payload])))))
|
||||
|
||||
(deftest example-payload-row-update-test
|
||||
(doseq [channel-type [:channel/slack :channel/email]]
|
||||
(is (=? {:context {:event_name "event/row.updated"}
|
||||
:creator {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
|
||||
:editor {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
|
||||
:table {:id (mt/id :orders) :name "ORDERS"}
|
||||
:settings {}
|
||||
:record (mt/malli=? [:fn #(= #{:ID :PRODUCT_ID :QUANTITY :SUBTOTAL :DISCOUNT :TOTAL
|
||||
:USER_ID :TAX :CREATED_AT}
|
||||
(set (keys %)))])
|
||||
:changes (mt/malli=? [:fn #(= #{:ID :PRODUCT_ID :QUANTITY :SUBTOTAL :DISCOUNT :TOTAL
|
||||
:USER_ID :TAX :CREATED_AT}
|
||||
(set (keys %)))])}
|
||||
(get-in (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.updated
|
||||
:table_id (mt/id :orders)}
|
||||
:creator_id (mt/user->id :crowberto)}
|
||||
:channel_types [channel-type]})
|
||||
|
||||
[channel-type
|
||||
:payload])))))
|
||||
|
||||
(deftest example-payload-row-delete-test
|
||||
(doseq [channel-type [:channel/slack :channel/email]]
|
||||
(is (=? {:context {:event_name "event/row.deleted"}
|
||||
:creator {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
|
||||
:editor {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
|
||||
:table {:id (mt/id :orders) :name "ORDERS"}
|
||||
:settings {}
|
||||
:record (mt/malli=? [:fn #(= #{:ID :PRODUCT_ID :QUANTITY :SUBTOTAL :DISCOUNT :TOTAL
|
||||
:USER_ID :TAX :CREATED_AT}
|
||||
(set (keys %)))])}
|
||||
(get-in (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.deleted
|
||||
:table_id (mt/id :orders)}
|
||||
:creator_id (mt/user->id :crowberto)}
|
||||
:channel_types [channel-type]})
|
||||
|
||||
[channel-type
|
||||
:payload])))))
|
||||
|
||||
(deftest preview-notification-test
|
||||
(is (=? {:context (mt/malli=? :map)
|
||||
:rendered {:from "notifications@metabase.com"
|
||||
:bcc ["bot@metabase.com"]
|
||||
:subject "Meta Bot has created a row for orders"
|
||||
:body [{:type "text/html; charset=utf-8" :content "New record: active"}]}}
|
||||
(mt/user-http-request :crowberto :post 200 "notification/preview_template"
|
||||
{:template {:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "{{editor.first_name}} {{editor.last_name}} has created a row for {{table.name}}"
|
||||
:body "New record: {{record.status}}"}}
|
||||
:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.created}}}))))
|
||||
|
||||
(deftest preview-notification-nice-error-message-test
|
||||
(testing "invalide handlebars template"
|
||||
(is (=? "Failed to render template: found: '}'\n{{}\n ^"
|
||||
(mt/user-http-request :crowberto :post 400 "notification/preview_template"
|
||||
{:template {:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "subject"
|
||||
:body "{{}"}}
|
||||
:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.created}}}))))
|
||||
(testing "invalid subject template"
|
||||
(is (=? "Failed to render template: Invalid query: found '[[' or '{{' with no matching ']]' or '}}'"
|
||||
(mt/user-http-request :crowberto :post 400 "notification/preview_template"
|
||||
{:template {:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "{{"
|
||||
:body "{{}"}}
|
||||
:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.created}}})))))
|
||||
|
||||
(deftest preview-notification-custom-payload-test
|
||||
(testing "use the custom context if it's valid"
|
||||
(is (=? {:context (mt/malli=? :map)
|
||||
:rendered {:from "notifications@metabase.com"
|
||||
:bcc ["bot@metabase.com"]
|
||||
:subject "Ngoc Khuat has created a row for orders"
|
||||
:body [{:type "text/html; charset=utf-8" :content "New record: cancelled"}]}}
|
||||
(mt/user-http-request :crowberto :post 200 "notification/preview_template"
|
||||
{:template {:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "{{editor.first_name}} {{editor.last_name}} has created a row for {{table.name}}"
|
||||
:body "New record: {{record.status}}"}}
|
||||
:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.created}}
|
||||
:custom_context {:context {:event_name :event/row.created
|
||||
:timestamp "2023-01-01T10:00:00Z"
|
||||
:scope {:type "table"
|
||||
:origin_url "https://metabase.com/table/1"}}
|
||||
:creator {:common_name "Meta Bot",
|
||||
:email "bot@metabase.com",
|
||||
:first_name "Meta",
|
||||
:last_name "Bot"},
|
||||
:editor {:common_name "Ngoc Khuat",
|
||||
:email "ngoc@metabase.com",
|
||||
:first_name "Ngoc",
|
||||
:last_name "Khuat"},
|
||||
:record {:id 1, :status "cancelled"}
|
||||
:settings {},
|
||||
:table {:id 1, :name "orders" :url "http://localhost:3000/table/1"}}}))))
|
||||
(testing "fail if the custom context does not match the schema"
|
||||
(is (=? {:errors (mt/malli=? :map)
|
||||
:specific-errors (mt/malli=? :map)}
|
||||
(mt/user-http-request :crowberto :post 400 "notification/preview_template"
|
||||
{:template {:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "{{editor.first_name}} {{editor.last_name}} has created a row for {{table.name}}"
|
||||
:body "Created {{count records}} records"}}
|
||||
:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.created}}
|
||||
:custom_context {::context true}})))))
|
||||
|
||||
(deftest custom-template-test
|
||||
(data-editing.tu/with-temp-test-db!
|
||||
(mt/with-temp [:model/Channel {http-chn :id} {:type :channel/http}
|
||||
:model/ChannelTemplate {http-tmpl :id} {:channel_type :channel/http
|
||||
:details {:type :http/handlebars-text
|
||||
:body "{\"record\": {{{json-encode record}}}, \"new_name\": \"{{changes.NAME.after}}\" }"}}
|
||||
:model/ChannelTemplate {slack-tmpl :id} {:channel_type :channel/slack
|
||||
:details {:type :slack/handlebars-text
|
||||
:body "Name Was changed from {{changes.NAME.before}} to {{record.NAME}}"}}
|
||||
:model/ChannelTemplate {email-tmpl :id} {:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "{{editor.first_name}} {{editor.last_name}} has created a row for {{table.name}}"
|
||||
:body "New record: {{record.status}}"}}]
|
||||
(notification.tu/with-system-event-notification!
|
||||
[_notification {:notification-system-event {:event_name :event/row.updated
|
||||
:table_id (mt/id :categories)}
|
||||
:handlers [{:channel_type :channel/email
|
||||
:template_id email-tmpl
|
||||
:recipients [{:type :notification-recipient/user
|
||||
:user_id (mt/user->id :crowberto)}]}
|
||||
{:channel_type :channel/slack
|
||||
:template_id slack-tmpl
|
||||
:recipients [{:type :notification-recipient/raw-value
|
||||
:details {:value "#test-pulse"}}]}
|
||||
{:channel_type :channel/http
|
||||
:template_id http-tmpl
|
||||
:channel_id http-chn}]}]
|
||||
(is (=? {:channel/email [{:bcc ["crowberto@metabase.com"]
|
||||
:body [{:content "New record: "
|
||||
:type "text/html; charset=utf-8"}]
|
||||
:from "notifications@metabase.com"
|
||||
:subject "Crowberto Corv has created a row for CATEGORIES"}]
|
||||
:channel/http [{:body {"record" {"ID" 1, "NAME" "Updated Category"}
|
||||
"new_name" "Updated Category"}}]
|
||||
:channel/slack [{:blocks [{:text {:text "Name Was changed from African to Updated Category"
|
||||
:type "mrkdwn"}
|
||||
:type "section"}]
|
||||
:channel "#test-pulse"}]}
|
||||
(notification.tu/with-captured-channel-send!
|
||||
(notification.tu/with-channel-fixtures [:channel/email :channel/slack]
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:put
|
||||
(data-editing.tu/table-url (mt/id :categories))
|
||||
{:rows [{:ID 1 :NAME "Updated Category"}]})))))))))
|
||||
@@ -0,0 +1,95 @@
|
||||
(ns metabase-enterprise.data-editing.test-util
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[clojure.walk :as walk]
|
||||
[metabase.actions.test-util :as actions.tu]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.sync.core :as sync]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(clojure.lang IDeref)
|
||||
(java.io Closeable)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn sync-new-table!
|
||||
"Syncs a new table by name, returns the table id."
|
||||
[db table-name]
|
||||
(let [table (sync/create-table! db {:name table-name
|
||||
;; todo figure out how to determine default schema from driver
|
||||
:schema (case (:engine db) :postgres "public" nil)
|
||||
:display_name table-name})]
|
||||
(sync/sync-fields-for-table! db table)
|
||||
(:id table)))
|
||||
|
||||
(defn- create-test-table! [db table-name column-map create-table-opts]
|
||||
(let [_ (driver/create-table! (:engine db)
|
||||
(:id db)
|
||||
table-name
|
||||
column-map
|
||||
create-table-opts)]
|
||||
(sync-new-table! db table-name)))
|
||||
|
||||
(defn table-url
|
||||
"Returns the URL for data editing of the table with the given ID."
|
||||
[table-id]
|
||||
(format "ee/data-editing/table/%d" table-id))
|
||||
|
||||
(defn webhook-ingest-url
|
||||
"Returns the URL for the webhook ingest endpoint."
|
||||
[token]
|
||||
(format "ee/data-editing-public/webhook/%s/data" token))
|
||||
|
||||
(defn alter-appdb-settings! [f & args]
|
||||
(let [id (mt/id)
|
||||
settings (t2/select-one-fn :settings :model/Database id)
|
||||
new-settings (apply f settings args)]
|
||||
(t2/update! :model/Database id {:settings new-settings})))
|
||||
|
||||
(defn toggle-data-editing-enabled! [on-or-off]
|
||||
(alter-appdb-settings! assoc :database-enable-table-editing (boolean on-or-off)))
|
||||
|
||||
(defn open-test-table!
|
||||
"Sets up an anonymous table in the test db (mt/id). Return a box that can be deref'd for the table-id.
|
||||
|
||||
Optionally accepts the column map and opts inputs to driver/create-table!.
|
||||
The symbol auto-inc-type can be used to denote the driver-specific auto-incrementing type for primary keys.
|
||||
e.g (open-test-table {:id 'auto-inc-type, :name [:text]} {:primary-key [:id]})
|
||||
|
||||
Returned box is java.io.Closeable so you can clean up with `with-open`.
|
||||
Otherwise .close the box to drop the table when finished."
|
||||
(^Closeable []
|
||||
(open-test-table!
|
||||
{:id 'auto-inc-type
|
||||
:name [:text]
|
||||
:song [:text]}
|
||||
{:primary-key [:id]}))
|
||||
(^Closeable [column-map create-table-opts]
|
||||
(let [db (t2/select-one :model/Database (mt/id))
|
||||
driver (:engine db)
|
||||
auto-inc-type (driver/upload-type->database-type driver :metabase.upload/auto-incrementing-int-pk)
|
||||
column-map (walk/postwalk-replace {'auto-inc-type auto-inc-type} column-map)
|
||||
table-name (str "temp_table_" (str/replace (random-uuid) "-" "_"))
|
||||
cleanup (fn []
|
||||
(driver/drop-table! driver (mt/id) table-name)
|
||||
(t2/delete! :model/Table :name table-name))]
|
||||
(try
|
||||
(let [table-id (create-test-table! db table-name column-map create-table-opts)]
|
||||
(reify Closeable
|
||||
IDeref
|
||||
(deref [_] table-id)
|
||||
(close [_] (cleanup))))
|
||||
(catch Exception e
|
||||
(try (cleanup) (catch Exception cleanup-ex (.addSuppressed e cleanup-ex)))
|
||||
(throw e))))))
|
||||
|
||||
(defmacro with-temp-test-db!
|
||||
"Sets up a temporary database in the appdb to do destrcutive tests.
|
||||
|
||||
Use (mt/id), (mt/db) etc to get the database id and database object."
|
||||
[& body]
|
||||
`(actions.tu/with-actions-test-data
|
||||
(toggle-data-editing-enabled! true)
|
||||
~@body))
|
||||
@@ -0,0 +1,381 @@
|
||||
(ns metabase-enterprise.data-editing.undo-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
|
||||
[metabase-enterprise.data-editing.undo :as undo]
|
||||
[metabase.query-processor :as qp]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(deftest diff-keys-test
|
||||
(testing "Detect which keys have changes"
|
||||
(is (= [:a :b :c] (#'undo/diff-keys
|
||||
nil
|
||||
(zipmap [:a :b :c] (range)))))
|
||||
(is (= [:a :b :c] (#'undo/diff-keys
|
||||
(zipmap [:a :b :c] (range))
|
||||
nil)))
|
||||
(is (= [:b :c :d] (#'undo/diff-keys
|
||||
(zipmap [:a :b :c] (range))
|
||||
(zipmap [:a :c :d] (range)))))))
|
||||
|
||||
(defn- table-rows [table-id]
|
||||
(mt/rows
|
||||
(qp/process-query
|
||||
{:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table table-id}})))
|
||||
|
||||
(defn- create-row! [user-id table-id row]
|
||||
(mt/user-http-request user-id :post 200 (data-editing.tu/table-url table-id) {:rows [row]}))
|
||||
|
||||
(defn- update-row! [user-id table-id row]
|
||||
(mt/user-http-request user-id :put 200 (data-editing.tu/table-url table-id) {:rows [row]}))
|
||||
|
||||
(defn- delete-row! [user-id table-id pk]
|
||||
(mt/user-http-request user-id :post 200 (str (data-editing.tu/table-url table-id) "/delete") {:rows [pk]}))
|
||||
|
||||
(defn- next-batch-num [undo-or-redo user-id scope]
|
||||
(undo/next-batch-num undo-or-redo user-id scope))
|
||||
|
||||
(defn- undo-via-api! [user-id scope]
|
||||
(mt/user-http-request user-id :post "/ee/data-editing/action/v2/execute"
|
||||
{:action_id "data-editing/undo"
|
||||
:scope scope
|
||||
:input {}}))
|
||||
|
||||
(defn- redo-via-api! [user-id scope]
|
||||
(mt/user-http-request user-id :post "/ee/data-editing/action/v2/execute"
|
||||
{:action_id "data-editing/redo"
|
||||
:scope scope
|
||||
:input {}}))
|
||||
|
||||
(defn- write-sequence! [table-id pk states]
|
||||
(loop [prior nil
|
||||
states states]
|
||||
(when (seq states)
|
||||
(let [[user-id value] (first states)]
|
||||
(case [(some? prior) (some? value)]
|
||||
[false false] nil
|
||||
[false true] (create-row! user-id table-id (merge pk value))
|
||||
[true false] (delete-row! user-id table-id pk)
|
||||
[true true] (update-row! user-id table-id (merge pk value)))
|
||||
(recur value (rest states))))))
|
||||
|
||||
;; I'm OK reducing this scenario's scope once we have e2e tests.
|
||||
;; Until then, I think this is ideal.
|
||||
#_{:clj-kondo/ignore [:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests]}
|
||||
(deftest undo-redo-single-user-single-table-single-record-integration-test
|
||||
(mt/with-empty-h2-app-db
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(testing "Single-user chain, non-generated pk"
|
||||
;; TODO test with a real PK
|
||||
;; Beware: h2 will not allow un-delete, we'll need to adjust the test not to include deletion in its history.
|
||||
;; and, we'll want to test the failure we get when we try to un-delete
|
||||
;; Blocker for other drivers: https://linear.app/metabase/issue/WRK-223/pk-creation-for-non-h2-drivers
|
||||
(with-open [table-ref (data-editing.tu/open-test-table! {:id [:int]
|
||||
:name [:text]
|
||||
:favourite_food [:text]}
|
||||
{:primary-key [:id]})]
|
||||
(let [table-id @table-ref
|
||||
user-id (mt/user->id :crowberto)
|
||||
test-scope {:table-id table-id}]
|
||||
(data-editing.tu/toggle-data-editing-enabled! true)
|
||||
|
||||
(write-sequence! table-id {:id 1} [[user-id {:name "Snorkmaiden" :favourite_food "pork"}]
|
||||
[user-id {:name "Snorkmaiden" :favourite_food "orc"}]
|
||||
[user-id nil]])
|
||||
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (not (next-batch-num :redo user-id test-scope)))
|
||||
(is (= {:outputs [{:op "created", :table-id table-id :row {:id 1, :name "Snorkmaiden", :favourite_food "orc"}}]}
|
||||
(undo-via-api! user-id test-scope)))
|
||||
(is (= [[1 "Snorkmaiden" "orc"]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (next-batch-num :redo user-id test-scope))
|
||||
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 1, :name "Snorkmaiden", :favourite_food "pork"}}]}
|
||||
(undo-via-api! user-id test-scope)))
|
||||
(is (= [[1 "Snorkmaiden" "pork"]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (next-batch-num :redo user-id test-scope))
|
||||
(is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 1}}]}
|
||||
(undo-via-api! user-id test-scope)))
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (not (next-batch-num :undo user-id test-scope)))
|
||||
(is (next-batch-num :redo user-id test-scope))
|
||||
(is (= "Nothing to do" (undo-via-api! user-id test-scope)))
|
||||
|
||||
(is (not (next-batch-num :undo user-id test-scope)))
|
||||
(is (next-batch-num :redo user-id test-scope))
|
||||
(is (= {:outputs [{:op "created", :table-id table-id :row {:id 1, :name "Snorkmaiden", :favourite_food "pork"}}]}
|
||||
(redo-via-api! user-id test-scope)))
|
||||
(is (= [[1 "Snorkmaiden" "pork"]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (next-batch-num :redo user-id test-scope))
|
||||
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 1, :name "Snorkmaiden", :favourite_food "orc"}}]}
|
||||
(redo-via-api! user-id test-scope)))
|
||||
(is (= [[1 "Snorkmaiden" "orc"]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (next-batch-num :redo user-id test-scope))
|
||||
(is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 1}}]}
|
||||
(redo-via-api! user-id test-scope)))
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (not (next-batch-num :redo user-id test-scope)))
|
||||
(is (= "Nothing to do" (redo-via-api! user-id test-scope)))
|
||||
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (not (next-batch-num :redo user-id test-scope)))))))))
|
||||
|
||||
;; I'm OK reducing this scenario's scope once we have e2e tests.
|
||||
;; Until then, I think this is ideal.
|
||||
#_{:clj-kondo/ignore [:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests]}
|
||||
(deftest undo-redo-multi-user-single-table-single-record-integration-test
|
||||
(mt/with-empty-h2-app-db
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(testing "Multi-user chain"
|
||||
(with-open [table-ref (data-editing.tu/open-test-table! {:id [:int]
|
||||
:name [:text]
|
||||
:power [:int]}
|
||||
{:primary-key [:id]})]
|
||||
(let [table-id @table-ref
|
||||
user-1 (mt/user->id :crowberto)
|
||||
user-2 (mt/user->id :rasta)
|
||||
test-scope {:table-id table-id}]
|
||||
(data-editing.tu/toggle-data-editing-enabled! true)
|
||||
|
||||
;; NOTE: this test relies on the "conflicts even when different columns changed" semantics
|
||||
;; If we improve the semantics, we'll need to improve this test!
|
||||
|
||||
(write-sequence! table-id {:id 2} [[user-1 {:name "Moomintroll" :power 3}]
|
||||
[user-1 {:name "Moomintroll" :power 9001}]
|
||||
[user-2 {:name "Moominswole" :power 9001}]
|
||||
[user-1 nil]])
|
||||
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-2 test-scope))
|
||||
(is (not (next-batch-num :redo user-2 test-scope)))
|
||||
(is (= "Your previous change has a conflict with another edit" (undo-via-api! user-2 test-scope)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (not (next-batch-num :redo user-1 test-scope)))
|
||||
(is (= {:outputs [{:op "created", :table-id table-id, :row {:id 2, :name "Moominswole", :power 9001}}]}
|
||||
(undo-via-api! user-1 test-scope)))
|
||||
(is (= [[2 "Moominswole" 9001]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= "Your previous change has a conflict with another edit" (undo-via-api! user-1 test-scope)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (next-batch-num :undo user-2 test-scope))
|
||||
(is (not (next-batch-num :redo user-2 test-scope)))
|
||||
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 9001}}]}
|
||||
(undo-via-api! user-2 test-scope)))
|
||||
(is (= [[2 "Moomintroll" 9001]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 3}}]}
|
||||
(undo-via-api! user-1 test-scope)))
|
||||
(is (= [[2 "Moomintroll" 3]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 2}}]}
|
||||
(undo-via-api! user-1 test-scope)))
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (not (next-batch-num :undo user-1 test-scope)))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= "Nothing to do" (undo-via-api! user-1 test-scope)))
|
||||
|
||||
(is (not (next-batch-num :undo user-1 test-scope)))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= {:outputs [{:op "created", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 3}}]}
|
||||
(redo-via-api! user-1 test-scope)))
|
||||
(is (= [[2 "Moomintroll" 3]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 9001}}]}
|
||||
(redo-via-api! user-1 test-scope)))
|
||||
(is (= [[2 "Moomintroll" 9001]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moominswole", :power 9001}}]}
|
||||
(redo-via-api! user-2 test-scope)))
|
||||
(is (= [[2 "Moominswole" 9001]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 2}}]}
|
||||
(redo-via-api! user-1 test-scope)))
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (not (next-batch-num :redo user-1 test-scope)))
|
||||
(is (= "Nothing to do" (redo-via-api! user-1 test-scope)))
|
||||
|
||||
(is (next-batch-num :undo user-2 test-scope))
|
||||
(is (not (next-batch-num :redo user-2 test-scope)))
|
||||
(is (= "Nothing to do" (redo-via-api! user-2 test-scope)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (not (next-batch-num :redo user-1 test-scope)))))))))
|
||||
|
||||
(deftest undo-reverted-changes-integration-test
|
||||
(mt/with-empty-h2-app-db
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(testing "Reverted changes have their snapshots deleted when there are further changes"
|
||||
(with-open [table-ref (data-editing.tu/open-test-table! {:id [:int]
|
||||
:name [:text]
|
||||
:status [:text]}
|
||||
{:primary-key [:id]})]
|
||||
(let [table-id @table-ref
|
||||
user-id (mt/user->id :crowberto)
|
||||
test-scope {:table-id table-id}]
|
||||
(data-editing.tu/toggle-data-editing-enabled! true)
|
||||
|
||||
;; NOTE: this test relies on the "conflicts even when different columns changed" semantics
|
||||
;; If we improve the semantics, we'll need to improve this test!
|
||||
|
||||
(write-sequence! table-id {:id 1} [[user-id {:name "Too-ticky" :status "sitting"}]
|
||||
[user-id {:name "Too-tickley" :status "squirming"}]
|
||||
[user-id nil]])
|
||||
|
||||
(write-sequence! table-id {:id 2} [[user-id {:name "Toffle" :status "uncomfortable"}]
|
||||
[user-id {:name "Toffle" :status "comforted"}]
|
||||
[user-id nil]])
|
||||
|
||||
(create-row! user-id table-id {:id 1, :name "Too-tickley", :status "squirming"})
|
||||
|
||||
(create-row! user-id table-id {:id 2, :name "Toggle", :status "restored"})
|
||||
(delete-row! user-id table-id {:id 2})
|
||||
|
||||
(is (= [[1 "Too-tickley" "squirming"]] (table-rows table-id)))
|
||||
|
||||
(is (nil? (next-batch-num :redo user-id test-scope)))
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
|
||||
(undo-via-api! user-id test-scope)
|
||||
(is (= [[1 "Too-tickley" "squirming"]
|
||||
[2 "Toggle" "restored"]] (table-rows table-id)))
|
||||
|
||||
(redo-via-api! user-id test-scope)
|
||||
(is (= [[1 "Too-tickley" "squirming"]] (table-rows table-id)))))))))
|
||||
|
||||
(defn- count-batches [& [where]]
|
||||
(val (ffirst (t2/query {:select [[[:count [:distinct :batch_num]] :cnt]]
|
||||
:from [(t2/table-name :model/Undo)]
|
||||
:where (or where true)}))))
|
||||
|
||||
(deftest prune-snapshots-test
|
||||
(mt/with-empty-h2-app-db
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(testing "We delete older batches when they exceed our retention limits"
|
||||
(with-open [table-ref-1 (data-editing.tu/open-test-table! {:id [:int]} {:primary-key [:id]})
|
||||
table-ref-2 (data-editing.tu/open-test-table! {:id [:int]} {:primary-key [:id]})]
|
||||
(let [table-1 @table-ref-1
|
||||
table-2 @table-ref-2
|
||||
user-1 (mt/user->id :crowberto)
|
||||
user-2 (mt/user->id :rasta)]
|
||||
(data-editing.tu/toggle-data-editing-enabled! true)
|
||||
|
||||
(testing "Total rows"
|
||||
(with-redefs [undo/retention-total-rows 17]
|
||||
(dotimes [i 25]
|
||||
(undo/track-change! user-1
|
||||
{:table-id table-1}
|
||||
{table-1
|
||||
{{:id 1} [(if (even? i) {} nil)
|
||||
(if (even? i) nil {})]
|
||||
{:id 2} [(if (odd? i) {} nil)
|
||||
(if (odd? i) nil {})]}}))
|
||||
|
||||
(is (= 16 (t2/count :model/Undo)))
|
||||
(is (= 8 (count-batches)))))
|
||||
|
||||
(testing "Total batches"
|
||||
(with-redefs [undo/retention-total-batches 15]
|
||||
(dotimes [i 25]
|
||||
(undo/track-change! user-1
|
||||
{:table-id table-1}
|
||||
{table-1
|
||||
{{:id 1} [(if (even? i) {} nil)
|
||||
(if (even? i) nil {})]
|
||||
{:id 2} [(if (odd? i) {} nil)
|
||||
(if (odd? i) nil {})]}}))
|
||||
|
||||
(is (= 30 (t2/count :model/Undo)))
|
||||
(is (= 15 (count-batches)))))
|
||||
|
||||
(testing "User id"
|
||||
(with-redefs [undo/retention-batches-per-user 5]
|
||||
(dotimes [i 25]
|
||||
;; just toggle existence
|
||||
(undo/track-change! (if (zero? (mod i 3)) user-1 user-2)
|
||||
{:table-id table-1}
|
||||
{table-1
|
||||
{{:id 1} [(if (even? i) {} nil)
|
||||
(if (even? i) nil {})]
|
||||
{:id 2} [(if (odd? i) {} nil)
|
||||
(if (odd? i) nil {})]}}))
|
||||
|
||||
(is (= 20 (t2/count :model/Undo)))
|
||||
(is (= 10 (count-batches)))
|
||||
(is (= 5 (count-batches [:= :user_id user-1])))
|
||||
(is (= 5 (count-batches [:= :user_id user-2])))))
|
||||
|
||||
(testing "Scope"
|
||||
(t2/delete! :model/Undo)
|
||||
(with-redefs [undo/retention-batches-per-scope 9]
|
||||
(dotimes [i 35]
|
||||
;; just toggle existence
|
||||
(let [table-id (if (zero? (mod i 5)) table-1 table-2)]
|
||||
(undo/track-change! user-1
|
||||
{:table-id table-id}
|
||||
{table-id
|
||||
{{:id 1} [(if (even? i) {} nil)
|
||||
(if (even? i) nil {})]
|
||||
{:id 2} [(if (odd? i) {} nil)
|
||||
(if (odd? i) nil {})]}})))
|
||||
|
||||
(is (= 32 (t2/count :model/Undo)))
|
||||
(is (= 16 (count-batches)))
|
||||
(is (= 7 (count-batches [:= :table_id table-1])))
|
||||
(is (= 9 (count-batches [:= :table_id table-2])))))
|
||||
|
||||
(testing "A haphazard mix"
|
||||
(with-redefs [undo/retention-total-rows 17
|
||||
undo/retention-total-batches 21
|
||||
undo/retention-batches-per-user 5
|
||||
undo/retention-batches-per-scope 9]
|
||||
|
||||
(dotimes [i 25]
|
||||
;; just toggle existence
|
||||
(undo/track-change! (if (zero? (mod i 3)) user-1 user-2)
|
||||
{:dashboard-id 1}
|
||||
{(if (zero? (mod i 5)) table-1 table-2)
|
||||
{{:id 1} [(if (even? i) {} nil)
|
||||
(if (even? i) nil {})]
|
||||
{:id 2} [(if (odd? i) {} nil)
|
||||
(if (odd? i) nil {})]}}))
|
||||
|
||||
(is (= 16 (t2/count :model/Undo)))
|
||||
(is (= 8 (count-batches)))
|
||||
(is (= 3 (count-batches [:= :user_id user-1])))
|
||||
(is (= 5 (count-batches [:= :user_id user-2])))
|
||||
(is (= 1 (count-batches [:= :table_id table-1])))
|
||||
(is (= 7 (count-batches [:= :table_id table-2])))))))))))
|
||||
@@ -0,0 +1 @@
|
||||
(ns metabase-enterprise.data-editing.util)
|
||||
@@ -53,6 +53,7 @@
|
||||
:model/Notification
|
||||
:model/NotificationCard
|
||||
:model/NotificationSubscription
|
||||
:model/NotificationSystemEvent
|
||||
:model/NotificationHandler
|
||||
:model/NotificationRecipient
|
||||
:model/ParameterCard
|
||||
@@ -78,6 +79,7 @@
|
||||
:model/Session
|
||||
:model/TaskHistory
|
||||
:model/TimelineEvent
|
||||
:model/Undo
|
||||
:model/User
|
||||
:model/UserParameterValue
|
||||
:model/UserKeyValue
|
||||
|
||||
@@ -14,20 +14,18 @@
|
||||
site-name "Metabase Test"
|
||||
site-url "https://metabase.com"
|
||||
admin-email "ngoc@metabase.com"]
|
||||
(is (= {:payload_type :notification/system-event
|
||||
:payload {:event_info {:foo :bar}
|
||||
:event_topic :event/user-joined
|
||||
:custom {}}
|
||||
:context {:application_name "Metabase Test"
|
||||
:application_color "#509EE3"
|
||||
:application_logo_url "http://static.metabase.com/email_logo.png"
|
||||
:include_branding false
|
||||
:site_name "Metabase Test"
|
||||
:site_url "https://metabase.com"
|
||||
:admin_email "ngoc@metabase.com"
|
||||
:style {:button true}}
|
||||
:creator nil}
|
||||
(is (= {:payload {:foo :bar
|
||||
:event_name :event/user-joined}
|
||||
:context {:application_name "Metabase Test"
|
||||
:application_color "#509EE3"
|
||||
:application_logo_url "http://static.metabase.com/email_logo.png"
|
||||
:site_name "Metabase Test"
|
||||
:site_url "https://metabase.com"
|
||||
:admin_email "ngoc@metabase.com"
|
||||
:include_branding false
|
||||
:style {:button true}}
|
||||
:creator nil}
|
||||
(-> (notification.payload/notification-payload {:payload_type :notification/system-event
|
||||
:payload {:event_topic :event/user-joined
|
||||
:event_info {:foo :bar}}})
|
||||
:payload {:event_name :event/user-joined}
|
||||
:event_info {:foo :bar}})
|
||||
(update-in [:context :style :button] string?))))))))
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
:handlers [@notification.tu/default-email-handler]}]
|
||||
(let [send-alert-by-user! (fn [user-kw]
|
||||
(-> (assoc notification :creator_id (mt/user->id user-kw))
|
||||
notification.payload/payload
|
||||
notification.payload/notification-payload
|
||||
:payload
|
||||
:card_part
|
||||
:result
|
||||
:data
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { EditableTableActionsDisplaySettings } from "metabase-types/api";
|
||||
|
||||
export const useBuiltInActions = (
|
||||
actionsVizSettings: EditableTableActionsDisplaySettings[] | undefined,
|
||||
) => {
|
||||
return useMemo(() => {
|
||||
let hasCreateAction = false;
|
||||
let hasDeleteAction = false;
|
||||
|
||||
actionsVizSettings?.forEach((action) => {
|
||||
if (action.actionId === "data-grid.row/create" && action.enabled) {
|
||||
hasCreateAction = true;
|
||||
}
|
||||
if (action.actionId === "data-grid.row/delete" && action.enabled) {
|
||||
hasDeleteAction = true;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
hasCreateAction,
|
||||
hasDeleteAction,
|
||||
};
|
||||
}, [actionsVizSettings]);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
.root {
|
||||
/* position: relative; */
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: calc(100% - 5.5rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rootPreviewOpen {
|
||||
grid-template-columns: 50% 50%;
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
|
||||
.rootNoPreview {
|
||||
grid-template-columns: 100%;
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modalBody {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
position: relative;
|
||||
z-index: 1002;
|
||||
background-color: var(--mb-color-bg-white);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
position: relative;
|
||||
z-index: 1003;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.modalClose {
|
||||
z-index: 1004;
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
import { skipToken } from "@reduxjs/toolkit/query";
|
||||
import cx from "classnames";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
import { isEqual } from "underscore";
|
||||
|
||||
import {
|
||||
useCreateNotificationMutation,
|
||||
useGetChannelInfoQuery,
|
||||
useGetDefaultNotificationTemplateQuery,
|
||||
useGetNotificationPayloadExampleQuery,
|
||||
useListChannelsQuery,
|
||||
usePreviewNotificationTemplateQuery,
|
||||
useUpdateNotificationMutation,
|
||||
} from "metabase/api";
|
||||
import { useEscapeToCloseModal } from "metabase/common/hooks/use-escape-to-close-modal";
|
||||
import ButtonWithStatus from "metabase/components/ButtonWithStatus";
|
||||
import { ConfirmModal } from "metabase/components/ConfirmModal";
|
||||
import { AutoWidthSelect } from "metabase/components/Schedule/AutoWidthSelect";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { alertIsValid } from "metabase/lib/notifications";
|
||||
import {
|
||||
getHasConfiguredAnyChannel,
|
||||
getHasConfiguredEmailChannel,
|
||||
} from "metabase/lib/pulse";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { ChannelSetupModal } from "metabase/notifications/modals/shared/ChannelSetupModal";
|
||||
import { AlertModalSettingsBlock } from "metabase/notifications/modals/shared/components/AlertModalSettingsBlock/AlertModalSettingsBlock";
|
||||
import { AlertTriggerIcon } from "metabase/notifications/modals/shared/components/AlertTriggerIcon";
|
||||
import { NotificationChannelsPicker } from "metabase/notifications/modals/shared/components/NotificationChannels/NotificationChannelsPicker/NotificationChannelsPicker";
|
||||
import { getDefaultTableNotificationRequest } from "metabase/notifications/utils";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
import { canAccessSettings, getUser } from "metabase/selectors/user";
|
||||
import { Button, Flex, Modal, Stack, rem } from "metabase/ui";
|
||||
import type {
|
||||
ChannelApiResponse,
|
||||
ChannelTemplate,
|
||||
ConditionalAlertExpression,
|
||||
CreateTableNotificationRequest,
|
||||
NotificationChannelType,
|
||||
NotificationHandler,
|
||||
NotificationTriggerEvent,
|
||||
TableId,
|
||||
TableNotification,
|
||||
UpdateTableNotificationRequest,
|
||||
User,
|
||||
UserId,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import S from "./CreateOrEditTableNotificationModal.module.css";
|
||||
import { AlertConditionBuilder } from "./components/AlertConditionBuilder/AlertConditionBuilder";
|
||||
import { PreviewTemplatePanel } from "./components/PreviewTemplatePanel/PreviewTemplatePanel";
|
||||
|
||||
type TableNotificationTriggerOption = {
|
||||
value: {
|
||||
eventName: NotificationTriggerEvent;
|
||||
};
|
||||
get label(): string;
|
||||
};
|
||||
|
||||
const NOTIFICATION_TRIGGER_OPTIONS_MAP: Record<
|
||||
NotificationTriggerEvent,
|
||||
TableNotificationTriggerOption
|
||||
> = {
|
||||
"event/row.created": {
|
||||
value: {
|
||||
eventName: "event/row.created",
|
||||
},
|
||||
get label() {
|
||||
return t`When new records are created`;
|
||||
},
|
||||
},
|
||||
"event/row.updated": {
|
||||
value: {
|
||||
eventName: "event/row.updated",
|
||||
},
|
||||
get label() {
|
||||
return t`When any cell changes it's value`;
|
||||
},
|
||||
},
|
||||
"event/row.deleted": {
|
||||
value: {
|
||||
eventName: "event/row.deleted",
|
||||
},
|
||||
get label() {
|
||||
return t`When records are deleted`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const triggerOptions = (
|
||||
[
|
||||
"event/row.created",
|
||||
"event/row.updated",
|
||||
"event/row.deleted",
|
||||
] as NotificationTriggerEvent[]
|
||||
).map((event) => ({
|
||||
value: event,
|
||||
label: NOTIFICATION_TRIGGER_OPTIONS_MAP[event].label,
|
||||
option: NOTIFICATION_TRIGGER_OPTIONS_MAP[event],
|
||||
}));
|
||||
|
||||
interface CreateOrEditTableNotificationModalProps {
|
||||
tableId: TableId;
|
||||
notification?: TableNotification | null;
|
||||
onNotificationCreated?: () => void;
|
||||
onNotificationUpdated?: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Custom hooks to encapsulate logic from the modal.
|
||||
// Fetch necessary data.
|
||||
const useNotificationTemplates = (
|
||||
requestBody:
|
||||
| CreateTableNotificationRequest
|
||||
| UpdateTableNotificationRequest
|
||||
| null,
|
||||
user: { id: UserId } | null,
|
||||
) => {
|
||||
const channelTypes = useMemo(
|
||||
() =>
|
||||
requestBody?.handlers
|
||||
? requestBody.handlers
|
||||
.map((h) => h.channel_type)
|
||||
.filter((type): type is NotificationChannelType => !!type)
|
||||
: [],
|
||||
[requestBody?.handlers],
|
||||
);
|
||||
|
||||
const { data: defaultTemplates } = useGetDefaultNotificationTemplateQuery(
|
||||
requestBody?.payload_type &&
|
||||
requestBody?.payload?.event_name &&
|
||||
user &&
|
||||
channelTypes.length > 0
|
||||
? {
|
||||
notification: {
|
||||
payload_type: requestBody.payload_type,
|
||||
payload: requestBody.payload,
|
||||
},
|
||||
channel_types: channelTypes,
|
||||
}
|
||||
: skipToken,
|
||||
{
|
||||
skip: !requestBody?.payload?.event_name || channelTypes.length === 0,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: templateContext } = useGetNotificationPayloadExampleQuery(
|
||||
requestBody?.payload_type &&
|
||||
requestBody?.payload?.event_name &&
|
||||
user &&
|
||||
channelTypes.length > 0
|
||||
? {
|
||||
notification: {
|
||||
payload_type: requestBody.payload_type,
|
||||
payload: requestBody.payload,
|
||||
creator_id: user.id,
|
||||
},
|
||||
channel_types: channelTypes,
|
||||
}
|
||||
: skipToken,
|
||||
{
|
||||
skip: !requestBody?.payload?.event_name || channelTypes.length === 0,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
defaultTemplates,
|
||||
templateContext,
|
||||
};
|
||||
};
|
||||
|
||||
const useNotificationFormState = (
|
||||
tableId: TableId,
|
||||
notification: TableNotification | null,
|
||||
user: User | null,
|
||||
channelSpec: ChannelApiResponse | undefined,
|
||||
userCanAccessSettings: boolean,
|
||||
) => {
|
||||
const { data: hookChannels } = useListChannelsQuery();
|
||||
const [requestBody, setRequestBody] = useState<
|
||||
CreateTableNotificationRequest | UpdateTableNotificationRequest | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure all required data is loaded before initializing
|
||||
if (!requestBody && user && channelSpec && hookChannels) {
|
||||
const eventName: NotificationTriggerEvent =
|
||||
notification?.payload.event_name ?? "event/row.created";
|
||||
const initialRequestBody = getDefaultTableNotificationRequest({
|
||||
tableId,
|
||||
eventName,
|
||||
currentUserId: user.id,
|
||||
channelSpec,
|
||||
hookChannels,
|
||||
userCanAccessSettings,
|
||||
});
|
||||
// If editing, merge existing notification data
|
||||
if (notification) {
|
||||
setRequestBody({ ...initialRequestBody, ...notification });
|
||||
} else {
|
||||
setRequestBody(initialRequestBody);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
tableId,
|
||||
notification,
|
||||
requestBody,
|
||||
user,
|
||||
channelSpec,
|
||||
hookChannels,
|
||||
userCanAccessSettings,
|
||||
]);
|
||||
|
||||
const handleEventNameChange = useCallback((value: string | null) => {
|
||||
if (value) {
|
||||
const selectedOption =
|
||||
NOTIFICATION_TRIGGER_OPTIONS_MAP[value as NotificationTriggerEvent];
|
||||
if (selectedOption) {
|
||||
setRequestBody((requestBody) =>
|
||||
requestBody
|
||||
? {
|
||||
...requestBody,
|
||||
payload: {
|
||||
...requestBody.payload,
|
||||
event_name: selectedOption.value.eventName,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChannelHandlersChange = useCallback(
|
||||
(newHandlers: NotificationHandler[]) => {
|
||||
setRequestBody((requestBody) =>
|
||||
requestBody
|
||||
? {
|
||||
...requestBody,
|
||||
handlers: newHandlers,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleConditionChange = useCallback(
|
||||
(newCondition: ConditionalAlertExpression) => {
|
||||
setRequestBody((requestBody) =>
|
||||
requestBody
|
||||
? {
|
||||
...requestBody,
|
||||
condition: newCondition,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
requestBody,
|
||||
handleEventNameChange,
|
||||
handleChannelHandlersChange,
|
||||
handleConditionChange,
|
||||
};
|
||||
};
|
||||
|
||||
// Manage the preview panel state and data fetching
|
||||
const useNotificationTemplatePreview = (
|
||||
requestBody:
|
||||
| CreateTableNotificationRequest
|
||||
| UpdateTableNotificationRequest
|
||||
| null,
|
||||
defaultTemplates:
|
||||
| Record<NotificationChannelType, ChannelTemplate>
|
||||
| undefined,
|
||||
) => {
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [channelType, setChannelType] =
|
||||
useState<NotificationChannelType | null>(null);
|
||||
const previewRequest = useMemo(() => {
|
||||
if (!requestBody || !channelType) {
|
||||
return null;
|
||||
}
|
||||
const handler = requestBody.handlers.find(
|
||||
(h) => h.channel_type === channelType && h.template,
|
||||
);
|
||||
const currentTemplate =
|
||||
handler?.template || defaultTemplates?.[channelType];
|
||||
if (!currentTemplate) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
notification: requestBody,
|
||||
template: currentTemplate,
|
||||
};
|
||||
}, [requestBody, defaultTemplates, channelType]);
|
||||
|
||||
const {
|
||||
data: previewData,
|
||||
isLoading: isPreviewLoading,
|
||||
error: previewError,
|
||||
} = usePreviewNotificationTemplateQuery(previewRequest ?? skipToken, {
|
||||
skip: !previewRequest,
|
||||
});
|
||||
const handlePreviewClick = useCallback(
|
||||
(channelType: NotificationChannelType) => {
|
||||
if (previewOpen) {
|
||||
setPreviewOpen(false);
|
||||
return;
|
||||
}
|
||||
const handler = requestBody?.handlers.find(
|
||||
(h) => h.channel_type === channelType && h.template,
|
||||
);
|
||||
const currentTemplate =
|
||||
handler?.template || defaultTemplates?.[channelType];
|
||||
|
||||
if (currentTemplate) {
|
||||
setPreviewOpen(true);
|
||||
setChannelType(channelType);
|
||||
}
|
||||
},
|
||||
[requestBody, defaultTemplates, previewOpen],
|
||||
);
|
||||
|
||||
const handlePreviewClose = useCallback(() => {
|
||||
setPreviewOpen(false);
|
||||
setChannelType(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
previewOpen,
|
||||
previewData,
|
||||
isPreviewLoading,
|
||||
previewError,
|
||||
handlePreviewClick,
|
||||
handlePreviewClose,
|
||||
};
|
||||
};
|
||||
|
||||
// Manage saving/updating and the confirmation modal
|
||||
const useNotificationSave = (
|
||||
requestBody:
|
||||
| CreateTableNotificationRequest
|
||||
| UpdateTableNotificationRequest
|
||||
| null,
|
||||
notification: TableNotification | null,
|
||||
isEditMode: boolean,
|
||||
onNotificationCreated?: () => void,
|
||||
onNotificationUpdated?: () => void,
|
||||
) => {
|
||||
const dispatch = useDispatch();
|
||||
const [createNotification] = useCreateNotificationMutation();
|
||||
const [updateNotification] = useUpdateNotificationMutation();
|
||||
|
||||
const hasChanges = useMemo(
|
||||
() => notification && !isEqual(requestBody, notification),
|
||||
[requestBody, notification],
|
||||
);
|
||||
|
||||
const onCreateOrEditAlert = useCallback(async () => {
|
||||
if (!requestBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEditMode && notification) {
|
||||
await updateNotification({
|
||||
...requestBody,
|
||||
id: notification.id,
|
||||
} as UpdateTableNotificationRequest);
|
||||
dispatch(
|
||||
addUndo({
|
||||
message: isEditMode ? t`Alert updated.` : t`Alert created.`,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isEditMode) {
|
||||
onNotificationUpdated?.();
|
||||
} else {
|
||||
onNotificationCreated?.();
|
||||
}
|
||||
} else {
|
||||
await createNotification(requestBody as CreateTableNotificationRequest);
|
||||
dispatch(
|
||||
addUndo({
|
||||
message: isEditMode ? t`Alert updated.` : t`Alert created.`,
|
||||
}),
|
||||
);
|
||||
onNotificationCreated?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save notification:", error);
|
||||
throw error;
|
||||
}
|
||||
}, [
|
||||
requestBody,
|
||||
isEditMode,
|
||||
notification,
|
||||
updateNotification,
|
||||
createNotification,
|
||||
dispatch,
|
||||
onNotificationCreated,
|
||||
onNotificationUpdated,
|
||||
]);
|
||||
|
||||
return {
|
||||
hasChanges,
|
||||
onCreateOrEditAlert,
|
||||
};
|
||||
};
|
||||
|
||||
export const CreateOrEditTableNotificationModal = ({
|
||||
tableId,
|
||||
notification,
|
||||
onNotificationCreated,
|
||||
onNotificationUpdated,
|
||||
onClose,
|
||||
}: CreateOrEditTableNotificationModalProps) => {
|
||||
const user = useSelector(getUser);
|
||||
const userCanAccessSettings = useSelector(canAccessSettings);
|
||||
const isEditMode = !!notification?.id;
|
||||
|
||||
const { data: channelSpec, isLoading: isLoadingChannelInfo } =
|
||||
useGetChannelInfoQuery();
|
||||
|
||||
const {
|
||||
requestBody,
|
||||
handleEventNameChange,
|
||||
handleChannelHandlersChange,
|
||||
handleConditionChange,
|
||||
} = useNotificationFormState(
|
||||
tableId,
|
||||
notification ?? null,
|
||||
user,
|
||||
channelSpec,
|
||||
userCanAccessSettings,
|
||||
);
|
||||
|
||||
const { defaultTemplates, templateContext } = useNotificationTemplates(
|
||||
requestBody,
|
||||
user,
|
||||
);
|
||||
|
||||
const {
|
||||
previewOpen,
|
||||
previewData,
|
||||
isPreviewLoading,
|
||||
previewError,
|
||||
handlePreviewClick,
|
||||
handlePreviewClose,
|
||||
} = useNotificationTemplatePreview(requestBody, defaultTemplates);
|
||||
|
||||
const { hasChanges, onCreateOrEditAlert } = useNotificationSave(
|
||||
requestBody,
|
||||
notification ?? null,
|
||||
isEditMode,
|
||||
onNotificationCreated,
|
||||
onNotificationUpdated,
|
||||
);
|
||||
|
||||
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
||||
const handleCloseAttempt = useCallback(() => {
|
||||
if (isConfirmModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
setIsConfirmModalOpen(true);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [hasChanges, onClose, isConfirmModalOpen]);
|
||||
|
||||
const handleConfirmDiscard = useCallback(() => {
|
||||
setIsConfirmModalOpen(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEscapeToCloseModal(handleCloseAttempt, { capture: false });
|
||||
|
||||
const hasConfiguredAnyChannel = useMemo(
|
||||
() => getHasConfiguredAnyChannel(channelSpec),
|
||||
[channelSpec],
|
||||
);
|
||||
const hasConfiguredEmailChannel = useMemo(
|
||||
() => getHasConfiguredEmailChannel(channelSpec),
|
||||
[channelSpec],
|
||||
);
|
||||
|
||||
const channelRequirementsMet = userCanAccessSettings
|
||||
? hasConfiguredAnyChannel
|
||||
: hasConfiguredEmailChannel;
|
||||
|
||||
if (!isLoadingChannelInfo && channelSpec && !channelRequirementsMet) {
|
||||
return (
|
||||
<ChannelSetupModal
|
||||
userCanAccessSettings={userCanAccessSettings}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!requestBody || !requestBody.payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
data-testid="table-notification-create"
|
||||
opened
|
||||
size={previewOpen ? rem(1360) : rem(680)}
|
||||
onClose={handleCloseAttempt}
|
||||
padding="2.5rem"
|
||||
closeOnEscape={false}
|
||||
title={isEditMode ? t`Edit alert` : t`New alert`}
|
||||
maw="90%"
|
||||
classNames={{
|
||||
header: S.modalHeader,
|
||||
content: S.modalContent,
|
||||
body: S.modalBody,
|
||||
close: S.modalClose,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
S.root,
|
||||
previewOpen ? S.rootPreviewOpen : S.rootNoPreview,
|
||||
)}
|
||||
>
|
||||
<Stack gap="xl" mt="1.5rem" mb="2rem" px="2.5rem">
|
||||
<AlertModalSettingsBlock
|
||||
title={t`What do you want to be notified about?`}
|
||||
>
|
||||
<Flex gap="lg" align="center">
|
||||
<AlertTriggerIcon />
|
||||
<AutoWidthSelect
|
||||
data-testid="notification-event-select"
|
||||
data={triggerOptions}
|
||||
value={requestBody.payload.event_name}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
handleEventNameChange(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex mt="md">
|
||||
<AlertConditionBuilder
|
||||
tableId={tableId}
|
||||
eventType={requestBody?.payload.event_name}
|
||||
onChange={handleConditionChange}
|
||||
initialExpression={notification?.condition}
|
||||
/>
|
||||
</Flex>
|
||||
</AlertModalSettingsBlock>
|
||||
<AlertModalSettingsBlock
|
||||
title={t`Where do you want to send the alerts?`}
|
||||
contentProps={{
|
||||
style: {
|
||||
// To display autocomplete popovers properly.
|
||||
overflow: "visible",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<NotificationChannelsPicker
|
||||
enableTemplates={{ email: true, slack: true }}
|
||||
notificationHandlers={requestBody.handlers}
|
||||
channels={channelSpec ? channelSpec.channels : undefined}
|
||||
onChange={handleChannelHandlersChange}
|
||||
templateContext={templateContext}
|
||||
defaultTemplates={defaultTemplates}
|
||||
onPreviewClick={handlePreviewClick}
|
||||
isPreviewOpen={previewOpen}
|
||||
getInvalidRecipientText={(domains) =>
|
||||
t`You're only allowed to email alerts to addresses ending in ${domains}`
|
||||
}
|
||||
/>
|
||||
</AlertModalSettingsBlock>
|
||||
</Stack>
|
||||
|
||||
{previewOpen && (
|
||||
<PreviewTemplatePanel
|
||||
onClose={handlePreviewClose}
|
||||
isLoading={isPreviewLoading}
|
||||
error={previewError}
|
||||
previewContent={previewData?.rendered}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Flex
|
||||
h="5.5rem"
|
||||
justify="flex-end"
|
||||
px="2.5rem"
|
||||
py="lg"
|
||||
className={cx(CS.borderTop, S.modalFooter)}
|
||||
>
|
||||
<Button
|
||||
onClick={handleCloseAttempt}
|
||||
className={CS.mr2}
|
||||
>{t`Cancel`}</Button>
|
||||
<ButtonWithStatus
|
||||
titleForState={{
|
||||
default: isEditMode && hasChanges ? t`Save changes` : t`Done`,
|
||||
}}
|
||||
disabled={
|
||||
!alertIsValid(requestBody.handlers, channelSpec) ||
|
||||
(isEditMode && !hasChanges)
|
||||
}
|
||||
onClickOperation={onCreateOrEditAlert}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{hasChanges && (
|
||||
<ConfirmModal
|
||||
size="md"
|
||||
opened={isConfirmModalOpen}
|
||||
title={t`Discard unsaved changes?`}
|
||||
message={t`You have unsaved changes. Are you sure you want to discard them?`}
|
||||
onClose={() => setIsConfirmModalOpen(false)}
|
||||
onConfirm={handleConfirmDiscard}
|
||||
confirmButtonText={t`Discard`}
|
||||
closeButtonText={t`Cancel`}
|
||||
closeOnEscape={false}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,450 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { setupEnterprisePlugins } from "__support__/enterprise";
|
||||
import {
|
||||
setupNotificationChannelsEndpoints,
|
||||
setupUserRecipientsEndpoint,
|
||||
} from "__support__/server-mocks";
|
||||
import { setupWebhookChannelsEndpoint } from "__support__/server-mocks/channel";
|
||||
import { mockSettings } from "__support__/settings";
|
||||
import { createMockEntitiesState } from "__support__/store";
|
||||
import { renderWithProviders, screen, waitFor } from "__support__/ui";
|
||||
import type { UserWithApplicationPermissions } from "metabase/plugins";
|
||||
import type {
|
||||
ChannelApiResponse,
|
||||
NotificationChannel,
|
||||
NotificationTriggerEvent,
|
||||
TableNotification,
|
||||
} from "metabase-types/api";
|
||||
import {
|
||||
createMockTable,
|
||||
createMockTokenFeatures,
|
||||
createMockUser,
|
||||
} from "metabase-types/api/mocks";
|
||||
import { createMockChannel } from "metabase-types/api/mocks/channel";
|
||||
import {
|
||||
createMockNotificationHandlerEmail,
|
||||
createMockNotificationRecipientUser,
|
||||
} from "metabase-types/api/mocks/notification";
|
||||
import { createSampleDatabase } from "metabase-types/api/mocks/presets";
|
||||
|
||||
import { CreateOrEditTableNotificationModal } from "./CreateOrEditTableNotificationModal";
|
||||
|
||||
describe("CreateOrEditTableNotificationModal", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.post("path:/api/notification/payload", { body: { id: 123 } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should display first available channel by default - Email", async () => {
|
||||
setup({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("table-notification-create"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([{ isAdmin: true }, { isAdmin: false, userCanAccessSettings: true }])(
|
||||
"should display first available channel by default - Slack %p",
|
||||
async (setupConfig) => {
|
||||
setup({
|
||||
isEmailSetup: false,
|
||||
isSlackSetup: true,
|
||||
...setupConfig,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("table-notification-create"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Email")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Slack")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ isAdmin: true }, { isAdmin: false, userCanAccessSettings: true }])(
|
||||
"should display first available channel by default - Webhook %p",
|
||||
async (setupConfig) => {
|
||||
const mockWebhook = createMockChannel();
|
||||
setup({
|
||||
isEmailSetup: false,
|
||||
isHttpSetup: true,
|
||||
webhooksResult: [mockWebhook],
|
||||
...setupConfig,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("table-notification-create"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Email")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(mockWebhook.name)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("should not show channels if user does not have permissions", async () => {
|
||||
const mockWebhook = createMockChannel();
|
||||
setup({
|
||||
isEmailSetup: false,
|
||||
isSlackSetup: true,
|
||||
isHttpSetup: true,
|
||||
webhooksResult: [mockWebhook],
|
||||
userCanAccessSettings: false,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("alerts-channel-setup-modal"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("table-notification-create"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show set up channels model for non-admin users with access setting permission", async () => {
|
||||
setup({
|
||||
isEmailSetup: false,
|
||||
isSlackSetup: false,
|
||||
isHttpSetup: false,
|
||||
userCanAccessSettings: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("alerts-channel-setup-modal"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId("alerts-channel-create-webhook"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show row created event as the default trigger", async () => {
|
||||
setup({
|
||||
isAdmin: true,
|
||||
isEmailSetup: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that the correct option is selected (UI displays the label, not the value)
|
||||
const triggerSelect = screen.getByTestId("notification-event-select");
|
||||
expect(triggerSelect).toHaveValue("When new records are created");
|
||||
});
|
||||
|
||||
it("should show the correct event options for table notifications", async () => {
|
||||
setup({
|
||||
isAdmin: true,
|
||||
isEmailSetup: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the event select to open the dropdown
|
||||
const triggerSelect = screen.getByTestId("notification-event-select");
|
||||
await userEvent.click(triggerSelect);
|
||||
|
||||
// Verify all event options are available
|
||||
expect(
|
||||
screen.getByRole("option", { name: /When new records are created/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: /When any cell changes it's value/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: /When records are deleted/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show notification data when in edit mode", async () => {
|
||||
// Create a custom notification with 'row updated' event
|
||||
const mockNotification = createMockTableNotification({
|
||||
event_name: "event/row.updated",
|
||||
});
|
||||
|
||||
setup({
|
||||
isAdmin: true,
|
||||
isEmailSetup: true,
|
||||
editingNotification: mockNotification,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show "Edit alert" instead of "New alert"
|
||||
expect(screen.getByText("Edit alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify the correct event is selected (UI displays the label, not the value)
|
||||
const triggerSelect = screen.getByTestId("notification-event-select");
|
||||
expect(triggerSelect).toHaveValue("When any cell changes it's value");
|
||||
});
|
||||
|
||||
it("should create a new notification with default settings", async () => {
|
||||
// Setup fetchMock for API call
|
||||
fetchMock.postOnce("path:/api/notification", { body: { id: 123 } });
|
||||
const onNotificationCreatedMock = jest.fn();
|
||||
|
||||
setup({
|
||||
isAdmin: true,
|
||||
isEmailSetup: true,
|
||||
onNotificationCreatedMock,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click save button without changing anything
|
||||
const saveButton = screen.getByRole("button", { name: /done/i });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
// Verify the API was called with the default settings
|
||||
const calls = fetchMock.calls("path:/api/notification");
|
||||
expect(calls.length).toBe(1);
|
||||
|
||||
const requestBody = await calls[0][1]?.body;
|
||||
const parsedBody = JSON.parse(requestBody as string);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(parsedBody.payload.event_name).toBe("event/row.created");
|
||||
});
|
||||
|
||||
// Verify it has the default table_id
|
||||
expect(parsedBody.payload.table_id).toBe(42);
|
||||
|
||||
// Verify it has at least one handler
|
||||
expect(parsedBody.handlers.length).toBeGreaterThan(0);
|
||||
|
||||
expect(onNotificationCreatedMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should change notification event and create new notification", async () => {
|
||||
// Setup fetchMock for API call
|
||||
fetchMock.postOnce("path:/api/notification", { body: { id: 123 } });
|
||||
const onNotificationCreatedMock = jest.fn();
|
||||
|
||||
setup({
|
||||
isAdmin: true,
|
||||
isEmailSetup: true,
|
||||
onNotificationCreatedMock,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change the event to "row updated"
|
||||
const triggerSelect = screen.getByTestId("notification-event-select");
|
||||
await userEvent.click(triggerSelect);
|
||||
await userEvent.click(
|
||||
screen.getByRole("option", { name: /When any cell changes it's value/i }),
|
||||
);
|
||||
|
||||
// Click save button
|
||||
const saveButton = screen.getByRole("button", { name: /done/i });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
// Verify the API was called with the updated event
|
||||
const calls = fetchMock.calls("path:/api/notification");
|
||||
expect(calls.length).toBe(1);
|
||||
|
||||
const requestBody = await calls[0][1]?.body;
|
||||
const parsedBody = JSON.parse(requestBody as string);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(parsedBody.payload.event_name).toBe("event/row.updated");
|
||||
});
|
||||
|
||||
// Additional assertions...
|
||||
});
|
||||
|
||||
it("should update an existing notification when in edit mode", async () => {
|
||||
const notificationId = 42;
|
||||
// Setup fetchMock for API call
|
||||
fetchMock.putOnce(`path:/api/notification/${notificationId}`, {
|
||||
body: { id: notificationId },
|
||||
});
|
||||
|
||||
const onNotificationUpdatedMock = jest.fn();
|
||||
|
||||
// Create a notification with 'row created' event
|
||||
const mockNotification = createMockTableNotification({
|
||||
id: notificationId,
|
||||
event_name: "event/row.created",
|
||||
});
|
||||
|
||||
setup({
|
||||
isAdmin: true,
|
||||
isEmailSetup: true,
|
||||
editingNotification: mockNotification,
|
||||
onNotificationUpdatedMock,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Edit alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change event from 'row created' to 'row updated'
|
||||
const triggerSelect = screen.getByTestId("notification-event-select");
|
||||
await userEvent.click(triggerSelect);
|
||||
|
||||
const updateOption = screen.getByRole("option", {
|
||||
name: /When any cell changes it's value/i,
|
||||
});
|
||||
await userEvent.click(updateOption);
|
||||
|
||||
// Click Save button
|
||||
const saveButton = screen.getByRole("button", { name: /save changes/i });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
// Verify the API was called with the updated event
|
||||
const calls = fetchMock.calls(`path:/api/notification/${notificationId}`);
|
||||
expect(calls.length).toBe(1);
|
||||
|
||||
await waitFor(async () => {
|
||||
const requestBody = await calls[0][1]?.body;
|
||||
const parsedBody = JSON.parse(requestBody as string);
|
||||
return parsedBody; // Return the parsed body for later assertions
|
||||
}).then((parsedBody) => {
|
||||
// Verify the event has been changed to 'row updated'
|
||||
expect(parsedBody.payload.event_name).toBe("event/row.updated");
|
||||
});
|
||||
|
||||
expect(onNotificationUpdatedMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to create a mock table notification
|
||||
function createMockTableNotification({
|
||||
id = 123,
|
||||
event_name = "event/row.created",
|
||||
table_id = 42,
|
||||
user_id = 1,
|
||||
}: {
|
||||
id?: number;
|
||||
event_name?: NotificationTriggerEvent;
|
||||
table_id?: number;
|
||||
user_id?: number;
|
||||
}): TableNotification {
|
||||
const user = createMockUser({ id: user_id });
|
||||
return {
|
||||
id,
|
||||
active: true,
|
||||
creator_id: user_id,
|
||||
creator: user,
|
||||
handlers: [
|
||||
createMockNotificationHandlerEmail({
|
||||
recipients: [
|
||||
createMockNotificationRecipientUser({
|
||||
user_id,
|
||||
user,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
created_at: "2025-01-07T12:00:00Z",
|
||||
updated_at: "2025-01-07T12:00:00Z",
|
||||
payload_type: "notification/system-event",
|
||||
payload: {
|
||||
event_name,
|
||||
table_id,
|
||||
},
|
||||
payload_id: null,
|
||||
condition: ["=", ["field", "id"], 1],
|
||||
};
|
||||
}
|
||||
|
||||
function setup({
|
||||
userCanAccessSettings = false,
|
||||
isAdmin = false,
|
||||
isEmailSetup = true,
|
||||
isSlackSetup = false,
|
||||
isHttpSetup = false,
|
||||
webhooksResult = [],
|
||||
editingNotification,
|
||||
onNotificationCreatedMock = jest.fn(),
|
||||
onNotificationUpdatedMock = jest.fn(),
|
||||
}: {
|
||||
userCanAccessSettings?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isEmailSetup?: boolean;
|
||||
isSlackSetup?: boolean;
|
||||
isHttpSetup?: boolean;
|
||||
webhooksResult?: NotificationChannel[];
|
||||
editingNotification?: TableNotification;
|
||||
onNotificationCreatedMock?: jest.Mock;
|
||||
onNotificationUpdatedMock?: jest.Mock;
|
||||
}) {
|
||||
const settings = mockSettings({
|
||||
"token-features": createMockTokenFeatures({
|
||||
advanced_permissions: true,
|
||||
}),
|
||||
});
|
||||
|
||||
setupEnterprisePlugins();
|
||||
|
||||
setupNotificationChannelsEndpoints({
|
||||
slack: { configured: isSlackSetup },
|
||||
email: { configured: isEmailSetup },
|
||||
http: { configured: isHttpSetup },
|
||||
} as ChannelApiResponse["channels"]);
|
||||
|
||||
setupWebhookChannelsEndpoint(webhooksResult);
|
||||
setupUserRecipientsEndpoint({ users: [] });
|
||||
|
||||
const currentUser = createMockUser(
|
||||
isAdmin ? { is_superuser: true } : undefined,
|
||||
);
|
||||
|
||||
if (userCanAccessSettings) {
|
||||
(currentUser as UserWithApplicationPermissions).permissions = {
|
||||
can_access_setting: true,
|
||||
can_access_monitoring: false,
|
||||
can_access_subscription: false,
|
||||
};
|
||||
}
|
||||
|
||||
const database = createSampleDatabase();
|
||||
const _table = createMockTable({ id: 42 });
|
||||
|
||||
return renderWithProviders(
|
||||
<CreateOrEditTableNotificationModal
|
||||
tableId={_table.id}
|
||||
notification={editingNotification ?? null}
|
||||
onClose={jest.fn()}
|
||||
onNotificationCreated={onNotificationCreatedMock}
|
||||
onNotificationUpdated={onNotificationUpdatedMock}
|
||||
/>,
|
||||
{
|
||||
storeInitialState: {
|
||||
currentUser,
|
||||
settings,
|
||||
entities: createMockEntitiesState({
|
||||
databases: [database],
|
||||
tables: [_table],
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
useGetTableDataQuery,
|
||||
useGetTableQueryMetadataQuery,
|
||||
} from "metabase/api";
|
||||
import { FIELD_SEMANTIC_TYPES_MAP } from "metabase/lib/core";
|
||||
import { getBaseCondition } from "metabase/notifications/utils";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Icon,
|
||||
Menu,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
rem,
|
||||
} from "metabase/ui";
|
||||
import { EditingBodyCellConditional } from "metabase-enterprise/data_editing/tables/edit/inputs";
|
||||
import * as Lib from "metabase-lib";
|
||||
import type {
|
||||
ComparisonOperator,
|
||||
ConditionalAlertExpression,
|
||||
FieldId,
|
||||
FieldWithMetadata,
|
||||
Literal,
|
||||
LogicalOperator,
|
||||
MultipleConditionalAlertExpressions,
|
||||
NotificationTriggerEvent,
|
||||
SingleConditionalAlertExpression,
|
||||
TableId,
|
||||
} from "metabase-types/api";
|
||||
import type { DatasetColumn } from "metabase-types/api/dataset";
|
||||
|
||||
// Types for the condition builder UI
|
||||
type ColumnType =
|
||||
| "type/Text"
|
||||
| "type/Integer"
|
||||
| "type/BigInteger"
|
||||
| "type/Float"
|
||||
| "type/Boolean"
|
||||
| "type/Date"
|
||||
| "type/DateTime"
|
||||
| "type/DateTimeWithLocalTZ";
|
||||
|
||||
type ConditionBuilderColumn = DatasetColumn & {
|
||||
field: FieldWithMetadata;
|
||||
isNumeric: boolean;
|
||||
isBoolean: boolean;
|
||||
isStringOrStringLike: boolean;
|
||||
};
|
||||
|
||||
const SUPPORTED_TYPES = [
|
||||
"type/PK",
|
||||
"type/Text",
|
||||
"type/Integer",
|
||||
"type/BigInteger",
|
||||
"type/Float",
|
||||
"type/Boolean",
|
||||
];
|
||||
|
||||
interface OperatorOption {
|
||||
value: ComparisonOperator;
|
||||
label: string;
|
||||
applicableTypes: ColumnType[];
|
||||
}
|
||||
|
||||
interface ConditionRow {
|
||||
id: string;
|
||||
columnId: FieldId | null;
|
||||
columnName: string | null;
|
||||
operator: ComparisonOperator | null;
|
||||
value: Literal;
|
||||
}
|
||||
|
||||
interface AlertConditionBuilderProps {
|
||||
tableId: TableId;
|
||||
eventType: NotificationTriggerEvent;
|
||||
onChange?: (expression: ConditionalAlertExpression) => void;
|
||||
initialExpression?: ConditionalAlertExpression;
|
||||
}
|
||||
|
||||
// Define operators outside of component to avoid recreation on each render
|
||||
const OPERATORS: OperatorOption[] = [
|
||||
{
|
||||
value: "=",
|
||||
label: "Equal to",
|
||||
applicableTypes: [
|
||||
"type/Text",
|
||||
"type/Integer",
|
||||
"type/BigInteger",
|
||||
"type/Float",
|
||||
"type/Boolean",
|
||||
"type/Date",
|
||||
"type/DateTime",
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "!=",
|
||||
label: "Not equal to",
|
||||
applicableTypes: [
|
||||
"type/Text",
|
||||
"type/Integer",
|
||||
"type/BigInteger",
|
||||
"type/Float",
|
||||
"type/Boolean",
|
||||
"type/Date",
|
||||
"type/DateTime",
|
||||
],
|
||||
},
|
||||
{
|
||||
value: ">",
|
||||
label: "Higher than",
|
||||
applicableTypes: ["type/Integer", "type/BigInteger", "type/Float"],
|
||||
},
|
||||
{
|
||||
value: "<",
|
||||
label: "Lower than",
|
||||
applicableTypes: ["type/Integer", "type/BigInteger", "type/Float"],
|
||||
},
|
||||
{
|
||||
value: ">=",
|
||||
label: "Greater than or equal to",
|
||||
applicableTypes: ["type/Integer", "type/BigInteger", "type/Float"],
|
||||
},
|
||||
{
|
||||
value: "<=",
|
||||
label: "Less than or equal to",
|
||||
applicableTypes: ["type/Integer", "type/BigInteger", "type/Float"],
|
||||
},
|
||||
];
|
||||
|
||||
// Component for a single condition row
|
||||
const ConditionRowComponent = ({
|
||||
condition,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
columns,
|
||||
}: {
|
||||
condition: ConditionRow;
|
||||
onUpdate: (id: string, updates: Partial<ConditionRow>) => void;
|
||||
onRemove: (id: string) => void;
|
||||
columns: Array<ConditionBuilderColumn>;
|
||||
}) => {
|
||||
const selectedColumn = condition.columnId
|
||||
? columns.find((col) => col.id === Number(condition.columnId))
|
||||
: columns[0];
|
||||
|
||||
const columnType = selectedColumn?.effective_type as ColumnType | undefined;
|
||||
// Filter operators based on column type
|
||||
|
||||
// Get operators based on column type, or all operators if no column selected
|
||||
const filteredOperators = columnType
|
||||
? OPERATORS.filter((op: OperatorOption) =>
|
||||
op.applicableTypes.includes(columnType),
|
||||
)
|
||||
: OPERATORS;
|
||||
|
||||
// Helper to get a default value based on column type
|
||||
const getDefaultValueForType = (columnType: ColumnType | undefined): any => {
|
||||
if (!columnType) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case columnType.includes("Integer"):
|
||||
case columnType.includes("Float"):
|
||||
case columnType.includes("Decimal"):
|
||||
return 0;
|
||||
case columnType.includes("Boolean"):
|
||||
return true;
|
||||
case columnType.includes("Date"):
|
||||
case columnType.includes("Time"):
|
||||
return new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
|
||||
case columnType.includes("Text"):
|
||||
return "";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const handleColumnChange = (columnId: FieldId | null) => {
|
||||
// When a column is selected, automatically select the first applicable operator and set default value
|
||||
if (columnId) {
|
||||
const selectedColumn = columns.find((col) => col.id === Number(columnId));
|
||||
const columnType = selectedColumn?.effective_type as
|
||||
| ColumnType
|
||||
| undefined;
|
||||
|
||||
if (columnType) {
|
||||
const applicableOperators = OPERATORS.filter((op) =>
|
||||
op.applicableTypes.includes(columnType),
|
||||
);
|
||||
|
||||
if (applicableOperators.length > 0) {
|
||||
const defaultValue = getDefaultValueForType(columnType);
|
||||
|
||||
onUpdate(condition.id, {
|
||||
columnId,
|
||||
columnName: selectedColumn?.name || null,
|
||||
operator: applicableOperators[0].value as ComparisonOperator,
|
||||
value: defaultValue,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no column selected or no applicable operators found
|
||||
onUpdate(condition.id, {
|
||||
columnId: null,
|
||||
columnName: null,
|
||||
operator: null,
|
||||
value: null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOperatorChange = (operator: ComparisonOperator | null) => {
|
||||
onUpdate(condition.id, { operator });
|
||||
};
|
||||
|
||||
const handleValueChange = (value: string) => {
|
||||
const parsedValue = parseLiteral(value, selectedColumn);
|
||||
onUpdate(condition.id, { value: parsedValue });
|
||||
};
|
||||
|
||||
// Helper function to get operator icon/symbol
|
||||
const getOperatorIcon = (operator: ComparisonOperator | null) => {
|
||||
switch (operator) {
|
||||
case "=":
|
||||
return "=";
|
||||
case "!=":
|
||||
return "≠";
|
||||
case ">":
|
||||
return ">";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">=":
|
||||
return "≥";
|
||||
case "<=":
|
||||
return "≤";
|
||||
default:
|
||||
return "=";
|
||||
}
|
||||
};
|
||||
|
||||
// Prepare UI for the condition row
|
||||
return (
|
||||
<Flex gap="xs" align="center" w="100%">
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 32px 1fr",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
data={columns.map((col) => ({
|
||||
value: col.id?.toString() || "",
|
||||
label: col.display_name,
|
||||
icon: getColumnIcon(col),
|
||||
}))}
|
||||
value={condition.columnId?.toString() || ""}
|
||||
onChange={(newValue) => handleColumnChange(Number(newValue))}
|
||||
placeholder={t`Select column`}
|
||||
searchable
|
||||
clearable
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
|
||||
{selectedColumn && (
|
||||
<>
|
||||
<Menu position="bottom" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
fz="lg"
|
||||
fw={500}
|
||||
>
|
||||
<Text>{getOperatorIcon(condition.operator)}</Text>
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{filteredOperators.map((op: OperatorOption) => (
|
||||
<Menu.Item
|
||||
key={op.value}
|
||||
onClick={() => handleOperatorChange(op.value)}
|
||||
leftSection={
|
||||
<Text fw={600}>
|
||||
{getOperatorIcon(op.value as ComparisonOperator)}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{op.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<EditingBodyCellConditional
|
||||
field={selectedColumn?.field}
|
||||
datasetColumn={selectedColumn}
|
||||
initialValue={condition.value}
|
||||
onSubmit={(value) => handleValueChange(value as string)}
|
||||
onCancel={() => {}}
|
||||
inputProps={{ disabled: false, placeholder: t`Select value` }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => onRemove(condition.id)}
|
||||
p={0}
|
||||
style={{
|
||||
width: "2rem",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
styles={{ label: { display: "flex" } }}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertConditionBuilder = ({
|
||||
tableId,
|
||||
eventType,
|
||||
onChange,
|
||||
initialExpression,
|
||||
}: AlertConditionBuilderProps) => {
|
||||
const { data: tableMetadata } = useGetTableQueryMetadataQuery({
|
||||
id: tableId,
|
||||
});
|
||||
const { data: datasetData } = useGetTableDataQuery({
|
||||
tableId,
|
||||
});
|
||||
|
||||
const [areConditionsEnabled, setConditionsEnabled] = useState(false);
|
||||
|
||||
// Convert table fields to column options
|
||||
const columns = useMemo(() => {
|
||||
return (datasetData?.data?.cols || [])
|
||||
.map((col) => {
|
||||
const field = tableMetadata?.fields?.find((f) => f.name === col.name);
|
||||
const typeInfo = Lib.legacyColumnTypeInfo(col);
|
||||
return {
|
||||
...col,
|
||||
field,
|
||||
isNumeric: Lib.isNumeric(typeInfo),
|
||||
isStringOrStringLike: Lib.isStringOrStringLike(typeInfo),
|
||||
isBoolean: Lib.isBoolean(typeInfo),
|
||||
} as ConditionBuilderColumn;
|
||||
})
|
||||
.filter(
|
||||
(col) =>
|
||||
col.visibility_type === "normal" &&
|
||||
(SUPPORTED_TYPES.includes(col.field?.semantic_type as string) ||
|
||||
SUPPORTED_TYPES.includes(col.field?.effective_type as ColumnType)),
|
||||
);
|
||||
}, [datasetData?.data?.cols, tableMetadata?.fields]);
|
||||
|
||||
// Initialize conditions from initialExpression or with a single empty condition
|
||||
const [conditions, setConditions] = useState<ConditionRow[]>([]);
|
||||
const [logicalOperator, setLogicalOperator] =
|
||||
useState<LogicalOperator>("and");
|
||||
|
||||
const populateInitialState = useCallback(() => {
|
||||
const {
|
||||
conditions: initialConditions,
|
||||
logicalOperator: initialLogicalOperator,
|
||||
} = parseInitialExpression(initialExpression, columns);
|
||||
setConditions(initialConditions);
|
||||
setLogicalOperator(initialLogicalOperator);
|
||||
}, [initialExpression, columns]);
|
||||
|
||||
// Initial state population.
|
||||
useEffect(() => {
|
||||
if (!initialExpression) {
|
||||
return;
|
||||
}
|
||||
populateInitialState();
|
||||
if (doesExpressionHaveClientSideConditions(initialExpression)) {
|
||||
setConditionsEnabled(true);
|
||||
}
|
||||
}, [populateInitialState, initialExpression]);
|
||||
|
||||
// Handle adding a new condition
|
||||
// const handleAddCondition = () => {
|
||||
// setConditions([
|
||||
// ...conditions,
|
||||
// {
|
||||
// id: crypto.randomUUID(),
|
||||
// columnId: null,
|
||||
// columnName: null,
|
||||
// operator: null,
|
||||
// value: null,
|
||||
// },
|
||||
// ]);
|
||||
// };
|
||||
|
||||
// Handle updating a condition
|
||||
const handleUpdateCondition = useCallback(
|
||||
(id: string, updates: Partial<ConditionRow>) => {
|
||||
setConditions((conditions) =>
|
||||
conditions.map((condition) =>
|
||||
condition.id === id ? { ...condition, ...updates } : condition,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle removing a condition
|
||||
const handleRemoveCondition = (id: string) => {
|
||||
// TODO: Consider running onChange here.
|
||||
const newConditions = conditions.filter((condition) => condition.id !== id);
|
||||
setConditions(newConditions);
|
||||
if (newConditions.length === 0) {
|
||||
// onChange?.(null);
|
||||
setConditionsEnabled(false);
|
||||
}
|
||||
// } else {
|
||||
// onChange?.(generateExpression(tableId, newConditions, logicalOperator));
|
||||
// }
|
||||
};
|
||||
|
||||
// Handle changing the logical operator
|
||||
const handleOperatorChange = useCallback((value: string | null) => {
|
||||
if (value === "and" || value === "or") {
|
||||
setLogicalOperator(value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const expression = generateExpression(
|
||||
tableId,
|
||||
eventType,
|
||||
conditions,
|
||||
logicalOperator,
|
||||
);
|
||||
onChange?.(expression);
|
||||
}, [
|
||||
initialExpression,
|
||||
conditions,
|
||||
logicalOperator,
|
||||
onChange,
|
||||
tableId,
|
||||
eventType,
|
||||
]);
|
||||
|
||||
if (!areConditionsEnabled) {
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
p={0}
|
||||
onClick={() => {
|
||||
populateInitialState();
|
||||
setConditionsEnabled(true);
|
||||
}}
|
||||
>
|
||||
{t`Add conditions for alerts`}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm" w="100%">
|
||||
<Text size="lg" style={{ marginRight: "auto" }}>
|
||||
{t`Notify if the following condition is met`}
|
||||
</Text>
|
||||
{conditions.length > 1 && (
|
||||
<Flex align="center">
|
||||
<Select
|
||||
data={[
|
||||
{ value: "and", label: t`AND` },
|
||||
{ value: "or", label: t`OR` },
|
||||
]}
|
||||
value={logicalOperator}
|
||||
onChange={handleOperatorChange}
|
||||
style={{ width: rem(100) }}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
{conditions.map((condition) => (
|
||||
<ConditionRowComponent
|
||||
key={condition.id}
|
||||
condition={condition}
|
||||
onUpdate={handleUpdateCondition}
|
||||
onRemove={handleRemoveCondition}
|
||||
columns={columns}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{/* Multiple conditions support will be implemented later. */}
|
||||
{/* <Button
|
||||
variant="outline"
|
||||
leftSection={<Icon name="add" />}
|
||||
onClick={handleAddCondition}
|
||||
mt="sm"
|
||||
>
|
||||
{t`Add condition`}
|
||||
</Button> */}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// Parse initial expression into condition rows
|
||||
function parseInitialExpression(
|
||||
initialExpression: ConditionalAlertExpression | undefined,
|
||||
columns: ConditionBuilderColumn[],
|
||||
): { conditions: ConditionRow[]; logicalOperator: LogicalOperator } {
|
||||
const defaultCondition = {
|
||||
conditions: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
columnId: null,
|
||||
columnName: null,
|
||||
operator: null,
|
||||
value: null,
|
||||
},
|
||||
],
|
||||
logicalOperator: "and" as LogicalOperator,
|
||||
};
|
||||
|
||||
// As a temporary solution, we need to hide an important implementation detail
|
||||
// from the client.
|
||||
// Currently, each condition has a required table_id comparison at the top level.
|
||||
// Eg.: '["=",["context","table_id"],8]'.
|
||||
// But it should not be exposed to the Condition Builder component, user cannot affect it.
|
||||
// An example of an actual conditional expression in that case would look like this:
|
||||
// Eg.: '["and",["=",["context","table_id"],8],["=",["context","PAYMENT"],0]]'
|
||||
// So we need to omit the table_id condition, and move directly to the 2nd nesting level,
|
||||
// before parsing the expression.
|
||||
|
||||
const clientSideCondition = doesExpressionHaveClientSideConditions(
|
||||
initialExpression,
|
||||
)
|
||||
? initialExpression[3]
|
||||
: null;
|
||||
|
||||
if (!clientSideCondition) {
|
||||
// If no initial expression, start with a single empty condition
|
||||
return defaultCondition;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if it's a single condition or multiple conditions
|
||||
if (Array.isArray(clientSideCondition) && clientSideCondition.length > 0) {
|
||||
const firstElement = clientSideCondition[0];
|
||||
|
||||
// Case 1: Single condition - ["=", ["context", "row_change", "before", "field_name"], value]
|
||||
if (
|
||||
typeof firstElement === "string" &&
|
||||
["=", "!=", ">", "<", ">=", "<="].includes(firstElement)
|
||||
) {
|
||||
const operator = firstElement as ComparisonOperator;
|
||||
const fieldRef = clientSideCondition[1];
|
||||
const value = clientSideCondition[2] as any;
|
||||
|
||||
if (
|
||||
Array.isArray(fieldRef) &&
|
||||
fieldRef[0] === "context" &&
|
||||
fieldRef.length > 1
|
||||
) {
|
||||
const columnName = fieldRef[2] as string;
|
||||
|
||||
return {
|
||||
conditions: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
columnId:
|
||||
columns.find((col) => col.name === columnName)?.id || null,
|
||||
columnName,
|
||||
operator,
|
||||
value,
|
||||
},
|
||||
],
|
||||
logicalOperator: "and" as LogicalOperator,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Multiple conditions - ["and"/"or", [...condition1], [...condition2], ...]
|
||||
if (
|
||||
typeof firstElement === "string" &&
|
||||
["and", "or"].includes(firstElement)
|
||||
) {
|
||||
const logicalOperator = firstElement as LogicalOperator;
|
||||
const parsedConditions: ConditionRow[] = [];
|
||||
|
||||
// Process each condition expression
|
||||
for (let i = 1; i < clientSideCondition.length; i++) {
|
||||
const conditionExpr = clientSideCondition[i];
|
||||
|
||||
if (Array.isArray(conditionExpr) && conditionExpr.length >= 3) {
|
||||
const operator = conditionExpr[0] as ComparisonOperator;
|
||||
const fieldRef = conditionExpr[1];
|
||||
const value = conditionExpr[2] as any;
|
||||
|
||||
if (
|
||||
Array.isArray(fieldRef) &&
|
||||
fieldRef[0] === "context" &&
|
||||
fieldRef.length > 1
|
||||
) {
|
||||
const columnName = fieldRef[2] as string;
|
||||
|
||||
parsedConditions.push({
|
||||
id: crypto.randomUUID(),
|
||||
columnId:
|
||||
columns.find((col) => col.name === columnName)?.id || null,
|
||||
columnName,
|
||||
operator,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedConditions.length > 0) {
|
||||
return {
|
||||
conditions: parsedConditions,
|
||||
logicalOperator,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing initial expression:", error);
|
||||
}
|
||||
|
||||
// Fallback to default if parsing fails
|
||||
return defaultCondition;
|
||||
}
|
||||
|
||||
// Generate the expression based on current conditions
|
||||
function generateExpression(
|
||||
tableId: TableId,
|
||||
eventType: NotificationTriggerEvent,
|
||||
conditions?: ConditionRow[],
|
||||
logicalOperator?: LogicalOperator,
|
||||
): ConditionalAlertExpression {
|
||||
const baseExpression = getBaseCondition(tableId, eventType);
|
||||
|
||||
if (!conditions) {
|
||||
return baseExpression;
|
||||
}
|
||||
|
||||
const validConditions = conditions.filter(
|
||||
(c) =>
|
||||
c.columnId && c.operator && c.value !== null && c.value !== undefined,
|
||||
);
|
||||
|
||||
if (validConditions.length === 0) {
|
||||
return baseExpression;
|
||||
}
|
||||
|
||||
// TODO: This is a temprorary workaround until we have a better abstraction
|
||||
// on the API side to specify conditions for different event types.
|
||||
// Basically, the meaningful payload of the event can have different paths.
|
||||
// For 'create' and 'update' event, it will be under 'after' key.
|
||||
// For 'delete' event, it will be under 'before' key.
|
||||
|
||||
const payloadPath = ["context", "record"];
|
||||
|
||||
if (validConditions.length === 1) {
|
||||
const condition = validConditions[0];
|
||||
|
||||
return [
|
||||
...baseExpression,
|
||||
[
|
||||
condition.operator as ComparisonOperator,
|
||||
[...payloadPath, condition.columnName],
|
||||
condition.value,
|
||||
] as SingleConditionalAlertExpression,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...baseExpression,
|
||||
[
|
||||
logicalOperator as LogicalOperator,
|
||||
...validConditions.map((condition) => [
|
||||
condition.operator,
|
||||
[...payloadPath, condition.columnName],
|
||||
condition.value,
|
||||
]),
|
||||
] as MultipleConditionalAlertExpressions,
|
||||
];
|
||||
}
|
||||
|
||||
function getColumnIcon(column: ConditionBuilderColumn) {
|
||||
if (column.isBoolean) {
|
||||
return "io";
|
||||
}
|
||||
|
||||
const semanticTypeIcon =
|
||||
FIELD_SEMANTIC_TYPES_MAP[
|
||||
(column.semantic_type ||
|
||||
column.effective_type ||
|
||||
column.base_type) as string
|
||||
]?.icon;
|
||||
if (semanticTypeIcon) {
|
||||
return semanticTypeIcon;
|
||||
}
|
||||
|
||||
if (column.isNumeric) {
|
||||
return "int";
|
||||
}
|
||||
|
||||
return "string";
|
||||
}
|
||||
|
||||
function doesExpressionHaveClientSideConditions(
|
||||
expression?: ConditionalAlertExpression,
|
||||
): expression is ConditionalAlertExpression {
|
||||
return Array.isArray(expression) && Array.isArray(expression[3]);
|
||||
}
|
||||
|
||||
function parseLiteral(
|
||||
value: string,
|
||||
selectedColumn: ConditionBuilderColumn | undefined,
|
||||
) {
|
||||
if (!selectedColumn) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (selectedColumn.isBoolean) {
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
if (selectedColumn.isNumeric) {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: calc(100% - 5.5rem);
|
||||
min-height: 0;
|
||||
width: 50%;
|
||||
border-left: 1px solid var(--mb-color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2.5rem;
|
||||
gap: var(--mantine-spacing-md);
|
||||
background: var(--mb-color-bg-white);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
height: 1.625rem;
|
||||
z-index: 1003;
|
||||
max-width: calc(100% - 1.5rem);
|
||||
}
|
||||
|
||||
.closePreviewIcon {
|
||||
color: var(--mb-color-text-tertiary);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--mb-color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.scrollBox {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.previewStack {
|
||||
background: var(--mb-color-bg-white);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: start;
|
||||
column-gap: 1rem;
|
||||
}
|
||||
|
||||
.previewDetails {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.previewBody {
|
||||
overflow: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Avatar } from "metabase/components/UserAvatar";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Flex,
|
||||
Icon,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "metabase/ui";
|
||||
import type { PreviewNotificationTemplateResponse } from "metabase-types/api";
|
||||
|
||||
import S from "./PreviewTemplatePanel.module.css";
|
||||
|
||||
interface PreviewTemplatePanelProps {
|
||||
onClose: () => void;
|
||||
isLoading: boolean;
|
||||
error: any;
|
||||
previewContent?: PreviewNotificationTemplateResponse["rendered"];
|
||||
}
|
||||
|
||||
export const PreviewTemplatePanel = ({
|
||||
onClose,
|
||||
isLoading,
|
||||
error,
|
||||
previewContent,
|
||||
}: PreviewTemplatePanelProps) => {
|
||||
const htmlContent = previewContent?.body?.[0]?.content;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
mah="100%"
|
||||
gap="md"
|
||||
className={S.root}
|
||||
data-testid="preview-template-panel"
|
||||
>
|
||||
<Flex gap="sm" align="center" h="1.625rem" className={S.header}>
|
||||
<Icon name="eye" size={16} />
|
||||
<Title size="h4">{t`Email message preview`}</Title>
|
||||
<Tooltip label={t`Close Preview`}>
|
||||
<ActionIcon variant="transparent" ml="auto">
|
||||
<Icon
|
||||
name="arrow_left_to_line"
|
||||
size={20}
|
||||
onClick={onClose}
|
||||
className={S.closePreviewIcon}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Box p={0} className={S.scrollBox}>
|
||||
{isLoading && (
|
||||
<Flex align="center" justify="center" gap="sm" py="md">
|
||||
<Loader size={12} />
|
||||
<Text
|
||||
size="lg"
|
||||
lh="1.25rem"
|
||||
c="text-medium"
|
||||
>{t`Loading preview...`}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{error && (
|
||||
<code style={{ color: "var(--mb-color-error)" }}>{error}</code>
|
||||
)}
|
||||
{previewContent && !error ? (
|
||||
<Stack className={S.previewStack}>
|
||||
<Box className={S.previewHeader}>
|
||||
<Avatar>{previewContent.from || "?"}</Avatar>
|
||||
|
||||
<Box className={S.previewDetails}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t`From:`}
|
||||
</Text>
|
||||
<Text size="sm">{previewContent.from}</Text>
|
||||
|
||||
{previewContent.bcc && previewContent.bcc.length > 0 && (
|
||||
<>
|
||||
<Text size="sm" fw={600}>
|
||||
{t`BCC:`}
|
||||
</Text>
|
||||
<Text size="sm">{previewContent.bcc.join(", ")}</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text size="sm" fw={600}>
|
||||
{t`Subject:`}
|
||||
</Text>
|
||||
<Text size="sm">{previewContent.subject}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{htmlContent ? (
|
||||
<div
|
||||
className={S.previewBody}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
) : (
|
||||
<Text c="text-medium">{t`(No body content)`}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
!isLoading &&
|
||||
!error && <Text c="text-medium">{t`No preview available.`}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CreateOrEditTableNotificationModal } from "./CreateOrEditTableNotificationModal";
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { NotificationTriggerEvent } from "metabase-types/api";
|
||||
|
||||
export type TableNotificationTriggerOption = {
|
||||
value: NotificationTriggerEvent;
|
||||
label: string;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
.notificationListItem {
|
||||
position: relative;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--mb-color-border);
|
||||
|
||||
.itemTitle {
|
||||
line-height: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.notificationListItemEditable {
|
||||
&:hover {
|
||||
background: var(--mb-base-color-brand-10);
|
||||
cursor: pointer;
|
||||
border-color: transparent;
|
||||
|
||||
.itemTitle {
|
||||
color: var(--mb-color-brand);
|
||||
}
|
||||
|
||||
.handlersContainer {
|
||||
color: var(--mb-color-text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionButtonContainer {
|
||||
position: absolute;
|
||||
|
||||
/* 1rem - 0.6875rem (small icon button paddings) */
|
||||
right: 0.3125rem;
|
||||
top: 0.3125rem;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import cx from "classnames";
|
||||
import { type MouseEvent, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { formatCreatorMessage } from "metabase/account/notifications/components/NotificationCard/utils";
|
||||
import { getNotificationHandlersGroupedByTypes } from "metabase/lib/notifications";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import {
|
||||
HandlersInfo,
|
||||
NotificationActionButton,
|
||||
} from "metabase/notifications/modals/shared/components";
|
||||
import { getUser } from "metabase/selectors/user";
|
||||
import { Box, Group, Text } from "metabase/ui";
|
||||
import type {
|
||||
NotificationChannel,
|
||||
NotificationTriggerEvent,
|
||||
TableNotification,
|
||||
User,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import S from "./TableNotificationsListItem.module.css";
|
||||
|
||||
type TableNotificationsListItemProps = {
|
||||
notification: TableNotification;
|
||||
canEdit: boolean;
|
||||
users: User[] | undefined;
|
||||
httpChannelsConfig: NotificationChannel[] | undefined;
|
||||
onEdit: (notification: TableNotification) => void;
|
||||
onUnsubscribe: (notification: TableNotification) => void;
|
||||
onDelete: (notification: TableNotification) => void;
|
||||
};
|
||||
|
||||
export const TableNotificationsListItem = ({
|
||||
notification,
|
||||
canEdit,
|
||||
users,
|
||||
httpChannelsConfig,
|
||||
onEdit,
|
||||
onUnsubscribe,
|
||||
onDelete,
|
||||
}: TableNotificationsListItemProps) => {
|
||||
const user = useSelector(getUser);
|
||||
|
||||
const [showHoverActions, setShowHoverActions] = useState(false);
|
||||
|
||||
const { emailHandler, slackHandler, hookHandlers } =
|
||||
getNotificationHandlersGroupedByTypes(notification.handlers);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (canEdit) {
|
||||
onEdit(notification);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsubscribe = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
onUnsubscribe(notification);
|
||||
};
|
||||
|
||||
const handleDelete = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
onDelete(notification);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setShowHoverActions(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setShowHoverActions(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={cx(
|
||||
S.notificationListItem,
|
||||
canEdit && S.notificationListItemEditable,
|
||||
)}
|
||||
p="1rem"
|
||||
onClick={handleEdit}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Text className={S.itemTitle} size="md" lineClamp={1} fw="bold">
|
||||
{formatTitle(notification.payload.event_name)}
|
||||
</Text>
|
||||
<Group gap="xs" align="center" c="text-secondary">
|
||||
{user && (
|
||||
<>
|
||||
<Text size="sm" c="text-light">
|
||||
•
|
||||
</Text>
|
||||
<Text size="sm" c="inherit">
|
||||
{formatCreatorMessage(notification, user?.id)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<HandlersInfo
|
||||
emailHandler={emailHandler}
|
||||
slackHandler={slackHandler}
|
||||
hookHandlers={hookHandlers}
|
||||
users={users}
|
||||
httpChannelsConfig={httpChannelsConfig}
|
||||
mt="1rem"
|
||||
/>
|
||||
|
||||
{showHoverActions && (
|
||||
<div className={S.actionButtonContainer}>
|
||||
{canEdit ? (
|
||||
<NotificationActionButton
|
||||
label={t`Delete this alert`}
|
||||
iconName="trash"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
) : (
|
||||
<NotificationActionButton
|
||||
label={t`Unsubscribe from this`}
|
||||
iconName="unsubscribe"
|
||||
onClick={handleUnsubscribe}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const formatTitle = (eventName: NotificationTriggerEvent): string => {
|
||||
switch (eventName) {
|
||||
case "event/row.created":
|
||||
return t`Notify when new records are created`;
|
||||
case "event/row.updated":
|
||||
return t`Notify when records are updated`;
|
||||
case "event/row.deleted":
|
||||
return t`Notify when records are deleted`;
|
||||
default:
|
||||
return t`Notification`;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import type { StoryFn } from "@storybook/react";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
|
||||
import {
|
||||
createMockAlertNotification,
|
||||
createMockNotificationHandlerEmail,
|
||||
createMockNotificationHandlerSlack,
|
||||
} from "metabase-types/api/mocks/notification";
|
||||
|
||||
import { TableNotificationsListModal } from "./TableNotificationsListModal";
|
||||
|
||||
export default {
|
||||
title: "Notifications/TableNotificationsListModal",
|
||||
component: TableNotificationsListModal,
|
||||
};
|
||||
|
||||
const Template: StoryFn<ComponentProps<typeof TableNotificationsListModal>> = (
|
||||
args,
|
||||
) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<TableNotificationsListModal
|
||||
{...args}
|
||||
opened={isOpen}
|
||||
onClose={() => {
|
||||
args.onClose();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const tableNotifications = [
|
||||
createMockAlertNotification({
|
||||
handlers: [
|
||||
createMockNotificationHandlerEmail(),
|
||||
createMockNotificationHandlerSlack(),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
export const Default = {
|
||||
render: Template,
|
||||
|
||||
args: {
|
||||
tableNotifications,
|
||||
onCreate: action("onCreate"),
|
||||
onEdit: action("onEdit"),
|
||||
onClose: action("onClose"),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { useListChannelsQuery, useListUserRecipientsQuery } from "metabase/api";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import {
|
||||
canManageSubscriptions as canManageSubscriptionsSelector,
|
||||
getUser,
|
||||
} from "metabase/selectors/user";
|
||||
import { Button, Modal, type ModalProps, Stack, rem } from "metabase/ui";
|
||||
import type { TableNotification } from "metabase-types/api";
|
||||
|
||||
import { TableNotificationsListItem } from "./TableNotificationsListItem";
|
||||
|
||||
type TableNotificationsListModal = {
|
||||
notifications?: TableNotification[];
|
||||
opened: boolean;
|
||||
onCreate: () => void;
|
||||
onEdit: (notification: TableNotification) => void;
|
||||
onDelete: (notification: TableNotification) => void;
|
||||
onUnsubscribe: (notification: TableNotification) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const TableNotificationsListModal = ({
|
||||
notifications,
|
||||
opened,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUnsubscribe,
|
||||
onClose,
|
||||
...modalProps
|
||||
}: TableNotificationsListModal & ModalProps) => {
|
||||
const user = useSelector(getUser);
|
||||
const canManageSubscriptions = useSelector(canManageSubscriptionsSelector);
|
||||
const isAdmin = user?.is_superuser;
|
||||
|
||||
const { data: users } = useListUserRecipientsQuery();
|
||||
const { data: httpChannelsConfig } = useListChannelsQuery();
|
||||
|
||||
if (!notifications) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCreatedByCurrentUser = (notification: TableNotification) => {
|
||||
return user ? notification.creator.id === user.id : false;
|
||||
};
|
||||
|
||||
const [ownNotifications, othersNotifications] = _.partition(
|
||||
notifications,
|
||||
isCreatedByCurrentUser,
|
||||
);
|
||||
|
||||
// user's own alert should be shown first if it exists
|
||||
const sortedNotifications = [...ownNotifications, ...othersNotifications];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
data-testid="alert-list-modal"
|
||||
opened={opened}
|
||||
size={rem(600)}
|
||||
onClose={onClose}
|
||||
padding="xl"
|
||||
title={t`Edit alerts`}
|
||||
{...modalProps}
|
||||
>
|
||||
<Stack gap="lg" mb="lg" mt="1rem">
|
||||
{sortedNotifications.map((notification) => {
|
||||
const canEditNotification =
|
||||
isAdmin ||
|
||||
(canManageSubscriptions && isCreatedByCurrentUser(notification));
|
||||
return (
|
||||
<TableNotificationsListItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
users={users?.data}
|
||||
httpChannelsConfig={httpChannelsConfig}
|
||||
canEdit={canEditNotification}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
<div>
|
||||
<Button variant="filled" onClick={onCreate}>{t`New alert`}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { screen } from "__support__/ui";
|
||||
|
||||
import { createNotificationForUser, setup } from "./test-utils";
|
||||
|
||||
describe("TableNotificationsListModal", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should render modal with notification list", () => {
|
||||
const currentUserId = 1;
|
||||
const notifications = [
|
||||
createNotificationForUser(currentUserId),
|
||||
createNotificationForUser(2),
|
||||
];
|
||||
|
||||
setup({ notifications, currentUserId });
|
||||
|
||||
expect(screen.getByTestId("alert-list-modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("Edit alerts")).toBeInTheDocument();
|
||||
// The Box component in TableNotificationsListItem doesn't have a data-testid
|
||||
// Use getAllByText since we'll have multiple elements with the same text
|
||||
const notificationTitles = screen.getAllByText(
|
||||
"Notify when new records are created",
|
||||
);
|
||||
expect(notificationTitles).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should display current user's notifications first", () => {
|
||||
const currentUserId = 1;
|
||||
const otherUserId = 2;
|
||||
|
||||
// Create notifications with different created_at timestamps to ensure
|
||||
// sort is by ownership, not time
|
||||
const userNotification = createNotificationForUser(currentUserId);
|
||||
userNotification.created_at = "2025-03-01T12:00:00.000Z";
|
||||
|
||||
const otherNotification = createNotificationForUser(otherUserId);
|
||||
otherNotification.created_at = "2025-03-27T12:00:00.000Z"; // more recent
|
||||
|
||||
setup({
|
||||
notifications: [otherNotification, userNotification],
|
||||
currentUserId,
|
||||
});
|
||||
|
||||
// We can determine the order of notifications by checking the creator message
|
||||
// which should contain info about the current user
|
||||
const creatorMessages = screen.getAllByText(/Created by/);
|
||||
expect(creatorMessages.length).toBe(2);
|
||||
|
||||
// The first message should be for the current user's notification
|
||||
expect(creatorMessages[0]).toHaveTextContent(/you/);
|
||||
});
|
||||
|
||||
it("should allow editing for admin users regardless of ownership", () => {
|
||||
const currentUserId = 1;
|
||||
const otherUserId = 2;
|
||||
const notifications = [
|
||||
createNotificationForUser(currentUserId),
|
||||
createNotificationForUser(otherUserId),
|
||||
];
|
||||
|
||||
setup({
|
||||
notifications,
|
||||
currentUserId,
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Test that we can click on both notification titles (admin can edit all)
|
||||
const notificationItems = screen.getAllByText(
|
||||
"Notify when new records are created",
|
||||
);
|
||||
expect(notificationItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should allow editing only user's own notifications for non-admin users with subscription permissions", () => {
|
||||
const currentUserId = 1;
|
||||
const otherUserId = 2;
|
||||
const notifications = [
|
||||
createNotificationForUser(currentUserId),
|
||||
createNotificationForUser(otherUserId),
|
||||
];
|
||||
|
||||
setup({
|
||||
notifications,
|
||||
currentUserId,
|
||||
isAdmin: false,
|
||||
canManageSubscriptions: true,
|
||||
});
|
||||
|
||||
// Get all title elements for notifications
|
||||
const titles = screen.getAllByText(/Notify when/);
|
||||
expect(titles).toHaveLength(2);
|
||||
|
||||
// We can't easily test hover interactions in JSDOM
|
||||
// Instead, let's verify that user with permissions can see the creator info for both items
|
||||
const creatorInfoTexts = screen.getAllByText(/Created by/);
|
||||
expect(creatorInfoTexts).toHaveLength(2);
|
||||
|
||||
// First should be from the current user (shown as 'you')
|
||||
expect(creatorInfoTexts[0]).toHaveTextContent(/you/);
|
||||
});
|
||||
|
||||
it("should not allow editing any notifications for users without permissions", () => {
|
||||
const currentUserId = 1;
|
||||
const notifications = [
|
||||
createNotificationForUser(currentUserId),
|
||||
createNotificationForUser(2),
|
||||
];
|
||||
|
||||
setup({
|
||||
notifications,
|
||||
currentUserId,
|
||||
isAdmin: false,
|
||||
canManageSubscriptions: false,
|
||||
});
|
||||
|
||||
expect(screen.queryAllByLabelText("pencil icon")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should call onCreate when 'New alert' button is clicked", async () => {
|
||||
const onCreate = jest.fn();
|
||||
|
||||
setup({ onCreate });
|
||||
|
||||
const createButton = screen.getByText("New alert");
|
||||
await userEvent.click(createButton);
|
||||
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onEdit when edit button is clicked", async () => {
|
||||
const currentUserId = 1;
|
||||
const notification = createNotificationForUser(currentUserId, 0, {
|
||||
payload: {
|
||||
event_name: "event/row.updated",
|
||||
table_id: 1,
|
||||
},
|
||||
});
|
||||
const onEdit = jest.fn();
|
||||
|
||||
setup({
|
||||
notifications: [notification],
|
||||
currentUserId,
|
||||
isAdmin: true,
|
||||
onEdit,
|
||||
});
|
||||
|
||||
// Clicking on the notification item itself triggers edit
|
||||
// We'll click directly on the title which is part of the notification item
|
||||
const notificationTitle = screen.getByText(
|
||||
"Notify when records are updated",
|
||||
);
|
||||
await userEvent.click(notificationTitle);
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith(notification);
|
||||
});
|
||||
|
||||
it("should call onDelete when delete button is clicked", async () => {
|
||||
const currentUserId = 1;
|
||||
const notification = createNotificationForUser(currentUserId, 0, {
|
||||
payload: {
|
||||
event_name: "event/row.deleted",
|
||||
table_id: 1,
|
||||
},
|
||||
});
|
||||
const onDelete = jest.fn();
|
||||
|
||||
setup({
|
||||
notifications: [notification],
|
||||
currentUserId,
|
||||
isAdmin: true,
|
||||
onDelete,
|
||||
});
|
||||
|
||||
// Get the notification title and test that it's rendered
|
||||
const notificationTitle = screen.getByText(
|
||||
"Notify when records are deleted",
|
||||
);
|
||||
expect(notificationTitle).toBeInTheDocument();
|
||||
|
||||
// Find the containing notification item (we're looking at structure not implementation)
|
||||
// and hover over it
|
||||
await userEvent.hover(notificationTitle);
|
||||
|
||||
// Now find and click the delete button (trash icon)
|
||||
const deleteButton = await screen.findByLabelText("Delete this alert");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Verify onDelete was called with the notification
|
||||
expect(onDelete).toHaveBeenCalledWith(notification);
|
||||
});
|
||||
|
||||
it("should call onUnsubscribe when unsubscribe button is clicked", async () => {
|
||||
const currentUserId = 1;
|
||||
const otherUserId = 2; // Use another user's notification
|
||||
const notification = createNotificationForUser(otherUserId);
|
||||
const onUnsubscribe = jest.fn();
|
||||
|
||||
// Set up with non-admin user who can't edit others' notifications
|
||||
setup({
|
||||
notifications: [notification],
|
||||
currentUserId,
|
||||
isAdmin: false,
|
||||
canManageSubscriptions: false,
|
||||
onUnsubscribe,
|
||||
});
|
||||
|
||||
// Verify the notification is rendered
|
||||
const notificationTitle = screen.getByText(
|
||||
"Notify when new records are created",
|
||||
);
|
||||
expect(notificationTitle).toBeInTheDocument();
|
||||
|
||||
// Hover over the notification title to trigger the hover state on the container
|
||||
await userEvent.hover(notificationTitle);
|
||||
|
||||
// Now find and click the unsubscribe button
|
||||
const unsubscribeButton = await screen.findByLabelText(
|
||||
"Unsubscribe from this",
|
||||
);
|
||||
await userEvent.click(unsubscribeButton);
|
||||
|
||||
// Verify onUnsubscribe was called with the notification
|
||||
expect(onUnsubscribe).toHaveBeenCalledWith(notification);
|
||||
});
|
||||
|
||||
it("should call onClose when modal is closed", async () => {
|
||||
const onClose = jest.fn();
|
||||
|
||||
setup({ onClose });
|
||||
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle case with no notifications", () => {
|
||||
setup({ notifications: [] });
|
||||
|
||||
// There should be no notification titles when the list is empty
|
||||
expect(screen.queryAllByText(/Notify when/)).toHaveLength(0);
|
||||
expect(screen.getByText("New alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render null if notifications is undefined", () => {
|
||||
setup({ notifications: undefined, opened: false });
|
||||
|
||||
expect(screen.queryByTestId("alert-list-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { TableNotificationsListModal } from "./TableNotificationsListModal";
|
||||
@@ -0,0 +1,106 @@
|
||||
import { setupUserRecipientsEndpoint } from "__support__/server-mocks";
|
||||
import { setupWebhookChannelsEndpoint } from "__support__/server-mocks/channel";
|
||||
import { mockSettings } from "__support__/settings";
|
||||
import { renderWithProviders } from "__support__/ui";
|
||||
import type { UserWithApplicationPermissions } from "metabase/plugins";
|
||||
import { TableNotificationsListModal } from "metabase-enterprise/data_editing/alerts/TableNotificationsModals/TableNotificationsListModal/TableNotificationsListModal";
|
||||
import type { TableNotification, User } from "metabase-types/api";
|
||||
import {
|
||||
createMockTableNotification,
|
||||
createMockUser,
|
||||
} from "metabase-types/api/mocks";
|
||||
|
||||
export interface SetupOpts {
|
||||
notifications?: TableNotification[];
|
||||
isAdmin?: boolean;
|
||||
canManageSubscriptions?: boolean;
|
||||
currentUserId?: number;
|
||||
onCreate?: jest.Mock;
|
||||
onEdit?: jest.Mock;
|
||||
onDelete?: jest.Mock;
|
||||
onUnsubscribe?: jest.Mock;
|
||||
onClose?: jest.Mock;
|
||||
webhooksConfig?: any[];
|
||||
users?: User[];
|
||||
opened?: boolean;
|
||||
}
|
||||
|
||||
export const setup = ({
|
||||
notifications = [],
|
||||
isAdmin = false,
|
||||
canManageSubscriptions = false,
|
||||
currentUserId = 1,
|
||||
onCreate = jest.fn(),
|
||||
onEdit = jest.fn(),
|
||||
onDelete = jest.fn(),
|
||||
onUnsubscribe = jest.fn(),
|
||||
onClose = jest.fn(),
|
||||
webhooksConfig = [],
|
||||
users = [],
|
||||
opened = true,
|
||||
}: SetupOpts = {}) => {
|
||||
// Set up mock users and webhooks
|
||||
setupWebhookChannelsEndpoint(webhooksConfig);
|
||||
setupUserRecipientsEndpoint({ users });
|
||||
|
||||
// Create current user with appropriate permissions
|
||||
const currentUser = createMockUser({
|
||||
id: currentUserId,
|
||||
is_superuser: isAdmin,
|
||||
});
|
||||
|
||||
if (canManageSubscriptions) {
|
||||
(currentUser as UserWithApplicationPermissions).permissions = {
|
||||
can_access_setting: false,
|
||||
can_access_monitoring: false,
|
||||
can_access_subscription: true,
|
||||
};
|
||||
}
|
||||
|
||||
const settings = mockSettings();
|
||||
|
||||
return {
|
||||
...renderWithProviders(
|
||||
<TableNotificationsListModal
|
||||
notifications={notifications}
|
||||
opened={opened}
|
||||
onCreate={onCreate}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
{
|
||||
storeInitialState: {
|
||||
currentUser,
|
||||
settings,
|
||||
},
|
||||
},
|
||||
),
|
||||
onCreate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUnsubscribe,
|
||||
onClose,
|
||||
};
|
||||
};
|
||||
|
||||
export const createNotificationForUser = (
|
||||
userId: number,
|
||||
index = 0,
|
||||
extra?: Partial<TableNotification>,
|
||||
): TableNotification => {
|
||||
return {
|
||||
...createMockTableNotification(),
|
||||
id: userId * 100 + index, // Ensure unique IDs
|
||||
creator: createMockUser({ id: userId, first_name: `User ${userId}` }),
|
||||
creator_id: userId,
|
||||
// Use payload to make each notification unique for testing
|
||||
payload: {
|
||||
event_name: "event/row.created",
|
||||
table_id: index + 1,
|
||||
},
|
||||
payload_id: null,
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Specificity to override already overridden custom styling without !important */
|
||||
.alertIcon[data-variant="subtle"] {
|
||||
&:hover {
|
||||
color: var(--mb-color-brand);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState } from "react";
|
||||
import { usePreviousDistinct } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
useTableNotificationsQuery,
|
||||
useUnsubscribeFromNotificationMutation,
|
||||
useUpdateNotificationMutation,
|
||||
} from "metabase/api";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { DeleteConfirmModal } from "metabase/notifications/modals/shared/DeleteConfirmModal";
|
||||
import { UnsubscribeConfirmModal } from "metabase/notifications/modals/shared/UnsubscribeConfirmModal";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
import { ActionIcon, Icon, Tooltip } from "metabase/ui";
|
||||
import type { TableId, TableNotification } from "metabase-types/api";
|
||||
|
||||
import { CreateOrEditTableNotificationModal } from "../CreateOrEditTableNotificationModal/CreateOrEditTableNotificationModal";
|
||||
import { TableNotificationsListModal } from "../TableNotificationsListModal/TableNotificationsListModal";
|
||||
|
||||
import S from "./TableNotificationsTrigger.module.css";
|
||||
|
||||
interface TableNotificationsModalProps {
|
||||
tableId: TableId;
|
||||
}
|
||||
|
||||
type AlertModalMode =
|
||||
| "list-modal"
|
||||
| "create-edit-modal"
|
||||
| "delete-confirm-modal"
|
||||
| "unsubscribe-confirm-modal";
|
||||
|
||||
export const TableNotificationsTrigger = ({
|
||||
tableId,
|
||||
...props
|
||||
}: TableNotificationsModalProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data: tableNotifications } = useTableNotificationsQuery({
|
||||
table_id: tableId,
|
||||
include_inactive: false,
|
||||
payload_type: "notification/system-event",
|
||||
});
|
||||
|
||||
const [editingItem, setEditingItem] = useState<TableNotification | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [activeModal, setActiveModal] = useState<AlertModalMode | null>(null);
|
||||
|
||||
const hasNotifications = tableNotifications && tableNotifications.length > 0;
|
||||
const handleOpen = () => {
|
||||
setIsOpen(true);
|
||||
setActiveModal(hasNotifications ? "list-modal" : "create-edit-modal");
|
||||
};
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setActiveModal(null);
|
||||
setEditingItem(null);
|
||||
};
|
||||
const previousActiveModal = usePreviousDistinct(activeModal);
|
||||
const handleInternalModalClose = () => {
|
||||
if (previousActiveModal === "list-modal") {
|
||||
setActiveModal("list-modal");
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [updateNotification] = useUpdateNotificationMutation();
|
||||
const handleDelete = async (itemToDelete: TableNotification) => {
|
||||
const result = await updateNotification({
|
||||
...itemToDelete,
|
||||
active: false,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
dispatch(
|
||||
addUndo({
|
||||
icon: "warning",
|
||||
toastColor: "error",
|
||||
message: t`An error occurred`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(addUndo({ message: t`The alert was successfully deleted.` }));
|
||||
|
||||
const notificationsCount = tableNotifications?.length || 0;
|
||||
// if we have just unsubscribed from the last alert, close the popover
|
||||
if (notificationsCount <= 1) {
|
||||
handleClose();
|
||||
} else {
|
||||
handleInternalModalClose();
|
||||
}
|
||||
};
|
||||
|
||||
const [unsubscribe] = useUnsubscribeFromNotificationMutation();
|
||||
const handleUnsubscribe = async (alert: TableNotification) => {
|
||||
const result = await unsubscribe(alert.id);
|
||||
|
||||
if (result.error) {
|
||||
dispatch(
|
||||
addUndo({
|
||||
icon: "warning",
|
||||
toastColor: "error",
|
||||
message: t`An error occurred`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(addUndo({ message: t`Successfully unsubscribed.` }));
|
||||
|
||||
const notificationsCount = tableNotifications?.length || 0;
|
||||
// if we have just unsubscribed from the last alert, close the popover
|
||||
if (notificationsCount <= 1) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={hasNotifications ? t`Edit alerts` : t`Create alert`}>
|
||||
<ActionIcon
|
||||
data-testid="table-notifications-trigger"
|
||||
className={S.alertIcon}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
onClick={handleOpen}
|
||||
{...props}
|
||||
>
|
||||
<Icon name="alert" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{activeModal === "list-modal" && (
|
||||
<TableNotificationsListModal
|
||||
opened={isOpen}
|
||||
notifications={tableNotifications}
|
||||
onCreate={() => setActiveModal("create-edit-modal")}
|
||||
onEdit={(notification) => {
|
||||
setEditingItem(notification);
|
||||
setActiveModal("create-edit-modal");
|
||||
}}
|
||||
onClose={handleClose}
|
||||
onDelete={(notification) => {
|
||||
setEditingItem(notification);
|
||||
setActiveModal("delete-confirm-modal");
|
||||
}}
|
||||
onUnsubscribe={(notification) => {
|
||||
setEditingItem(notification);
|
||||
setActiveModal("unsubscribe-confirm-modal");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal === "create-edit-modal" && (
|
||||
<CreateOrEditTableNotificationModal
|
||||
tableId={tableId}
|
||||
notification={editingItem}
|
||||
onClose={handleClose}
|
||||
onNotificationCreated={handleClose}
|
||||
onNotificationUpdated={handleClose}
|
||||
/>
|
||||
)}
|
||||
{activeModal === "delete-confirm-modal" && editingItem && (
|
||||
<DeleteConfirmModal
|
||||
title={t`Delete this alert?`}
|
||||
onConfirm={() => handleDelete(editingItem)}
|
||||
onClose={handleInternalModalClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal === "unsubscribe-confirm-modal" && editingItem && (
|
||||
<UnsubscribeConfirmModal
|
||||
title={t`Unsubscribe from this alert?`}
|
||||
description={t`You'll stop receiving this alert from now on. Depending on your organization’s permissions you might need to ask a moderator to be re-added in the future.`}
|
||||
onConfirm={() => handleUnsubscribe(editingItem)}
|
||||
onClose={handleInternalModalClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { FieldWithMetadata } from "metabase-types/api";
|
||||
|
||||
export function canEditField(field?: FieldWithMetadata) {
|
||||
return (
|
||||
field && field.semantic_type !== "type/PK" && !field.database_is_generated
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PLUGIN_DATA_EDITING } from "metabase/plugins";
|
||||
import { hasPremiumFeature } from "metabase-enterprise/settings";
|
||||
|
||||
import { isDatabaseTableEditingEnabled } from "./settings";
|
||||
import { EditTableDataContainer } from "./tables/edit";
|
||||
import { EditTableDashcardVisualization } from "./tables/edit/EditTableDashcardVisualization";
|
||||
import { BrowseTableData } from "./tables/view";
|
||||
|
||||
if (hasPremiumFeature("table_data_editing")) {
|
||||
PLUGIN_DATA_EDITING.isEnabled = () => true;
|
||||
PLUGIN_DATA_EDITING.isDatabaseTableEditingEnabled =
|
||||
isDatabaseTableEditingEnabled;
|
||||
PLUGIN_DATA_EDITING.VIEW_PAGE_COMPONENT = BrowseTableData;
|
||||
PLUGIN_DATA_EDITING.EDIT_PAGE_COMPONENT = EditTableDataContainer;
|
||||
PLUGIN_DATA_EDITING.CARD_TABLE_COMPONENT = EditTableDashcardVisualization;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type Database from "metabase-lib/v1/metadata/Database";
|
||||
import type { Database as DatabaseApi } from "metabase-types/api";
|
||||
|
||||
export function isDatabaseTableEditingEnabled(
|
||||
database: Database | DatabaseApi,
|
||||
) {
|
||||
return database.settings?.["database-enable-table-editing"] ?? false;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { HeadBreadcrumbs } from "metabase/query_builder/components/view/ViewHeader/components"; // TODO: we should not use query builder components
|
||||
import type { DataSourcePart } from "metabase/query_builder/components/view/ViewHeader/components/QuestionDataSource/utils"; // TODO: we should not use query builder components
|
||||
import { ViewHeading } from "metabase/query_builder/components/view/ViewSection"; // TODO: we should not use query builder components
|
||||
import type DatabaseV1 from "metabase-lib/v1/metadata/Database";
|
||||
import type TableV1 from "metabase-lib/v1/metadata/Table";
|
||||
import type { Database, Table } from "metabase-types/api";
|
||||
|
||||
type TableBreadcrumbsProps = {
|
||||
database: Pick<DatabaseV1 | Database, "id" | "name"> | undefined;
|
||||
table: TableV1 | Table | undefined;
|
||||
isEditMode?: boolean;
|
||||
};
|
||||
|
||||
export const TableBreadcrumbs = ({
|
||||
database,
|
||||
table,
|
||||
isEditMode,
|
||||
}: TableBreadcrumbsProps) => {
|
||||
const breadcrumbsParts = useMemo(
|
||||
() =>
|
||||
database && table
|
||||
? getBreadcrumbsParts({
|
||||
database,
|
||||
table,
|
||||
isEditMode,
|
||||
})
|
||||
: [],
|
||||
[database, isEditMode, table],
|
||||
);
|
||||
|
||||
return (
|
||||
<ViewHeading color="medium">
|
||||
<HeadBreadcrumbs parts={breadcrumbsParts} variant="head" />
|
||||
</ViewHeading>
|
||||
);
|
||||
};
|
||||
|
||||
const getBreadcrumbsParts = ({
|
||||
database,
|
||||
table,
|
||||
isEditMode = false,
|
||||
}: {
|
||||
database: Pick<DatabaseV1 | Database, "id" | "name">;
|
||||
table: TableV1 | Table;
|
||||
isEditMode?: boolean;
|
||||
}): DataSourcePart[] => {
|
||||
const parts: DataSourcePart[] = [
|
||||
{
|
||||
icon: "database" as const,
|
||||
model: "database" as const,
|
||||
name: database.name,
|
||||
href: Urls.browseDatabase(database),
|
||||
},
|
||||
{
|
||||
icon: "table" as const,
|
||||
model: "table" as const,
|
||||
name: table.display_name,
|
||||
href: isEditMode ? Urls.browseTable(table) : undefined,
|
||||
},
|
||||
isEditMode
|
||||
? {
|
||||
get name() {
|
||||
return t`Edit`;
|
||||
},
|
||||
}
|
||||
: null,
|
||||
].filter(isNotNull);
|
||||
|
||||
return parts;
|
||||
};
|
||||
@@ -0,0 +1,453 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import cx from "classnames";
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import { useLocation } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
import Modal from "metabase/components/Modal";
|
||||
import { NoDataError } from "metabase/components/errors/NoDataError";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Icon,
|
||||
Stack,
|
||||
Text,
|
||||
rem,
|
||||
} from "metabase/ui";
|
||||
import { ShortMessage } from "metabase/visualizations/components/Visualization/NoResultsView/NoResultsView.styled";
|
||||
import { useBuiltInActions } from "metabase-enterprise/data_editing/actions/use-built-in-actions";
|
||||
import { canEditField } from "metabase-enterprise/data_editing/helpers";
|
||||
import { TableActionExecuteModalContent } from "metabase-enterprise/table-actions/execution/TableActionExecuteModalContent";
|
||||
import { useTableActionsExecute } from "metabase-enterprise/table-actions/execution/use-table-actions-execute";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import { HARD_ROW_LIMIT } from "metabase-lib/v1/queries/utils";
|
||||
import { formatRowCount } from "metabase-lib/v1/queries/utils/row-count";
|
||||
import type {
|
||||
ConcreteTableId,
|
||||
DashCardVisualizationSettings,
|
||||
DatasetData,
|
||||
TableActionDisplaySettings,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import S from "./EditTableData.module.css";
|
||||
import { EditTableDataGrid } from "./EditTableDataGrid";
|
||||
import { EditTableDataOverlay } from "./EditTableDataOverlay";
|
||||
import { DeleteBulkRowConfirmationModal } from "./modals/DeleteBulkRowConfirmationModal";
|
||||
import { EditBulkRowsModal } from "./modals/EditBulkRowsModal";
|
||||
import { EditingBaseRowModal } from "./modals/EditingBaseRowModal";
|
||||
import { UnsavedLeaveConfirmationModal } from "./modals/UnsavedLeaveConfirmationModal";
|
||||
import { useTableBulkDeleteConfirmation } from "./modals/use-table-bulk-delete-confirmation";
|
||||
import { useTableEditingModalControllerWithObjectId } from "./modals/use-table-modal-with-object-id";
|
||||
import { useEditableTableColumnConfigFromVisualizationSettings } from "./use-editable-column-config";
|
||||
import { useTableCRUD } from "./use-table-crud";
|
||||
import { useEditingTableRowSelection } from "./use-table-row-selection";
|
||||
import { useTableSorting } from "./use-table-sorting";
|
||||
import { useTableEditingStateDashcardUpdateStrategy } from "./use-table-state-dashcard-update-strategy";
|
||||
import { useTableEditingUndoRedo } from "./use-table-undo-redo";
|
||||
|
||||
type EditTableDashcardVisualizationProps = {
|
||||
title: string;
|
||||
dashcardId: number;
|
||||
cardId: number;
|
||||
tableId: ConcreteTableId;
|
||||
data: DatasetData;
|
||||
className?: string;
|
||||
visualizationSettings?: DashCardVisualizationSettings; // TODO: move editable table viz settings type to Card Visualization
|
||||
question: Question;
|
||||
withLeaveUnsavedConfirmation?: boolean;
|
||||
isEditing?: boolean;
|
||||
};
|
||||
|
||||
export const EditTableDashcardVisualization = memo(
|
||||
({
|
||||
title,
|
||||
dashcardId,
|
||||
cardId,
|
||||
tableId,
|
||||
data,
|
||||
className,
|
||||
visualizationSettings,
|
||||
question,
|
||||
withLeaveUnsavedConfirmation = true,
|
||||
isEditing,
|
||||
}: EditTableDashcardVisualizationProps) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const location = useLocation();
|
||||
const objectIdParam = useMemo(() => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const objectIdParam = searchParams.get("objectId");
|
||||
const parsedParams = parseModalCompositeObjectId(objectIdParam);
|
||||
|
||||
if (parsedParams?.dashcardId === dashcardId) {
|
||||
return parsedParams.objectId ?? undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [location.search, dashcardId]);
|
||||
|
||||
const handleCurrentObjectIdChange = useCallback(
|
||||
(objectId?: string) => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
if (objectId) {
|
||||
searchParams.set(
|
||||
"objectId",
|
||||
getModalCompositeObjectId(objectId, dashcardId),
|
||||
);
|
||||
} else {
|
||||
searchParams.delete("objectId");
|
||||
}
|
||||
|
||||
dispatch(
|
||||
push({
|
||||
...location,
|
||||
search: "?" + searchParams.toString(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
[location, dispatch, dashcardId],
|
||||
);
|
||||
|
||||
const {
|
||||
state: modalState,
|
||||
openCreateRowModal,
|
||||
openEditRowModal,
|
||||
closeModal,
|
||||
} = useTableEditingModalControllerWithObjectId({
|
||||
currentObjectId: objectIdParam,
|
||||
datasetData: data,
|
||||
onObjectIdChange: handleCurrentObjectIdChange,
|
||||
});
|
||||
|
||||
const stateUpdateStrategy = useTableEditingStateDashcardUpdateStrategy(
|
||||
dashcardId,
|
||||
cardId,
|
||||
);
|
||||
|
||||
const editingScope = useMemo(() => {
|
||||
return { "dashcard-id": dashcardId };
|
||||
}, [dashcardId]);
|
||||
|
||||
const {
|
||||
isInserting,
|
||||
isDeleting,
|
||||
isUpdating,
|
||||
tableFieldMetadataMap,
|
||||
cellsWithFailedUpdatesMap,
|
||||
|
||||
handleCellValueUpdate,
|
||||
handleRowCreate,
|
||||
handleRowUpdate,
|
||||
handleRowUpdateBulk,
|
||||
handleRowDelete,
|
||||
handleRowDeleteBulk,
|
||||
} = useTableCRUD({
|
||||
tableId,
|
||||
scope: editingScope,
|
||||
datasetData: data,
|
||||
stateUpdateStrategy,
|
||||
});
|
||||
|
||||
const { undo, redo, isUndoLoading, isRedoLoading, currentActionLabel } =
|
||||
useTableEditingUndoRedo({
|
||||
tableId,
|
||||
scope: editingScope,
|
||||
stateUpdateStrategy,
|
||||
});
|
||||
|
||||
const columnsConfig = useEditableTableColumnConfigFromVisualizationSettings(
|
||||
visualizationSettings,
|
||||
);
|
||||
|
||||
const { hasCreateAction, hasDeleteAction } = useBuiltInActions(
|
||||
visualizationSettings?.["editableTable.enabledActions"],
|
||||
);
|
||||
|
||||
const hasEditableAndVisibleColumns = useMemo(() => {
|
||||
return data.cols.some(
|
||||
(column) =>
|
||||
!columnsConfig?.isColumnReadonly(column.name) &&
|
||||
!columnsConfig?.isColumnHidden(column.name) &&
|
||||
canEditField(tableFieldMetadataMap[column.name]),
|
||||
);
|
||||
}, [data.cols, tableFieldMetadataMap, columnsConfig]);
|
||||
const hasBulkEditing = hasEditableAndVisibleColumns;
|
||||
|
||||
const {
|
||||
tableActions,
|
||||
handleTableActionRun,
|
||||
selectedTableActionState,
|
||||
handleExecuteActionModalClose,
|
||||
} = useTableActionsExecute({
|
||||
actionsVizSettings: visualizationSettings?.[
|
||||
"editableTable.enabledActions"
|
||||
] as TableActionDisplaySettings[] | undefined,
|
||||
datasetData: data,
|
||||
});
|
||||
|
||||
const { rowSelection, selectedRowIndices, setRowSelection } =
|
||||
useEditingTableRowSelection();
|
||||
|
||||
const {
|
||||
isDeleteBulkRequested,
|
||||
requestDeleteBulk,
|
||||
cancelDeleteBulk,
|
||||
onDeleteBulkConfirmation,
|
||||
} = useTableBulkDeleteConfirmation({
|
||||
handleRowDeleteBulk,
|
||||
selectedRowIndices,
|
||||
setRowSelection,
|
||||
});
|
||||
|
||||
const isActionExecuteModalOpen = !!selectedTableActionState;
|
||||
|
||||
const { getColumnSortDirection } = useTableSorting({
|
||||
question,
|
||||
});
|
||||
|
||||
const [
|
||||
isBulkEditingRequested,
|
||||
{ open: requestBulkEditing, close: closeBulkEditing },
|
||||
] = useDisclosure();
|
||||
|
||||
const shouldDisableActions = isUndoLoading || isRedoLoading;
|
||||
|
||||
return (
|
||||
<Stack className={cx(S.container, className)} gap={0}>
|
||||
<Flex
|
||||
p="0.5rem"
|
||||
px="1rem"
|
||||
style={{ borderBottom: "1px solid var(--mb-color-border)" }}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Text fw="bold">{title}</Text>
|
||||
|
||||
{!isEditing && (
|
||||
<Group gap="sm" align="center">
|
||||
{hasBulkEditing && (
|
||||
<ActionIcon
|
||||
size="md"
|
||||
onClick={requestBulkEditing}
|
||||
disabled={shouldDisableActions || !selectedRowIndices.length}
|
||||
>
|
||||
<Icon
|
||||
name="pencil"
|
||||
tooltip={
|
||||
selectedRowIndices.length
|
||||
? t`Edit selected records`
|
||||
: t`Select records to edit`
|
||||
}
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
{hasDeleteAction && (
|
||||
<ActionIcon
|
||||
size="md"
|
||||
onClick={requestDeleteBulk}
|
||||
disabled={shouldDisableActions || !selectedRowIndices.length}
|
||||
>
|
||||
<Icon
|
||||
name="trash"
|
||||
tooltip={
|
||||
selectedRowIndices.length
|
||||
? t`Delete selected records`
|
||||
: t`Select records to delete`
|
||||
}
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
{(hasBulkEditing || hasDeleteAction) && (
|
||||
<Box h={rem(16)}>
|
||||
<Divider orientation="vertical" h="100%" />
|
||||
</Box>
|
||||
)}
|
||||
<ActionIcon
|
||||
size="md"
|
||||
onClick={undo}
|
||||
disabled={shouldDisableActions}
|
||||
loading={isUndoLoading}
|
||||
>
|
||||
<Icon name="undo" tooltip={t`Undo changes`} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
onClick={redo}
|
||||
disabled={shouldDisableActions}
|
||||
loading={isRedoLoading}
|
||||
>
|
||||
<Icon name="redo" tooltip={t`Redo changes`} />
|
||||
</ActionIcon>
|
||||
{hasCreateAction && (
|
||||
<>
|
||||
<Box h={rem(16)}>
|
||||
<Divider orientation="vertical" h="100%" />
|
||||
</Box>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
onClick={openCreateRowModal}
|
||||
disabled={shouldDisableActions}
|
||||
>
|
||||
<Icon name="add" tooltip={t`New record`} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Flex>
|
||||
{data.rows.length === 0 ? (
|
||||
<Stack
|
||||
h="100%"
|
||||
justify="center"
|
||||
align="center"
|
||||
c="var(--mb-color-text-tertiary)"
|
||||
>
|
||||
<NoDataError data-testid="no-results-image" />
|
||||
<ShortMessage>{t`No results!`}</ShortMessage>
|
||||
<Button
|
||||
leftSection={<Icon name="add" />}
|
||||
variant="filled"
|
||||
onClick={openCreateRowModal}
|
||||
disabled={shouldDisableActions}
|
||||
>{t`New Record`}</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Box pos="relative" className={S.gridWrapper}>
|
||||
<EditTableDataOverlay
|
||||
show={shouldDisableActions}
|
||||
message={currentActionLabel ?? ""}
|
||||
/>
|
||||
<EditTableDataGrid
|
||||
data={data}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
cellsWithFailedUpdatesMap={cellsWithFailedUpdatesMap}
|
||||
onCellValueUpdate={handleCellValueUpdate}
|
||||
onRowExpandClick={openEditRowModal}
|
||||
columnsConfig={columnsConfig}
|
||||
getColumnSortDirection={getColumnSortDirection}
|
||||
rowActions={tableActions}
|
||||
onActionRun={handleTableActionRun}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Flex
|
||||
p="xs"
|
||||
px="1rem"
|
||||
justify="flex-end"
|
||||
align="center"
|
||||
className={S.gridFooterDashcardVisualization}
|
||||
>
|
||||
<Text fz="sm" fw="bold">
|
||||
{getEditTableRowCountMessage(data)}
|
||||
</Text>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
<EditingBaseRowModal
|
||||
modalState={modalState}
|
||||
onClose={closeModal}
|
||||
hasDeleteAction={hasDeleteAction}
|
||||
onEdit={handleRowUpdate}
|
||||
onRowCreate={handleRowCreate}
|
||||
onRowDelete={handleRowDelete}
|
||||
datasetColumns={data.cols}
|
||||
currentRowData={
|
||||
modalState.rowIndex !== undefined
|
||||
? data.rows[modalState.rowIndex]
|
||||
: undefined
|
||||
}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
isLoading={isInserting}
|
||||
columnsConfig={columnsConfig}
|
||||
/>
|
||||
<EditBulkRowsModal
|
||||
opened={isBulkEditingRequested}
|
||||
datasetColumns={data.cols}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
hasDeleteAction={hasDeleteAction}
|
||||
columnsConfig={columnsConfig}
|
||||
onClose={closeBulkEditing}
|
||||
onEdit={handleRowUpdateBulk}
|
||||
onDelete={handleRowDeleteBulk}
|
||||
isDeleting={isDeleting}
|
||||
selectedRowIndices={selectedRowIndices}
|
||||
setRowSelection={setRowSelection}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={isActionExecuteModalOpen}
|
||||
onClose={handleExecuteActionModalClose}
|
||||
>
|
||||
{selectedTableActionState && (
|
||||
<TableActionExecuteModalContent
|
||||
actionId={selectedTableActionState.actionId}
|
||||
initialValues={selectedTableActionState.rowData}
|
||||
actionOverrides={selectedTableActionState.actionOverrides}
|
||||
onClose={handleExecuteActionModalClose}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
<DeleteBulkRowConfirmationModal
|
||||
opened={isDeleteBulkRequested}
|
||||
rowCount={selectedRowIndices.length}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={onDeleteBulkConfirmation}
|
||||
onClose={cancelDeleteBulk}
|
||||
/>
|
||||
{withLeaveUnsavedConfirmation && (
|
||||
<UnsavedLeaveConfirmationModal
|
||||
isUpdating={isUpdating}
|
||||
isDeleting={isDeleting}
|
||||
isInserting={isInserting}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
EditTableDashcardVisualization.displayName = "EditTableDashcardVisualization";
|
||||
|
||||
function getEditTableRowCountMessage(data: DatasetData): string {
|
||||
const rowCount = data.rows.length;
|
||||
|
||||
if (data.rows_truncated > 0) {
|
||||
return t`Showing first ${formatRowCount(rowCount)}`;
|
||||
}
|
||||
if (rowCount === HARD_ROW_LIMIT) {
|
||||
return t`Showing first ${HARD_ROW_LIMIT} rows`;
|
||||
}
|
||||
return t`Showing ${formatRowCount(rowCount)}`;
|
||||
}
|
||||
|
||||
const MODAL_COMPOSITE_OBJECT_ID_SEPARATOR = "_";
|
||||
function getModalCompositeObjectId(objectId: string, dashcardId: number) {
|
||||
return `${dashcardId}${MODAL_COMPOSITE_OBJECT_ID_SEPARATOR}${objectId}`;
|
||||
}
|
||||
|
||||
function parseModalCompositeObjectId(compositeObjectId: string | null) {
|
||||
if (!compositeObjectId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// objectId can contain separator symbol itself, so we should slice the first part
|
||||
const separatorIndex = compositeObjectId.indexOf(
|
||||
MODAL_COMPOSITE_OBJECT_ID_SEPARATOR,
|
||||
);
|
||||
if (separatorIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const dashcardId = compositeObjectId.slice(0, separatorIndex);
|
||||
const objectId = compositeObjectId.slice(separatorIndex + 1);
|
||||
|
||||
return { dashcardId: parseInt(dashcardId), objectId };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
.container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableRoot {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridWrapper {
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.gridFooter {
|
||||
border: 1px solid var(--mb-color-border);
|
||||
background-color: var(--mb-color-background);
|
||||
color: var(--mb-color-text-secondary);
|
||||
}
|
||||
|
||||
.gridFooterDashcardVisualization {
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
}
|
||||
|
||||
/** DataGrid overrides */
|
||||
|
||||
.tableRow {
|
||||
&:hover .tableBodyCell {
|
||||
background-color: var(--mb-base-color-blue-10);
|
||||
}
|
||||
|
||||
&:hover .cellWithUpdateFail {
|
||||
background-color: var(--mb-base-color-dubloon-5);
|
||||
}
|
||||
|
||||
&:hover .tableHeaderCell {
|
||||
/* Draggable hover highlight for `SortableHeader` */
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&[data-row-selected="true"] .tableBodyCell {
|
||||
background-color: var(--mb-base-color-blue-10);
|
||||
}
|
||||
}
|
||||
|
||||
.tableHeaderContainer {
|
||||
--cell-bg-color: var(--mb-base-color-orion-5);
|
||||
--cell-hover-bg-color: var(--mb-base-color-orion-5);
|
||||
|
||||
background-color: var(--mb-base-color-orion-5);
|
||||
}
|
||||
|
||||
.tableHeaderCell {
|
||||
border-right: 1px solid var(--mb-color-border);
|
||||
|
||||
&[data-column-id="__MB_ROW_SELECT"] {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tableBodyContainer {
|
||||
background-color: var(--mb-color-bg-white);
|
||||
}
|
||||
|
||||
.tableBodyCell {
|
||||
background-color: var(--mb-color-bg-white);
|
||||
border-right: 1px solid var(--mb-color-border-alpha-30);
|
||||
|
||||
&[data-column-id="__MB_ROW_SELECT"] {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.headerCellContainer {
|
||||
background-color: var(--cell-bg-color);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.sortIndicator {
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.readonlyCell {
|
||||
color: var(--mb-color-text-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cellWithUpdateFail {
|
||||
background-color: var(--mb-base-color-dubloon-5);
|
||||
outline: 2px solid var(--mb-base-color-dubloon-30);
|
||||
outline-offset: -2px;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Link } from "react-router";
|
||||
import { t } from "ttag";
|
||||
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { ActionIcon, Icon, Tooltip } from "metabase/ui";
|
||||
import type Table from "metabase-lib/v1/metadata/Table";
|
||||
import type { Table as TableApi } from "metabase-types/api";
|
||||
|
||||
export const EditTableDataBackButton = ({
|
||||
table,
|
||||
}: {
|
||||
table: Table | TableApi;
|
||||
}) => {
|
||||
const label = t`Back to ${table.display_name}`;
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<Link to={Urls.browseTable(table)}>
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="2.625rem"
|
||||
color="border"
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon c="text-dark" name="arrow_left" />
|
||||
</ActionIcon>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import type { Location } from "history";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import { useMount } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
skipToken,
|
||||
useGetAdhocQueryQuery,
|
||||
useGetDatabaseQuery,
|
||||
} from "metabase/api";
|
||||
import { GenericError } from "metabase/components/ErrorPages";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { closeNavbar } from "metabase/redux/app";
|
||||
import { Box, Flex, Stack, Text } from "metabase/ui";
|
||||
import { extractRemappedColumns } from "metabase/visualizations";
|
||||
import { isDatabaseTableEditingEnabled } from "metabase-enterprise/data_editing/settings";
|
||||
import { getRowCountMessage } from "metabase-lib/v1/queries/utils/row-count";
|
||||
|
||||
import S from "./EditTableData.module.css";
|
||||
import { EditTableDataGrid } from "./EditTableDataGrid";
|
||||
import { EditTableDataHeader } from "./EditTableDataHeader";
|
||||
import { EditTableDataOverlay } from "./EditTableDataOverlay";
|
||||
import { DeleteBulkRowConfirmationModal } from "./modals/DeleteBulkRowConfirmationModal";
|
||||
import { EditBulkRowsModal } from "./modals/EditBulkRowsModal";
|
||||
import { EditingBaseRowModal } from "./modals/EditingBaseRowModal";
|
||||
import { UnsavedLeaveConfirmationModal } from "./modals/UnsavedLeaveConfirmationModal";
|
||||
import { useTableBulkDeleteConfirmation } from "./modals/use-table-bulk-delete-confirmation";
|
||||
import { useTableEditingModalControllerWithObjectId } from "./modals/use-table-modal-with-object-id";
|
||||
import { getTableEditPathname } from "./url";
|
||||
import { useStandaloneTableQuery } from "./use-standalone-table-query";
|
||||
import { useTableCRUD } from "./use-table-crud";
|
||||
import { useEditingTableRowSelection } from "./use-table-row-selection";
|
||||
import { useTableSorting } from "./use-table-sorting";
|
||||
import { useTableEditingStateApiUpdateStrategy } from "./use-table-state-api-update-strategy";
|
||||
import { useTableEditingUndoRedo } from "./use-table-undo-redo";
|
||||
|
||||
type EditTableDataContainerProps = {
|
||||
params: {
|
||||
dbId: string;
|
||||
tableId: string;
|
||||
objectId?: string;
|
||||
};
|
||||
location: Location<{ filter?: string }>;
|
||||
};
|
||||
|
||||
export const EditTableDataContainer = ({
|
||||
params: { dbId: dbIdParam, tableId: tableIdParam, objectId: objectIdParam },
|
||||
location,
|
||||
}: EditTableDataContainerProps) => {
|
||||
const databaseId = parseInt(dbIdParam, 10);
|
||||
const tableId = parseInt(tableIdParam, 10);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { data: database } = useGetDatabaseQuery({ id: databaseId });
|
||||
|
||||
const { fakeTableQuestion, fakeTableQuery, table, handleQuestionChange } =
|
||||
useStandaloneTableQuery({ tableId, databaseId, location });
|
||||
|
||||
const {
|
||||
data: rawDatasetResult,
|
||||
isFetching,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useGetAdhocQueryQuery(fakeTableQuery || skipToken, {
|
||||
// Invalidates cache when filter changes (some records might be updated)
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
|
||||
const datasetData = useMemo(() => {
|
||||
return rawDatasetResult
|
||||
? extractRemappedColumns(rawDatasetResult.data)
|
||||
: undefined;
|
||||
}, [rawDatasetResult]);
|
||||
|
||||
const handleCurrentObjectIdChange = useCallback(
|
||||
(objectId?: string) => {
|
||||
dispatch(
|
||||
push({
|
||||
...location,
|
||||
pathname: getTableEditPathname(databaseId, tableId, objectId),
|
||||
}),
|
||||
);
|
||||
},
|
||||
[databaseId, tableId, location, dispatch],
|
||||
);
|
||||
|
||||
const {
|
||||
state: modalState,
|
||||
openCreateRowModal,
|
||||
openEditRowModal,
|
||||
closeModal,
|
||||
} = useTableEditingModalControllerWithObjectId({
|
||||
currentObjectId: objectIdParam,
|
||||
datasetData,
|
||||
onObjectIdChange: handleCurrentObjectIdChange,
|
||||
});
|
||||
|
||||
const stateUpdateStrategy =
|
||||
useTableEditingStateApiUpdateStrategy(fakeTableQuery);
|
||||
|
||||
const editingScope = useMemo(() => {
|
||||
return { "table-id": tableId };
|
||||
}, [tableId]);
|
||||
|
||||
const {
|
||||
isInserting,
|
||||
isDeleting,
|
||||
isUpdating,
|
||||
tableFieldMetadataMap,
|
||||
cellsWithFailedUpdatesMap,
|
||||
|
||||
handleCellValueUpdate,
|
||||
handleRowCreate,
|
||||
handleRowUpdate,
|
||||
handleRowUpdateBulk,
|
||||
handleRowDelete,
|
||||
handleRowDeleteBulk,
|
||||
} = useTableCRUD({
|
||||
tableId,
|
||||
scope: editingScope,
|
||||
datasetData,
|
||||
stateUpdateStrategy,
|
||||
});
|
||||
|
||||
const { undo, redo, isUndoLoading, isRedoLoading, currentActionLabel } =
|
||||
useTableEditingUndoRedo({
|
||||
tableId,
|
||||
scope: editingScope,
|
||||
stateUpdateStrategy,
|
||||
});
|
||||
|
||||
const { rowSelection, selectedRowIndices, setRowSelection } =
|
||||
useEditingTableRowSelection();
|
||||
|
||||
const {
|
||||
isDeleteBulkRequested,
|
||||
requestDeleteBulk,
|
||||
cancelDeleteBulk,
|
||||
onDeleteBulkConfirmation,
|
||||
} = useTableBulkDeleteConfirmation({
|
||||
handleRowDeleteBulk,
|
||||
selectedRowIndices,
|
||||
setRowSelection,
|
||||
});
|
||||
|
||||
const { getColumnSortDirection, handleChangeColumnSort } = useTableSorting({
|
||||
question: fakeTableQuestion,
|
||||
handleQuestionChange,
|
||||
});
|
||||
|
||||
const [
|
||||
isBulkEditingRequested,
|
||||
{ open: requestBulkEditing, close: closeBulkEditing },
|
||||
] = useDisclosure();
|
||||
|
||||
useMount(() => {
|
||||
dispatch(closeNavbar());
|
||||
});
|
||||
|
||||
// Do not trigger leave confirmation modals on modal URL change
|
||||
const handleIsLeaveLocationAllowed = useCallback(
|
||||
(location: Location | undefined) => {
|
||||
if (
|
||||
location?.pathname.startsWith(getTableEditPathname(databaseId, tableId))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[databaseId, tableId],
|
||||
);
|
||||
|
||||
if (!database || isLoading || !fakeTableQuestion) {
|
||||
// TODO: show loader
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!rawDatasetResult || !datasetData || !tableFieldMetadataMap) {
|
||||
// TODO: show error
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldDisableTable = isUndoLoading || isRedoLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack className={S.container} gap={0} data-testid="edit-table-data-root">
|
||||
{table && (
|
||||
<EditTableDataHeader
|
||||
database={database}
|
||||
table={table}
|
||||
question={fakeTableQuestion}
|
||||
isLoading={isFetching}
|
||||
isUndoLoading={isUndoLoading}
|
||||
isRedoLoading={isRedoLoading}
|
||||
selectedRowIndices={selectedRowIndices}
|
||||
onCreate={openCreateRowModal}
|
||||
onQuestionChange={handleQuestionChange}
|
||||
refetchTableDataQuery={refetch}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onRequestDeleteBulk={requestDeleteBulk}
|
||||
onRequestBulkEditing={requestBulkEditing}
|
||||
/>
|
||||
)}
|
||||
{isDatabaseTableEditingEnabled(database) ? (
|
||||
<>
|
||||
<Box pos="relative" className={S.gridWrapper}>
|
||||
<EditTableDataOverlay
|
||||
show={shouldDisableTable}
|
||||
message={currentActionLabel ?? ""}
|
||||
/>
|
||||
<EditTableDataGrid
|
||||
data={datasetData}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
cellsWithFailedUpdatesMap={cellsWithFailedUpdatesMap}
|
||||
getColumnSortDirection={getColumnSortDirection}
|
||||
onCellValueUpdate={handleCellValueUpdate}
|
||||
onRowExpandClick={openEditRowModal}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
rowSelection={rowSelection}
|
||||
onColumnSort={handleChangeColumnSort}
|
||||
/>
|
||||
</Box>
|
||||
<Flex
|
||||
py="0.5rem"
|
||||
px="1.5rem"
|
||||
h="2.5rem"
|
||||
justify="flex-end"
|
||||
align="center"
|
||||
className={S.gridFooter}
|
||||
>
|
||||
<Text fw="bold" size="md" c="inherit" component="span">
|
||||
{getRowCountMessage(rawDatasetResult)}
|
||||
</Text>
|
||||
</Flex>
|
||||
</>
|
||||
) : (
|
||||
<GenericError
|
||||
title={t`Table editing is not enabled for this database`}
|
||||
message={t`Please ask your admin to enable table editing`}
|
||||
details={undefined}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<EditingBaseRowModal
|
||||
modalState={modalState}
|
||||
onClose={closeModal}
|
||||
onEdit={handleRowUpdate}
|
||||
onRowCreate={handleRowCreate}
|
||||
onRowDelete={handleRowDelete}
|
||||
datasetColumns={datasetData.cols}
|
||||
currentRowData={
|
||||
modalState.rowIndex !== undefined
|
||||
? datasetData.rows[modalState.rowIndex]
|
||||
: undefined
|
||||
}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
isLoading={isInserting}
|
||||
hasDeleteAction
|
||||
/>
|
||||
<EditBulkRowsModal
|
||||
opened={isBulkEditingRequested}
|
||||
datasetColumns={datasetData.cols}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
onClose={closeBulkEditing}
|
||||
onEdit={handleRowUpdateBulk}
|
||||
onDelete={handleRowDeleteBulk}
|
||||
isDeleting={isDeleting}
|
||||
selectedRowIndices={selectedRowIndices}
|
||||
setRowSelection={setRowSelection}
|
||||
hasDeleteAction
|
||||
/>
|
||||
<DeleteBulkRowConfirmationModal
|
||||
opened={isDeleteBulkRequested}
|
||||
rowCount={selectedRowIndices.length}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={onDeleteBulkConfirmation}
|
||||
onClose={cancelDeleteBulk}
|
||||
/>
|
||||
<UnsavedLeaveConfirmationModal
|
||||
isUpdating={isUpdating}
|
||||
isDeleting={isDeleting}
|
||||
isInserting={isInserting}
|
||||
isLocationAllowed={handleIsLeaveLocationAllowed}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/* copied from metabase/query_builder/components/view/ViewHeader/ViewTitleHeader.module.css */
|
||||
|
||||
.FilterButton {
|
||||
&[data-variant="default"] {
|
||||
transition: background 300ms linear;
|
||||
|
||||
&:hover {
|
||||
color: var(--mb-color-filter);
|
||||
background-color: color-mix(in srgb, var(--mb-color-filter) 10%, white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.FilterButtonAttachment {
|
||||
&[data-variant="default"] {
|
||||
padding: 0.5rem;
|
||||
transition: background 300ms linear;
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(in srgb, var(--mb-color-filter) 10%, white);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-expanded="true"] {
|
||||
.FilterCountChip {
|
||||
color: var(--mb-color-text-selected);
|
||||
background-color: var(--mb-color-filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.FilterCountChip {
|
||||
font-size: 0.6875rem;
|
||||
border-radius: 10px;
|
||||
line-height: 1rem;
|
||||
padding-inline: 0.5rem;
|
||||
color: var(--mb-color-text-primary);
|
||||
background-color: color-mix(in srgb, var(--mb-color-filter) 15%, white);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import cx from "classnames";
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { getFilterItems } from "metabase/querying/filters/components/FilterPanel/utils";
|
||||
import { MultiStageFilterPicker } from "metabase/querying/filters/components/FilterPicker/MultiStageFilterPicker";
|
||||
import { Button, Icon, Popover, Tooltip } from "metabase/ui";
|
||||
import type * as Lib from "metabase-lib";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
|
||||
import ViewTitleHeaderS from "./EditTableDataFilterButton.module.css";
|
||||
|
||||
interface EditTableDataFilterButtonProps {
|
||||
className?: string;
|
||||
question: Question;
|
||||
isExpanded: boolean;
|
||||
onExpand: () => void;
|
||||
onCollapse: () => void;
|
||||
onQuestionChange: (newQuestion: Question) => void;
|
||||
}
|
||||
|
||||
// copied from metabase/query_builder/components/view/ViewHeader/components/FilterHeaderButton.tsx
|
||||
export function EditTableDataFilterButton({
|
||||
className,
|
||||
question,
|
||||
isExpanded,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onQuestionChange,
|
||||
}: EditTableDataFilterButtonProps) {
|
||||
const [isOpened, { close, toggle }] = useDisclosure();
|
||||
const query = question.query();
|
||||
const items = useMemo(() => (query ? getFilterItems(query) : []), [query]);
|
||||
const hasFilters = items.length > 0;
|
||||
const label = isExpanded ? t`Hide filters` : t`Show filters`;
|
||||
|
||||
const handleQueryChange = (newQuery: Lib.Query) => {
|
||||
const newQuestion = question.setQuery(newQuery);
|
||||
onQuestionChange(newQuestion);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button.Group>
|
||||
<Popover opened={isOpened} position="bottom-start" onDismiss={close}>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
className={cx(className, ViewTitleHeaderS.FilterButton)}
|
||||
leftSection={<Icon name={hasFilters ? "filter_plus" : "filter"} />}
|
||||
onClick={toggle}
|
||||
data-testid="table-edit-filter-header"
|
||||
>
|
||||
{t`Filter`}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<MultiStageFilterPicker
|
||||
query={query}
|
||||
canAppendStage={false}
|
||||
onChange={handleQueryChange}
|
||||
onClose={close}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
{hasFilters && (
|
||||
<Tooltip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className={ViewTitleHeaderS.FilterButtonAttachment}
|
||||
onClick={isExpanded ? onCollapse : onExpand}
|
||||
data-testid="filters-visibility-control"
|
||||
data-expanded={isExpanded}
|
||||
style={{ borderLeft: "none" }} // mantine puts a double border between buttons in groups
|
||||
>
|
||||
<div className={ViewTitleHeaderS.FilterCountChip}>
|
||||
{items?.length}
|
||||
</div>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button.Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { OnChangeFn, Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { Ellipsified } from "metabase/core/components/Ellipsified";
|
||||
import {
|
||||
type ColumnOptions,
|
||||
DataGrid,
|
||||
type RowIdColumnOptions,
|
||||
useDataGridInstance,
|
||||
} from "metabase/data-grid";
|
||||
import { ROW_ID_COLUMN_ID } from "metabase/data-grid/constants";
|
||||
import { formatValue } from "metabase/lib/formatting/value";
|
||||
import { Box, Icon } from "metabase/ui";
|
||||
import type { OrderByDirection } from "metabase-lib";
|
||||
import { isPK } from "metabase-lib/v1/types/utils/isa";
|
||||
import type {
|
||||
DatasetColumn,
|
||||
DatasetData,
|
||||
Field,
|
||||
FieldWithMetadata,
|
||||
RowPkValue,
|
||||
RowValue,
|
||||
RowValues,
|
||||
UpdateCellValueHandlerParams,
|
||||
WritebackAction,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { canEditField } from "../../helpers";
|
||||
|
||||
import S from "./EditTableData.module.css";
|
||||
import { EditingBodyCellWrapper } from "./EditingBodyCell";
|
||||
import type { EditableTableColumnConfig } from "./use-editable-column-config";
|
||||
import {
|
||||
ROW_SELECT_COLUMN_ID,
|
||||
useTableColumnRowSelect,
|
||||
} from "./use-table-column-row-select";
|
||||
import { useTableEditing } from "./use-table-editing";
|
||||
import { getCellUniqKey } from "./utils";
|
||||
|
||||
type EditTableDataGridProps = {
|
||||
data: DatasetData;
|
||||
fieldMetadataMap: Record<FieldWithMetadata["name"], FieldWithMetadata>;
|
||||
onCellValueUpdate: (params: UpdateCellValueHandlerParams) => void;
|
||||
onRowExpandClick: (rowIndex: number) => void;
|
||||
columnsConfig?: EditableTableColumnConfig;
|
||||
getColumnSortDirection?: (
|
||||
column: DatasetColumn,
|
||||
) => OrderByDirection | undefined;
|
||||
cellsWithFailedUpdatesMap?: Record<RowPkValue, true>;
|
||||
rowActions?: WritebackAction[];
|
||||
onActionRun?: (action: WritebackAction, row: Row<RowValues>) => void;
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
|
||||
onColumnSort?: (field: Field) => void;
|
||||
};
|
||||
|
||||
export const EditTableDataGrid = ({
|
||||
data,
|
||||
fieldMetadataMap,
|
||||
onCellValueUpdate,
|
||||
onRowExpandClick,
|
||||
columnsConfig,
|
||||
getColumnSortDirection,
|
||||
cellsWithFailedUpdatesMap,
|
||||
rowActions,
|
||||
onActionRun,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
onColumnSort,
|
||||
}: EditTableDataGridProps) => {
|
||||
const { cols, rows } = data;
|
||||
|
||||
const { editingCellId, onCellClickToEdit, onCellEditCancel } =
|
||||
useTableEditing();
|
||||
|
||||
const columnOrder = useMemo(
|
||||
() =>
|
||||
columnsConfig?.columnOrder.length
|
||||
? [ROW_SELECT_COLUMN_ID, ...columnsConfig.columnOrder]
|
||||
: [ROW_SELECT_COLUMN_ID, ...cols.map(({ name }) => name)],
|
||||
[cols, columnsConfig],
|
||||
);
|
||||
|
||||
const columnVisibility = useMemo(
|
||||
() => (columnsConfig ? columnsConfig.columnVisibilityMap : undefined),
|
||||
[columnsConfig],
|
||||
);
|
||||
|
||||
const columnSizingMap = useMemo(() => ({}), []);
|
||||
|
||||
const columnsOptions: ColumnOptions<RowValues, RowValue>[] = useMemo(() => {
|
||||
const pkColumnIndex = cols.findIndex(isPK);
|
||||
|
||||
return cols.map((column, columnIndex) => {
|
||||
const isEditableColumn =
|
||||
!columnsConfig || !columnsConfig.isColumnReadonly(column.name);
|
||||
|
||||
const sortDirection = getColumnSortDirection?.(column);
|
||||
|
||||
const options: ColumnOptions<RowValues, RowValue> = {
|
||||
id: column.name,
|
||||
name: column.display_name,
|
||||
accessorFn: (row: RowValues) => row[columnIndex],
|
||||
formatter: (value) => formatValue(value, { column }),
|
||||
wrap: false,
|
||||
sortDirection,
|
||||
header: function EditingHeader() {
|
||||
return (
|
||||
<Box className={S.headerCellContainer}>
|
||||
<Ellipsified>{column.display_name}</Ellipsified>
|
||||
{sortDirection != null ? (
|
||||
<Icon
|
||||
className={S.sortIndicator}
|
||||
data-testid="header-sort-indicator"
|
||||
name={sortDirection === "asc" ? "chevronup" : "chevrondown"}
|
||||
size={10}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
editingCell: (cellContext) => (
|
||||
<EditingBodyCellWrapper
|
||||
cellContext={cellContext}
|
||||
column={column}
|
||||
field={fieldMetadataMap?.[column.name]}
|
||||
onCellValueUpdate={onCellValueUpdate}
|
||||
onCellEditCancel={onCellEditCancel}
|
||||
/>
|
||||
),
|
||||
getIsCellEditing: (cellId: string) => editingCellId === cellId,
|
||||
getCellClassNameByCellId: (cellContext) => {
|
||||
const rowIndex = cellContext.row.index;
|
||||
const columnName = cellContext.column.id;
|
||||
const rowData = rows[rowIndex];
|
||||
const rowPkValue = rowData[pkColumnIndex] as RowPkValue;
|
||||
|
||||
const cellUniqKey = getCellUniqKey(rowPkValue, columnName);
|
||||
|
||||
return cellsWithFailedUpdatesMap?.[cellUniqKey]
|
||||
? S.cellWithUpdateFail
|
||||
: undefined;
|
||||
},
|
||||
};
|
||||
|
||||
if (!isEditableColumn) {
|
||||
options.getCellClassName = () => S.readonlyCell;
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
}, [
|
||||
cols,
|
||||
columnsConfig,
|
||||
getColumnSortDirection,
|
||||
fieldMetadataMap,
|
||||
onCellValueUpdate,
|
||||
onCellEditCancel,
|
||||
editingCellId,
|
||||
rows,
|
||||
cellsWithFailedUpdatesMap,
|
||||
]);
|
||||
|
||||
const rowId: RowIdColumnOptions = useMemo(
|
||||
() => ({
|
||||
variant: "expandButton",
|
||||
onRowExpandClick,
|
||||
}),
|
||||
[onRowExpandClick],
|
||||
);
|
||||
|
||||
const columnRowSelectOptions = useTableColumnRowSelect();
|
||||
|
||||
const tableProps = useDataGridInstance({
|
||||
data: rows,
|
||||
rowId,
|
||||
columnOrder,
|
||||
columnSizingMap,
|
||||
columnsOptions,
|
||||
columnVisibility,
|
||||
columnPinning: { left: [ROW_SELECT_COLUMN_ID, ROW_ID_COLUMN_ID] },
|
||||
enableRowSelection: true,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
columnRowSelectOptions: columnRowSelectOptions,
|
||||
rowActionsColumn:
|
||||
rowActions?.length && onActionRun
|
||||
? { actions: rowActions, onActionRun }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const handleBodyCellClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
{
|
||||
cellId,
|
||||
columnId,
|
||||
}: {
|
||||
cellId: string;
|
||||
columnId: string;
|
||||
},
|
||||
) => {
|
||||
const field = fieldMetadataMap?.[columnId];
|
||||
|
||||
if (columnsConfig && columnsConfig.isColumnReadonly(columnId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disables editing for some columns, such as primary keys
|
||||
if (!canEditField(field)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevents event from bubbling up inside editing cell
|
||||
// Otherwise requires special handling in EditingBodyCell
|
||||
if (editingCellId !== cellId) {
|
||||
onCellClickToEdit(cellId);
|
||||
}
|
||||
},
|
||||
[onCellClickToEdit, editingCellId, fieldMetadataMap, columnsConfig],
|
||||
);
|
||||
|
||||
const handleHeaderCellClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>, columnId?: string) => {
|
||||
if (columnId) {
|
||||
const field = fieldMetadataMap?.[columnId];
|
||||
|
||||
if (field) {
|
||||
onColumnSort?.(field);
|
||||
}
|
||||
}
|
||||
},
|
||||
[fieldMetadataMap, onColumnSort],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
{...tableProps}
|
||||
classNames={{
|
||||
headerContainer: S.tableHeaderContainer,
|
||||
headerCell: S.tableHeaderCell,
|
||||
bodyContainer: S.tableBodyContainer,
|
||||
bodyCell: S.tableBodyCell,
|
||||
row: S.tableRow,
|
||||
}}
|
||||
styles={{
|
||||
// Overrides theme constants and default white bg
|
||||
bodyCell: {
|
||||
backgroundColor: undefined,
|
||||
},
|
||||
}}
|
||||
theme={{ cell: { backgroundColor: "" } }}
|
||||
onBodyCellClick={handleBodyCellClick}
|
||||
onHeaderCellClick={handleHeaderCellClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import RunButtonWithTooltip from "metabase/query_builder/components/RunButtonWithTooltip"; // TODO: we should not use query builder components
|
||||
import { QuestionFiltersHeader } from "metabase/query_builder/components/view/ViewHeader/components"; // TODO: we should not use query builder components
|
||||
import { getFilterItems } from "metabase/querying/filters/components/FilterPanel/utils";
|
||||
import { ActionIcon, Button, Flex, Group, Icon, Stack } from "metabase/ui";
|
||||
import { TableNotificationsTrigger } from "metabase-enterprise/data_editing/alerts/TableNotificationsModals/TableNotificationsTrigger/TableNotificationsTrigger";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import type TableV1 from "metabase-lib/v1/metadata/Table";
|
||||
import type { Database } from "metabase-types/api";
|
||||
|
||||
import { TableBreadcrumbs } from "../common/TableBreadcrumbs";
|
||||
|
||||
import { EditTableDataFilterButton } from "./EditTableDataFilterButton";
|
||||
|
||||
interface EditTableDataHeaderProps {
|
||||
database: Database;
|
||||
table: TableV1;
|
||||
question: Question;
|
||||
isLoading: boolean;
|
||||
isUndoLoading: boolean;
|
||||
isRedoLoading: boolean;
|
||||
selectedRowIndices: number[];
|
||||
onCreate: () => void;
|
||||
onQuestionChange: (newQuestion: Question) => void;
|
||||
refetchTableDataQuery: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onRequestDeleteBulk: () => void;
|
||||
onRequestBulkEditing: () => void;
|
||||
}
|
||||
|
||||
export const EditTableDataHeader = ({
|
||||
database,
|
||||
table,
|
||||
question,
|
||||
isLoading,
|
||||
isUndoLoading,
|
||||
isRedoLoading,
|
||||
selectedRowIndices,
|
||||
onCreate,
|
||||
onQuestionChange,
|
||||
refetchTableDataQuery,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onRequestDeleteBulk,
|
||||
onRequestBulkEditing,
|
||||
}: EditTableDataHeaderProps) => {
|
||||
const hasFilters = useMemo(
|
||||
() =>
|
||||
question.query() ? getFilterItems(question.query()).length > 0 : false,
|
||||
[question],
|
||||
);
|
||||
const [
|
||||
areFiltersExpanded,
|
||||
{ open: onExpandFilters, close: onCollapseFilters },
|
||||
] = useDisclosure(hasFilters);
|
||||
|
||||
const shouldDisableActions = isUndoLoading || isRedoLoading;
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p="0.5rem 1rem 0.5rem 2rem"
|
||||
mih="4rem"
|
||||
bg="var(--mb-color-background)"
|
||||
data-testid="table-data-view-header"
|
||||
>
|
||||
<TableBreadcrumbs database={database} table={table} isEditMode />
|
||||
|
||||
<Group>
|
||||
<EditTableDataFilterButton
|
||||
question={question}
|
||||
isExpanded={areFiltersExpanded}
|
||||
onExpand={onExpandFilters}
|
||||
onCollapse={onCollapseFilters}
|
||||
onQuestionChange={onQuestionChange}
|
||||
/>
|
||||
<Button
|
||||
leftSection={<Icon name="add" />}
|
||||
variant="filled"
|
||||
onClick={() => onCreate()}
|
||||
disabled={shouldDisableActions}
|
||||
>{t`New record`}</Button>
|
||||
<Button
|
||||
leftSection={<Icon name="pencil" />}
|
||||
onClick={onRequestBulkEditing}
|
||||
disabled={shouldDisableActions || !selectedRowIndices.length}
|
||||
>{t`Edit`}</Button>
|
||||
<Button
|
||||
leftSection={<Icon name="trash" />}
|
||||
variant="filled"
|
||||
color="error"
|
||||
onClick={onRequestDeleteBulk}
|
||||
disabled={shouldDisableActions || !selectedRowIndices.length}
|
||||
>{t`Delete`}</Button>
|
||||
<Flex gap="xs">
|
||||
<RunButtonWithTooltip
|
||||
iconSize={16}
|
||||
onlyIcon
|
||||
medium
|
||||
compact
|
||||
isRunning={isLoading}
|
||||
onRun={refetchTableDataQuery}
|
||||
/>
|
||||
<TableNotificationsTrigger tableId={table.id} />
|
||||
<ActionIcon
|
||||
onClick={onUndo}
|
||||
size="lg"
|
||||
loading={isUndoLoading}
|
||||
disabled={shouldDisableActions}
|
||||
>
|
||||
<Icon name="undo" tooltip={t`Undo changes`} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
onClick={onRedo}
|
||||
size="lg"
|
||||
loading={isRedoLoading}
|
||||
disabled={shouldDisableActions}
|
||||
>
|
||||
<Icon name="redo" tooltip={t`Redo changes`} />
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
</Group>
|
||||
</Group>
|
||||
<QuestionFiltersHeader
|
||||
expanded={areFiltersExpanded}
|
||||
question={question}
|
||||
updateQuestion={onQuestionChange}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
/* +1 for DataGrid `headerContainer` (which has z-index: 2) */
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
color: var(--mb-color-brand);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--mb-color-bg-white) 75%,
|
||||
transparent
|
||||
);
|
||||
transition:
|
||||
opacity 0.5s,
|
||||
background-color 0.5s;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--mb-color-brand);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Flex, Loader, Text } from "metabase/ui";
|
||||
|
||||
import S from "./EditTableDataOverlay.module.css";
|
||||
|
||||
type EditTableDataOverlayProps = {
|
||||
message: string;
|
||||
show?: boolean;
|
||||
};
|
||||
|
||||
export function EditTableDataOverlay({
|
||||
show = true,
|
||||
message,
|
||||
}: EditTableDataOverlayProps) {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex className={S.overlay}>
|
||||
<Loader size="lg" color="currentColor" />
|
||||
<Text className={S.message}>{message}</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.inlineEditingTextInput {
|
||||
transition: none;
|
||||
outline: 2px solid var(--mb-base-color-blue-50);
|
||||
outline-offset: -2px;
|
||||
border-color: transparent;
|
||||
box-sizing: border-box;
|
||||
font-weight: 700;
|
||||
background-color: var(--mb-base-color-white);
|
||||
border-radius: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
/* Should be in sync with DEFAULT_FONT_SIZE */
|
||||
font-size: 12.5px;
|
||||
|
||||
/* Properly alignment with BaseCell, to avoid text flickering (BaseCell has only border-bottom 1px) */
|
||||
border-top-width: 0;
|
||||
|
||||
/* Keep outline consistent for inline editing, since focused and non focused state is semantically the same */
|
||||
&:focus {
|
||||
outline: 2px solid var(--mb-base-color-blue-50);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { CellContext } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type {
|
||||
DatasetColumn,
|
||||
FieldWithMetadata,
|
||||
RowValue,
|
||||
RowValues,
|
||||
UpdateCellValueHandlerParams,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import S from "./EditingBodyCell.module.css";
|
||||
import { EditingBodyCellConditional } from "./inputs";
|
||||
|
||||
interface EditingBodyCellWrapperProps<TRow, TValue> {
|
||||
column: DatasetColumn;
|
||||
field?: FieldWithMetadata;
|
||||
cellContext: CellContext<TRow, TValue>;
|
||||
onCellValueUpdate: (params: UpdateCellValueHandlerParams) => void;
|
||||
onCellEditCancel: () => void;
|
||||
}
|
||||
|
||||
export const EditingBodyCellWrapper = (
|
||||
props: EditingBodyCellWrapperProps<RowValues, RowValue>,
|
||||
) => {
|
||||
const {
|
||||
onCellEditCancel,
|
||||
onCellValueUpdate,
|
||||
column,
|
||||
field,
|
||||
cellContext: {
|
||||
getValue,
|
||||
column: { id: columnName },
|
||||
row: { index: rowIndex },
|
||||
},
|
||||
} = props;
|
||||
|
||||
const initialValue = getValue<RowValue>();
|
||||
|
||||
const doCellValueUpdate = useCallback(
|
||||
(value: RowValue) => {
|
||||
if (value !== initialValue) {
|
||||
onCellValueUpdate({
|
||||
updatedData: { [columnName]: value },
|
||||
rowIndex,
|
||||
columnName,
|
||||
});
|
||||
}
|
||||
|
||||
// Hide the editing cell after submitting the value
|
||||
onCellEditCancel();
|
||||
},
|
||||
[initialValue, onCellEditCancel, onCellValueUpdate, columnName, rowIndex],
|
||||
);
|
||||
|
||||
return (
|
||||
<EditingBodyCellConditional
|
||||
autoFocus
|
||||
inputProps={{
|
||||
variant: "unstyled",
|
||||
size: "sm",
|
||||
}}
|
||||
classNames={{
|
||||
textInputElement: S.inlineEditingTextInput,
|
||||
selectTextInputElement: S.inlineEditingTextInput,
|
||||
dateTextInputElement: S.inlineEditingTextInput,
|
||||
}}
|
||||
field={field}
|
||||
initialValue={initialValue}
|
||||
datasetColumn={column}
|
||||
onSubmit={doCellValueUpdate}
|
||||
onCancel={onCellEditCancel}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Button, Group, Icon, Text } from "metabase/ui";
|
||||
|
||||
import { getUpdateApiErrorMessage } from "./utils";
|
||||
|
||||
type ErrorUpdateToastProps = {
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export const ErrorUpdateToast = ({ error }: ErrorUpdateToastProps) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
const errorMessage = getUpdateApiErrorMessage(error);
|
||||
|
||||
if (showDetails) {
|
||||
return (
|
||||
<Text c="text-white">
|
||||
{t`Couldn't save table changes:`}
|
||||
<br />
|
||||
{errorMessage}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap="2.5rem">
|
||||
<Group gap="0.5rem">
|
||||
<Icon name="warning" c="danger" size={12} />
|
||||
<Text c="text-white" fw={700}>{t`Couldn't save table changes`}</Text>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
size="compact-lg"
|
||||
color="var(--mb-base-color-orion-80)"
|
||||
variant="filled"
|
||||
autoContrast
|
||||
radius="0.5rem"
|
||||
onClick={() => setShowDetails(true)}
|
||||
>{t`More info`}</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { EditTableDataContainer } from "./EditTableDataContainer";
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Input } from "metabase/ui";
|
||||
|
||||
import type { EditingBodyPrimitiveProps } from "./types";
|
||||
|
||||
export const EditingBodyCellBasicInput = ({
|
||||
autoFocus,
|
||||
inputProps,
|
||||
initialValue,
|
||||
classNames,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onChangeValue,
|
||||
}: EditingBodyPrimitiveProps) => {
|
||||
return (
|
||||
<Input
|
||||
defaultValue={(initialValue ?? "").toString()}
|
||||
autoFocus={autoFocus}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
onCancel();
|
||||
} else if (event.key === "Enter") {
|
||||
onSubmit(event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => {
|
||||
// Convert empty string to null
|
||||
onChangeValue?.(event.currentTarget.value || null);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onSubmit(event.currentTarget.value || null);
|
||||
}}
|
||||
classNames={{
|
||||
wrapper: classNames?.wrapper,
|
||||
input: classNames?.textInputElement,
|
||||
}}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useSearchFieldValuesQuery } from "metabase/api";
|
||||
import { useDebouncedValue } from "metabase/hooks/use-debounced-value";
|
||||
import { getFieldOptions } from "metabase/querying/filters/components/FilterValuePicker/utils";
|
||||
import {
|
||||
Box,
|
||||
Combobox,
|
||||
Icon,
|
||||
Input,
|
||||
Loader,
|
||||
type SelectOption,
|
||||
TextInput,
|
||||
useCombobox,
|
||||
} from "metabase/ui";
|
||||
import type { RemappingHydratedDatasetColumn } from "metabase/visualizations/types";
|
||||
import type { FieldValue } from "metabase-types/api";
|
||||
|
||||
import type { EditingBodyPrimitiveProps } from "./types";
|
||||
|
||||
type EditingBodyCellCategorySelectProps = EditingBodyPrimitiveProps & {
|
||||
withCreateNew?: boolean;
|
||||
getDropdownLabelText?: (item: SelectOption) => string;
|
||||
getSelectedLabelText?: (item: SelectOption) => string;
|
||||
};
|
||||
|
||||
const DefaultItemLabelTextGetter = (item: SelectOption) => item.label;
|
||||
const DefaultSelectedLabelTextGetter = (item: SelectOption) => item.value;
|
||||
|
||||
export const EditingBodyCellCategorySelect = ({
|
||||
autoFocus,
|
||||
inputProps,
|
||||
initialValue,
|
||||
datasetColumn,
|
||||
withCreateNew = true,
|
||||
classNames,
|
||||
field,
|
||||
getDropdownLabelText = DefaultItemLabelTextGetter,
|
||||
getSelectedLabelText = DefaultSelectedLabelTextGetter,
|
||||
onSubmit,
|
||||
onChangeValue,
|
||||
onCancel,
|
||||
}: EditingBodyCellCategorySelectProps) => {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(initialValue?.toString() ?? "");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const focusSearchInput = useCallback(() => {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
const combobox = useCombobox({
|
||||
defaultOpened: autoFocus,
|
||||
onDropdownClose: onCancel,
|
||||
onDropdownOpen: focusSearchInput,
|
||||
});
|
||||
|
||||
const { options, isLoading, isFetching } = useCategorySelectSearchOptions({
|
||||
search,
|
||||
fieldId: datasetColumn?.id,
|
||||
// Mostly used for FKs, e.g. an ID is remapped to a name
|
||||
searchFieldId: datasetColumn.remapped_to_column?.id ?? datasetColumn.id,
|
||||
getDropdownLabelText,
|
||||
});
|
||||
|
||||
const handleOptionSubmit = useCallback(
|
||||
(value: string | null) => {
|
||||
setValue(value ?? "");
|
||||
onSubmit(value);
|
||||
onChangeValue?.(value);
|
||||
combobox.toggleDropdown();
|
||||
},
|
||||
[onSubmit, setValue, onChangeValue, combobox],
|
||||
);
|
||||
|
||||
const optionValueToOptionMap = useMemo(
|
||||
() =>
|
||||
options.reduce(
|
||||
(map, item) => ({
|
||||
...map,
|
||||
[item.value]: item,
|
||||
}),
|
||||
{} as Record<string, SelectOption>,
|
||||
),
|
||||
[options],
|
||||
);
|
||||
|
||||
const inputLabel = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Input.Placeholder c="text-light">{t`Loading...`}</Input.Placeholder>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value && inputProps?.placeholder) {
|
||||
return (
|
||||
<Input.Placeholder c="text-light">
|
||||
{inputProps.placeholder}
|
||||
</Input.Placeholder>
|
||||
);
|
||||
}
|
||||
|
||||
if (value) {
|
||||
// Lookup ad-hoc search results (e.g. might be different from the initial value)
|
||||
if (value in optionValueToOptionMap) {
|
||||
return getSelectedLabelText(optionValueToOptionMap[value]);
|
||||
}
|
||||
|
||||
// Lookup column remapped values (usually when FK value is not presented in the current search results)
|
||||
const maybeHydratedDatasetColumn =
|
||||
datasetColumn as RemappingHydratedDatasetColumn;
|
||||
const intValue = parseInt(value, 10);
|
||||
if (
|
||||
maybeHydratedDatasetColumn.remapping?.has(value) ||
|
||||
maybeHydratedDatasetColumn.remapping?.has(intValue)
|
||||
) {
|
||||
const remappedValue =
|
||||
maybeHydratedDatasetColumn.remapping.get(value) ??
|
||||
maybeHydratedDatasetColumn.remapping.get(intValue);
|
||||
|
||||
return getSelectedLabelText({
|
||||
value: value,
|
||||
label: remappedValue,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to the raw value instead of a label
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
isLoading,
|
||||
value,
|
||||
optionValueToOptionMap,
|
||||
inputProps?.placeholder,
|
||||
getSelectedLabelText,
|
||||
datasetColumn,
|
||||
]);
|
||||
|
||||
const isNullable = field?.database_is_nullable;
|
||||
const shouldDisplayClearButton =
|
||||
isNullable && !!value && !inputProps?.disabled;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
position="bottom-start"
|
||||
onOptionSubmit={handleOptionSubmit}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<Input
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
onClick={() => combobox.openDropdown()}
|
||||
classNames={{
|
||||
wrapper: classNames?.wrapper,
|
||||
input: classNames?.selectTextInputElement,
|
||||
}}
|
||||
rightSectionPointerEvents="all"
|
||||
rightSection={
|
||||
shouldDisplayClearButton && (
|
||||
<Icon
|
||||
name="close"
|
||||
color="var(--mb-color-text-light)"
|
||||
onClick={() => handleOptionSubmit(null)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...inputProps}
|
||||
>
|
||||
{inputLabel}
|
||||
</Input>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown mah="none" miw={250}>
|
||||
<Box p="0.5rem" pb="0" bg="white" pos="sticky" top={0}>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
placeholder={t`Search the list`}
|
||||
leftSection={<Icon name="search" />}
|
||||
rightSection={isFetching ? <Loader size="xs" /> : undefined}
|
||||
// Auto focus works when dropdown is initially rendered as opened (e.g. inline editing)
|
||||
// whereas `onDropdownOpen` is called only when dropdown is opened by clicking the button (e.g. modal editing)
|
||||
autoFocus
|
||||
ref={searchInputRef}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Combobox.Options p="0.5rem">
|
||||
{options.length > 0 ? (
|
||||
options.map((item) => (
|
||||
<Combobox.Option
|
||||
selected={value === item.value}
|
||||
value={item.value}
|
||||
key={item.value}
|
||||
>
|
||||
{getDropdownLabelText(item)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : isLoading ? (
|
||||
<Combobox.Empty>{t`Loading values...`}</Combobox.Empty>
|
||||
) : withCreateNew ? (
|
||||
<Combobox.Option value={search}>
|
||||
{t`Add option:`} <strong>{search}</strong>
|
||||
</Combobox.Option>
|
||||
) : (
|
||||
<Combobox.Empty>{t`Nothing found`}</Combobox.Empty>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
const SEARCH_LIMIT_DEFAULT = 20;
|
||||
const SEARCH_DEBOUNCE = 500;
|
||||
|
||||
type UseCategorySelectSearchOptionsProps = {
|
||||
search: string;
|
||||
fieldId?: number;
|
||||
searchFieldId?: number;
|
||||
limit?: number;
|
||||
getDropdownLabelText: (item: SelectOption) => string;
|
||||
};
|
||||
|
||||
function useCategorySelectSearchOptions({
|
||||
search,
|
||||
fieldId,
|
||||
searchFieldId,
|
||||
limit = SEARCH_LIMIT_DEFAULT,
|
||||
getDropdownLabelText,
|
||||
}: UseCategorySelectSearchOptionsProps) {
|
||||
const debouncedSearch = useDebouncedValue(search, SEARCH_DEBOUNCE);
|
||||
const [searchValueToFetch, setSearchValueToFetch] = useState(debouncedSearch);
|
||||
|
||||
// Values that are fetched without search value (all values with some limit)
|
||||
const [initialFieldValues, setInitialFieldValues] = useState<
|
||||
FieldValue[] | undefined
|
||||
>(undefined);
|
||||
|
||||
// Local search is performed if there are less than `limit` initial values
|
||||
const shouldPerformLocalSearch =
|
||||
initialFieldValues && initialFieldValues.length < limit;
|
||||
|
||||
// Update the search value to fetch on every debounced search value change
|
||||
// if the local search is disabled
|
||||
useEffect(() => {
|
||||
if (!shouldPerformLocalSearch) {
|
||||
setSearchValueToFetch(debouncedSearch);
|
||||
}
|
||||
}, [debouncedSearch, shouldPerformLocalSearch]);
|
||||
|
||||
const {
|
||||
data: fieldValues,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useSearchFieldValuesQuery(
|
||||
{
|
||||
fieldId: fieldId ?? -1, // type guard
|
||||
searchFieldId: searchFieldId ?? -1, // type guard
|
||||
// empty string is invalid from the API perspective
|
||||
value: searchValueToFetch === "" ? undefined : searchValueToFetch,
|
||||
limit: SEARCH_LIMIT_DEFAULT,
|
||||
},
|
||||
{ skip: fieldId === undefined },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialFieldValues && fieldValues) {
|
||||
setInitialFieldValues(fieldValues);
|
||||
}
|
||||
}, [initialFieldValues, fieldValues]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (fieldValues) {
|
||||
const options = getFieldOptions(fieldValues);
|
||||
|
||||
if (shouldPerformLocalSearch) {
|
||||
return options.filter((item) =>
|
||||
getDropdownLabelText(item)
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase().trim()),
|
||||
);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [shouldPerformLocalSearch, fieldValues, search, getDropdownLabelText]);
|
||||
|
||||
return {
|
||||
options,
|
||||
isLoading,
|
||||
isFetching:
|
||||
(isFetching || search !== debouncedSearch) && !shouldPerformLocalSearch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { canEditField } from "../../../helpers";
|
||||
|
||||
import { EditingBodyCellBasicInput } from "./EditingBodyCellBasicInput";
|
||||
import { EditingBodyCellCategorySelect } from "./EditingBodyCellCategorySelect";
|
||||
import { EditingBodyCellDatetime } from "./EditingBodyCellDatetime";
|
||||
import { EditingBodyCellFKSelect } from "./EditingBodyCellFKSelect";
|
||||
import { EditingBodyCellTextarea } from "./EditingBodyCellTextarea";
|
||||
import type { EditingBodyPrimitiveProps } from "./types";
|
||||
|
||||
export const EditingBodyCellConditional = (
|
||||
props: EditingBodyPrimitiveProps,
|
||||
) => {
|
||||
const { datasetColumn: column, field, inputProps, withTextarea } = props;
|
||||
|
||||
const disabled = !canEditField(field);
|
||||
const placeholder = field?.database_default
|
||||
? t`Auto populated`
|
||||
: field?.database_is_nullable
|
||||
? t`Optional`
|
||||
: props.inputProps?.placeholder;
|
||||
|
||||
if (
|
||||
column.semantic_type === "type/State" ||
|
||||
column.semantic_type === "type/Country" ||
|
||||
column.semantic_type === "type/Category"
|
||||
) {
|
||||
return (
|
||||
<EditingBodyCellCategorySelect
|
||||
{...props}
|
||||
inputProps={{ placeholder, disabled, ...inputProps }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.semantic_type === "type/FK") {
|
||||
return (
|
||||
<EditingBodyCellFKSelect
|
||||
{...props}
|
||||
inputProps={{ placeholder, disabled, ...inputProps }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
column.effective_type === "type/Date" ||
|
||||
column.effective_type === "type/DateTime" ||
|
||||
column.effective_type === "type/DateTimeWithLocalTZ"
|
||||
) {
|
||||
return (
|
||||
<EditingBodyCellDatetime
|
||||
{...props}
|
||||
inputProps={{ placeholder, disabled, ...inputProps }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (withTextarea && column.semantic_type === "type/Description") {
|
||||
return (
|
||||
<EditingBodyCellTextarea
|
||||
{...props}
|
||||
inputProps={{ placeholder, disabled, ...inputProps }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EditingBodyCellBasicInput
|
||||
{...props}
|
||||
inputProps={{ placeholder, disabled, ...inputProps }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { DatesProvider, useDatesContext } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import { type KeyboardEvent, useCallback, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_DATE_STYLE,
|
||||
DEFAULT_TIME_STYLE,
|
||||
} from "metabase/lib/formatting/datetime-utils";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { getSetting } from "metabase/selectors/settings";
|
||||
import { DateInput } from "metabase/ui";
|
||||
|
||||
import type { EditingBodyPrimitiveProps } from "./types";
|
||||
|
||||
const DEFAULT_DATETIME_STYLE = `${DEFAULT_DATE_STYLE}, ${DEFAULT_TIME_STYLE}`;
|
||||
|
||||
export const EditingBodyCellDatetime = ({
|
||||
autoFocus,
|
||||
inputProps,
|
||||
initialValue,
|
||||
datasetColumn,
|
||||
classNames,
|
||||
onSubmit,
|
||||
onChangeValue,
|
||||
onCancel,
|
||||
}: EditingBodyPrimitiveProps) => {
|
||||
const isDateTime =
|
||||
datasetColumn.effective_type === "type/DateTime" ||
|
||||
datasetColumn.effective_type === "type/DateTimeWithLocalTZ";
|
||||
|
||||
const { locale, firstDayOfWeek, consistentWeeks } = useDatesContext();
|
||||
const reportTimezone = useSelector((state) =>
|
||||
getSetting(state, "report-timezone-long"),
|
||||
);
|
||||
|
||||
const { restoreTimezone, initialDate } = useMemo(
|
||||
() => ({
|
||||
initialDate: initialValue ? new Date(initialValue.toString()) : null,
|
||||
restoreTimezone: (date: Date | null) => {
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dayjs(date)
|
||||
.tz(reportTimezone)
|
||||
.format("YYYY-MM-DDTHH:mm:ss.SSSZ");
|
||||
},
|
||||
}),
|
||||
[initialValue, reportTimezone],
|
||||
);
|
||||
|
||||
const valueFormat = isDateTime ? DEFAULT_DATETIME_STYLE : DEFAULT_DATE_STYLE;
|
||||
|
||||
const [value, setValue] = useState<Date | null>(initialDate);
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: Date | null) => {
|
||||
setValue(value);
|
||||
onChangeValue?.(restoreTimezone(value));
|
||||
},
|
||||
[onChangeValue, restoreTimezone],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
}, [setFocused]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
onSubmit(restoreTimezone(value));
|
||||
}, [value, onSubmit, restoreTimezone]);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onCancel();
|
||||
} else if (event.key === "Enter") {
|
||||
onSubmit(restoreTimezone(value));
|
||||
}
|
||||
},
|
||||
[value, onCancel, onSubmit, restoreTimezone],
|
||||
);
|
||||
|
||||
return (
|
||||
<DatesProvider
|
||||
settings={{
|
||||
timezone: reportTimezone,
|
||||
locale,
|
||||
firstDayOfWeek,
|
||||
consistentWeeks,
|
||||
}}
|
||||
>
|
||||
<DateInput
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
valueFormat={valueFormat}
|
||||
classNames={{
|
||||
wrapper: classNames?.wrapper,
|
||||
input: classNames?.dateTextInputElement,
|
||||
}}
|
||||
// Keeps popover mounted when focused to improve time editing UX
|
||||
popoverProps={{ opened: isFocused }}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyUp={handleKeyUp}
|
||||
{...inputProps}
|
||||
/>
|
||||
</DatesProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { SelectOption } from "metabase/ui";
|
||||
|
||||
import { EditingBodyCellCategorySelect } from "./EditingBodyCellCategorySelect";
|
||||
import type { EditingBodyPrimitiveProps } from "./types";
|
||||
|
||||
export const EditingBodyCellFKSelect = (props: EditingBodyPrimitiveProps) => {
|
||||
const getDropdownLabelText = useCallback(
|
||||
(item: SelectOption) =>
|
||||
item.label !== item.value ? `${item.label} [${item.value}]` : item.value,
|
||||
[],
|
||||
);
|
||||
|
||||
const getSelectedLabelText = useCallback(
|
||||
(item: SelectOption) => item.label,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<EditingBodyCellCategorySelect
|
||||
{...props}
|
||||
getDropdownLabelText={getDropdownLabelText}
|
||||
getSelectedLabelText={getSelectedLabelText}
|
||||
withCreateNew={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Textarea } from "metabase/ui";
|
||||
|
||||
import type { EditingBodyPrimitiveProps } from "./types";
|
||||
|
||||
const MAX_ROWS = 8;
|
||||
const MIN_ROWS = 1;
|
||||
|
||||
export const EditingBodyCellTextarea = ({
|
||||
autoFocus,
|
||||
inputProps,
|
||||
initialValue,
|
||||
classNames,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onChangeValue,
|
||||
}: EditingBodyPrimitiveProps) => {
|
||||
return (
|
||||
<Textarea
|
||||
defaultValue={(initialValue ?? "").toString()}
|
||||
autoFocus={autoFocus}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
onChange={(event) => {
|
||||
// Convert empty string to null
|
||||
onChangeValue?.(event.currentTarget.value || null);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onSubmit(event.currentTarget.value || null);
|
||||
}}
|
||||
classNames={{
|
||||
wrapper: classNames?.wrapper,
|
||||
input: classNames?.textInputElement,
|
||||
}}
|
||||
{...inputProps}
|
||||
// Match the line height for single line text (should look like a regular input)
|
||||
styles={{ input: { lineHeight: "165%" } }}
|
||||
maxRows={MAX_ROWS}
|
||||
minRows={MIN_ROWS}
|
||||
autosize
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { EditingBodyCellBasicInput } from "./EditingBodyCellBasicInput";
|
||||
export { EditingBodyCellCategorySelect } from "./EditingBodyCellCategorySelect";
|
||||
export { EditingBodyCellFKSelect } from "./EditingBodyCellFKSelect";
|
||||
export { EditingBodyCellDatetime } from "./EditingBodyCellDatetime";
|
||||
export { EditingBodyCellConditional } from "./EditingBodyCellConditional";
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { MantineSize, TextInputProps } from "metabase/ui";
|
||||
import type {
|
||||
DatasetColumn,
|
||||
FieldWithMetadata,
|
||||
RowValue,
|
||||
} from "metabase-types/api";
|
||||
|
||||
export type EditingBodyCellConditionalStylesNames =
|
||||
| "wrapper"
|
||||
| "textInputElement"
|
||||
| "selectTextInputElement"
|
||||
| "dateTextInputElement";
|
||||
|
||||
export interface EditingBodyPrimitiveProps {
|
||||
withTextarea?: boolean;
|
||||
autoFocus?: boolean;
|
||||
datasetColumn: DatasetColumn;
|
||||
field?: FieldWithMetadata;
|
||||
initialValue: RowValue;
|
||||
inputProps?: {
|
||||
size?: MantineSize;
|
||||
variant?: TextInputProps["variant"];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
error?: TextInputProps["error"];
|
||||
rightSection?: TextInputProps["rightSection"];
|
||||
rightSectionPointerEvents?: TextInputProps["rightSectionPointerEvents"];
|
||||
};
|
||||
classNames?: { [key in EditingBodyCellConditionalStylesNames]?: string };
|
||||
onChangeValue?: (value: RowValue) => unknown;
|
||||
onSubmit: (value: RowValue) => unknown;
|
||||
onCancel: () => unknown;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { msgid, ngettext, t } from "ttag";
|
||||
|
||||
import { Button, Group, Modal } from "metabase/ui";
|
||||
|
||||
type DeleteBulkRowConfirmationModalProps = {
|
||||
opened: boolean;
|
||||
rowCount: number;
|
||||
isLoading?: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
export function DeleteBulkRowConfirmationModal({
|
||||
opened,
|
||||
rowCount,
|
||||
isLoading,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: DeleteBulkRowConfirmationModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
title={ngettext(
|
||||
msgid`Delete ${rowCount} record?`,
|
||||
`Delete ${rowCount} records?`,
|
||||
rowCount,
|
||||
)}
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="danger"
|
||||
onClick={onConfirm}
|
||||
loading={isLoading}
|
||||
>
|
||||
{ngettext(
|
||||
msgid`Delete ${rowCount} record`,
|
||||
`Delete ${rowCount} records`,
|
||||
rowCount,
|
||||
)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Button, Group, Modal } from "metabase/ui";
|
||||
|
||||
type DeleteRowConfirmationModalProps = {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
export function DeleteRowConfirmationModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DeleteRowConfirmationModalProps) {
|
||||
return (
|
||||
<Modal size="md" title={t`Delete this record?`} opened onClose={onCancel}>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="subtle" onClick={onCancel}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="danger"
|
||||
onClick={onConfirm}
|
||||
>{t`Delete record`}</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import type { RowSelectionState } from "@tanstack/react-table";
|
||||
import cx from "classnames";
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import { msgid, ngettext, t } from "ttag";
|
||||
import { noop } from "underscore";
|
||||
|
||||
import { FIELD_SEMANTIC_TYPES_MAP } from "metabase/lib/core";
|
||||
import { ActionIcon, Group, Icon, Modal, Text, rem } from "metabase/ui";
|
||||
import { canEditField } from "metabase-enterprise/data_editing/helpers";
|
||||
import type {
|
||||
DatasetColumn,
|
||||
FieldWithMetadata,
|
||||
RowValue,
|
||||
Table,
|
||||
UpdatedRowBulkHandlerParams,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { EditingBodyCellConditional } from "../inputs";
|
||||
import type { EditableTableColumnConfig } from "../use-editable-column-config";
|
||||
|
||||
import { DeleteBulkRowConfirmationModal } from "./DeleteBulkRowConfirmationModal";
|
||||
import S from "./EditingBaseRowModal.module.css";
|
||||
import { useEditingModalOrderedVisibleDatasetColumns } from "./use-editing-modal-ordered-dataset-columns";
|
||||
|
||||
interface EditBulkRowsModalProps {
|
||||
opened: boolean;
|
||||
datasetColumns: DatasetColumn[];
|
||||
datasetTable?: Table;
|
||||
onClose: () => void;
|
||||
onEdit: (data: UpdatedRowBulkHandlerParams) => Promise<boolean>;
|
||||
onDelete: (rowIndices: number[]) => Promise<boolean>;
|
||||
fieldMetadataMap: Record<FieldWithMetadata["name"], FieldWithMetadata>;
|
||||
hasDeleteAction: boolean;
|
||||
isDeleting?: boolean;
|
||||
columnsConfig?: EditableTableColumnConfig;
|
||||
selectedRowIndices: number[];
|
||||
setRowSelection: (state: RowSelectionState) => void;
|
||||
}
|
||||
|
||||
export function EditBulkRowsModal({
|
||||
opened,
|
||||
datasetColumns,
|
||||
onClose,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
fieldMetadataMap,
|
||||
hasDeleteAction,
|
||||
columnsConfig,
|
||||
selectedRowIndices,
|
||||
setRowSelection,
|
||||
}: EditBulkRowsModalProps) {
|
||||
// We need editing state to track null values for columns that were cleared
|
||||
const [editingState, setEditingState] = useState<Record<string, RowValue>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// Clear editing state on modal close
|
||||
useEffect(() => {
|
||||
setEditingState({});
|
||||
}, [opened]);
|
||||
|
||||
const [
|
||||
isDeleteRequested,
|
||||
{ open: requestDeletion, close: closeDeletionModal },
|
||||
] = useDisclosure();
|
||||
|
||||
// Columns might be reordered to match the order in `columnsConfig`
|
||||
const orderedVisibleDatasetColumns =
|
||||
useEditingModalOrderedVisibleDatasetColumns(datasetColumns, columnsConfig);
|
||||
|
||||
const handleValueEdit = useCallback(
|
||||
async (key: string, value: RowValue, forceUpdate = false) => {
|
||||
// Skip update if the value is empty and the value was not changed before
|
||||
const valueWasModifiedBefore = key in editingState;
|
||||
if (!forceUpdate && !value && !valueWasModifiedBefore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await onEdit({
|
||||
updatedData: { [key]: value },
|
||||
rowIndices: selectedRowIndices,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
setEditingState((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
},
|
||||
[onEdit, selectedRowIndices, setEditingState, editingState],
|
||||
);
|
||||
|
||||
const handleDeleteConfirmation = useCallback(async () => {
|
||||
const result = await onDelete(selectedRowIndices);
|
||||
if (result) {
|
||||
setRowSelection({});
|
||||
onClose();
|
||||
closeDeletionModal();
|
||||
}
|
||||
}, [
|
||||
onDelete,
|
||||
selectedRowIndices,
|
||||
closeDeletionModal,
|
||||
onClose,
|
||||
setRowSelection,
|
||||
]);
|
||||
|
||||
if (isDeleteRequested) {
|
||||
return (
|
||||
<DeleteBulkRowConfirmationModal
|
||||
opened={true}
|
||||
onClose={closeDeletionModal}
|
||||
rowCount={selectedRowIndices.length}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDeleteConfirmation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal.Root opened={opened} onClose={onClose}>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content>
|
||||
<Modal.Header px="xl" pb="0" className={S.modalHeader}>
|
||||
<Modal.Title>
|
||||
{ngettext(
|
||||
msgid`Editing ${selectedRowIndices.length} record`,
|
||||
`Editing ${selectedRowIndices.length} records`,
|
||||
selectedRowIndices.length,
|
||||
)}
|
||||
</Modal.Title>
|
||||
<Group
|
||||
gap="xs"
|
||||
mr={rem(-5) /* aligns cross with modal right padding */}
|
||||
>
|
||||
{hasDeleteAction && (
|
||||
<ActionIcon variant="subtle" onClick={requestDeletion}>
|
||||
<Icon name="trash" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon variant="subtle" onClick={onClose}>
|
||||
<Icon name="close" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Modal.Header>
|
||||
<Modal.Body
|
||||
px="xl"
|
||||
py="lg"
|
||||
className={cx(S.modalBody, S.modalBodyEditing)}
|
||||
>
|
||||
{orderedVisibleDatasetColumns.map((column) => {
|
||||
const field = fieldMetadataMap?.[column.name];
|
||||
|
||||
return (
|
||||
<Fragment key={column.id}>
|
||||
<Group h="2.5rem" align="center">
|
||||
<Icon
|
||||
className={S.modalBodyColumn}
|
||||
name={
|
||||
column.semantic_type
|
||||
? FIELD_SEMANTIC_TYPES_MAP[column.semantic_type].icon
|
||||
: "string"
|
||||
}
|
||||
/>
|
||||
<Text className={S.modalBodyColumn}>
|
||||
{column.display_name}
|
||||
{field?.database_required && (
|
||||
<Text component="span" c="error">
|
||||
*
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<BulkEditingInput
|
||||
datasetColumn={column}
|
||||
field={field}
|
||||
onSubmitValue={handleValueEdit}
|
||||
isValueCleared={editingState[column.name] === null}
|
||||
isValueModified={column.name in editingState}
|
||||
columnsConfig={columnsConfig}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
type BulkEditingInputProps = {
|
||||
datasetColumn: DatasetColumn;
|
||||
field?: FieldWithMetadata;
|
||||
onSubmitValue: (key: string, value: RowValue, forceUpdate?: boolean) => void;
|
||||
columnsConfig?: EditableTableColumnConfig;
|
||||
isValueCleared?: boolean;
|
||||
isValueModified?: boolean;
|
||||
};
|
||||
|
||||
function BulkEditingInput({
|
||||
datasetColumn,
|
||||
field,
|
||||
onSubmitValue,
|
||||
columnsConfig,
|
||||
isValueCleared,
|
||||
isValueModified,
|
||||
}: BulkEditingInputProps) {
|
||||
const disabled =
|
||||
columnsConfig?.isColumnReadonly(datasetColumn.name) || !canEditField(field);
|
||||
|
||||
const handleValueClear = useCallback(() => {
|
||||
onSubmitValue(datasetColumn.name, null, true);
|
||||
}, [onSubmitValue, datasetColumn.name]);
|
||||
|
||||
const handleValueSubmit = useCallback(
|
||||
(value: RowValue) => {
|
||||
onSubmitValue(datasetColumn.name, value);
|
||||
},
|
||||
[onSubmitValue, datasetColumn.name],
|
||||
);
|
||||
|
||||
const placeholder = isValueCleared
|
||||
? "NULL"
|
||||
: !isValueModified
|
||||
? t`(Unchanged)`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<EditingBodyCellConditional
|
||||
autoFocus={false}
|
||||
datasetColumn={datasetColumn}
|
||||
field={field}
|
||||
initialValue={null}
|
||||
onCancel={noop}
|
||||
onSubmit={handleValueSubmit}
|
||||
inputProps={{
|
||||
placeholder,
|
||||
disabled,
|
||||
rightSectionPointerEvents: "all",
|
||||
rightSection: !disabled && !isValueCleared && (
|
||||
<Icon
|
||||
name="close"
|
||||
color="var(--mb-color-text-light)"
|
||||
onClick={handleValueClear}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
withTextarea
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.modalHeader {
|
||||
background-color: var(--mb-color-background);
|
||||
border-bottom: 1px solid var(--mb-color-border);
|
||||
height: rem(80);
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modalBody {
|
||||
background-color: var(--mb-base-color-orion-5);
|
||||
display: grid;
|
||||
grid-template-columns: 45% auto;
|
||||
row-gap: rem(24);
|
||||
max-height: calc(
|
||||
100vh - (var(--modal-y-offset) * 2) - rem(80) - rem(72)
|
||||
/* header + footer */
|
||||
);
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.modalBodyEditing {
|
||||
max-height: calc(100vh - (var(--modal-y-offset) * 2) - rem(80) /* header */);
|
||||
}
|
||||
|
||||
.modalBodyColumn {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
background-color: var(--mb-color-background);
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
height: rem(72);
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import cx from "classnames";
|
||||
import { useFormik } from "formik";
|
||||
import { Fragment, useCallback, useEffect, useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
import { noop } from "underscore";
|
||||
|
||||
import { FIELD_SEMANTIC_TYPES_MAP } from "metabase/lib/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
Icon,
|
||||
Modal,
|
||||
Text,
|
||||
rem,
|
||||
} from "metabase/ui";
|
||||
import type {
|
||||
DatasetColumn,
|
||||
FieldWithMetadata,
|
||||
RowValue,
|
||||
RowValues,
|
||||
Table,
|
||||
UpdatedRowHandlerParams,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { EditingBodyCellConditional } from "../inputs";
|
||||
import type { EditableTableColumnConfig } from "../use-editable-column-config";
|
||||
|
||||
import { DeleteRowConfirmationModal } from "./DeleteRowConfirmationModal";
|
||||
import S from "./EditingBaseRowModal.module.css";
|
||||
import { useEditingModalOrderedVisibleDatasetColumns } from "./use-editing-modal-ordered-dataset-columns";
|
||||
import type { TableEditingModalState } from "./use-table-modal";
|
||||
import { TableEditingModalAction } from "./use-table-modal";
|
||||
|
||||
interface EditingBaseRowModalProps {
|
||||
datasetColumns: DatasetColumn[];
|
||||
datasetTable?: Table;
|
||||
modalState: TableEditingModalState;
|
||||
onClose: () => void;
|
||||
onEdit: (data: UpdatedRowHandlerParams) => Promise<boolean>;
|
||||
onRowCreate: (data: Record<string, RowValue>) => Promise<boolean>;
|
||||
onRowDelete: (rowIndex: number) => Promise<boolean>;
|
||||
currentRowData?: RowValues;
|
||||
fieldMetadataMap: Record<FieldWithMetadata["name"], FieldWithMetadata>;
|
||||
hasDeleteAction: boolean;
|
||||
isLoading?: boolean;
|
||||
columnsConfig?: EditableTableColumnConfig;
|
||||
}
|
||||
|
||||
type EditingFormValues = Record<string, RowValue>;
|
||||
|
||||
export function EditingBaseRowModal({
|
||||
datasetColumns,
|
||||
modalState,
|
||||
onClose,
|
||||
onEdit,
|
||||
onRowCreate,
|
||||
onRowDelete,
|
||||
currentRowData,
|
||||
fieldMetadataMap,
|
||||
hasDeleteAction,
|
||||
isLoading,
|
||||
columnsConfig,
|
||||
}: EditingBaseRowModalProps) {
|
||||
const isEditingMode = !!currentRowData;
|
||||
|
||||
const [
|
||||
isDeleteRequested,
|
||||
{ open: requestDeletion, close: closeDeletionModal },
|
||||
] = useDisclosure();
|
||||
|
||||
const validateForm = useCallback(
|
||||
(values: EditingFormValues) => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
datasetColumns.forEach((column) => {
|
||||
const field = fieldMetadataMap?.[column.name];
|
||||
const isRequired = field?.database_required;
|
||||
if (isRequired && !values[column.name]) {
|
||||
errors[column.name] = t`This column is required`;
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
},
|
||||
[fieldMetadataMap, datasetColumns],
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (values: EditingFormValues) => {
|
||||
const success = await onRowCreate(values);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose, onRowCreate],
|
||||
);
|
||||
|
||||
const {
|
||||
isValid,
|
||||
resetForm,
|
||||
setFieldValue,
|
||||
handleSubmit,
|
||||
validateForm: revalidateForm,
|
||||
} = useFormik({
|
||||
initialValues: {} as EditingFormValues,
|
||||
onSubmit,
|
||||
validate: validateForm,
|
||||
validateOnMount: true,
|
||||
});
|
||||
|
||||
// Clear new row data when modal is opened
|
||||
useEffect(() => {
|
||||
if (modalState.action === TableEditingModalAction.Create) {
|
||||
resetForm();
|
||||
revalidateForm();
|
||||
}
|
||||
}, [modalState.action, resetForm, revalidateForm]);
|
||||
|
||||
const handleValueEdit = useCallback(
|
||||
(key: string, value: RowValue) => {
|
||||
if (modalState.rowIndex != null && isEditingMode) {
|
||||
onEdit({
|
||||
rowIndex: modalState.rowIndex,
|
||||
updatedData: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[isEditingMode, modalState.rowIndex, onEdit],
|
||||
);
|
||||
|
||||
const handleDeleteConfirmation = useCallback(async () => {
|
||||
if (modalState.rowIndex !== undefined) {
|
||||
closeDeletionModal();
|
||||
onClose();
|
||||
|
||||
await onRowDelete(modalState.rowIndex);
|
||||
}
|
||||
}, [closeDeletionModal, onRowDelete, modalState.rowIndex, onClose]);
|
||||
|
||||
// Columns might be reordered to match the order in `columnsConfig`
|
||||
const orderedVisibleDatasetColumns =
|
||||
useEditingModalOrderedVisibleDatasetColumns(datasetColumns, columnsConfig);
|
||||
|
||||
// We can't use `currentRowData` in case
|
||||
// when colums are reordered due to `columnsConfig`
|
||||
const currentRowDataMap = useMemo(() => {
|
||||
if (!currentRowData) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return datasetColumns.reduce(
|
||||
(acc, column, index) => ({
|
||||
...acc,
|
||||
[column.name]: currentRowData[index],
|
||||
}),
|
||||
{} as Record<string, RowValue>,
|
||||
);
|
||||
}, [currentRowData, datasetColumns]);
|
||||
|
||||
if (isDeleteRequested) {
|
||||
return (
|
||||
<DeleteRowConfirmationModal
|
||||
onCancel={closeDeletionModal}
|
||||
onConfirm={handleDeleteConfirmation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal.Root opened={modalState.action !== null} onClose={onClose}>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Modal.Header px="xl" pb="0" className={S.modalHeader}>
|
||||
<Modal.Title>
|
||||
{isEditingMode ? t`Edit record` : t`Create a new record`}
|
||||
</Modal.Title>
|
||||
<Group
|
||||
gap="xs"
|
||||
mr={rem(-5) /* aligns cross with modal right padding */}
|
||||
>
|
||||
{isEditingMode && hasDeleteAction && (
|
||||
<ActionIcon variant="subtle" onClick={requestDeletion}>
|
||||
<Icon name="trash" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon variant="subtle" onClick={onClose}>
|
||||
<Icon name="close" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Modal.Header>
|
||||
<Modal.Body
|
||||
px="xl"
|
||||
py="lg"
|
||||
className={cx(S.modalBody, { [S.modalBodyEditing]: isEditingMode })}
|
||||
>
|
||||
{orderedVisibleDatasetColumns.map((column) => {
|
||||
const field = fieldMetadataMap?.[column.name];
|
||||
|
||||
return (
|
||||
<Fragment key={column.id}>
|
||||
<Group h="2.5rem" align="center">
|
||||
<Icon
|
||||
className={S.modalBodyColumn}
|
||||
name={
|
||||
column.semantic_type
|
||||
? FIELD_SEMANTIC_TYPES_MAP[column.semantic_type].icon
|
||||
: "string"
|
||||
}
|
||||
/>
|
||||
<Text className={S.modalBodyColumn}>
|
||||
{column.display_name}
|
||||
{field?.database_required && (
|
||||
<Text component="span" c="error">
|
||||
*
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
<ModalEditingInput
|
||||
isEditingMode={isEditingMode}
|
||||
initialValue={currentRowDataMap[column.name] ?? null}
|
||||
datasetColumn={column}
|
||||
field={field}
|
||||
onSubmitValue={handleValueEdit}
|
||||
onChangeValue={setFieldValue}
|
||||
disabled={columnsConfig?.isColumnReadonly(column.name)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Modal.Body>
|
||||
{!isEditingMode && (
|
||||
<Flex px="xl" className={S.modalFooter} gap="lg" justify="flex-end">
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isLoading || !isValid}
|
||||
variant="filled"
|
||||
type="submit"
|
||||
>{t`Create new record`}</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</form>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
type EditingInputWithEventsProps = {
|
||||
isEditingMode: boolean;
|
||||
initialValue?: RowValue;
|
||||
datasetColumn: DatasetColumn;
|
||||
field?: FieldWithMetadata;
|
||||
onSubmitValue: (key: string, value: RowValue) => void;
|
||||
onChangeValue: (key: string, value: RowValue) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
function ModalEditingInput({
|
||||
isEditingMode,
|
||||
initialValue,
|
||||
datasetColumn,
|
||||
field,
|
||||
onSubmitValue,
|
||||
onChangeValue,
|
||||
disabled,
|
||||
}: EditingInputWithEventsProps) {
|
||||
// The difference is that in editing mode we handle change only when value is ready (e.g. on blur)
|
||||
// whereas in creating mode we handle change immediately to update the form state
|
||||
const handleValueSubmit = useCallback(
|
||||
(value: RowValue) => {
|
||||
if (isEditingMode) {
|
||||
onSubmitValue(datasetColumn.name, value);
|
||||
}
|
||||
},
|
||||
[isEditingMode, datasetColumn.name, onSubmitValue],
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: RowValue) => {
|
||||
if (!isEditingMode) {
|
||||
onChangeValue(datasetColumn.name, value);
|
||||
}
|
||||
},
|
||||
[isEditingMode, datasetColumn.name, onChangeValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<EditingBodyCellConditional
|
||||
autoFocus={false}
|
||||
datasetColumn={datasetColumn}
|
||||
field={field}
|
||||
initialValue={initialValue ?? null}
|
||||
onCancel={noop}
|
||||
onSubmit={handleValueSubmit}
|
||||
onChangeValue={handleValueChange}
|
||||
inputProps={{ disabled }}
|
||||
withTextarea
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Location } from "history";
|
||||
import {
|
||||
type InjectedRouter,
|
||||
type Route,
|
||||
type RouterProps,
|
||||
withRouter,
|
||||
} from "react-router";
|
||||
import { t } from "ttag";
|
||||
|
||||
import useBeforeUnload from "metabase/hooks/use-before-unload";
|
||||
import { useConfirmRouteLeaveModal } from "metabase/hooks/use-confirm-route-leave-modal";
|
||||
import { Button, Group, Modal, Stack, Text } from "metabase/ui";
|
||||
|
||||
type UnsavedLeaveConfirmationModalProps = {
|
||||
router: RouterProps;
|
||||
isUpdating: boolean;
|
||||
isDeleting: boolean;
|
||||
isInserting: boolean;
|
||||
isLocationAllowed?: (location: Location | undefined) => boolean;
|
||||
};
|
||||
|
||||
export const UnsavedLeaveConfirmationModal = withRouter(
|
||||
({
|
||||
router,
|
||||
isUpdating,
|
||||
isDeleting,
|
||||
isInserting,
|
||||
isLocationAllowed,
|
||||
}: UnsavedLeaveConfirmationModalProps) => {
|
||||
const isLoading = isUpdating || isDeleting || isInserting;
|
||||
const routes = router.routes as Route[];
|
||||
const currentRoute = routes[routes.length - 1];
|
||||
|
||||
useBeforeUnload(isLoading);
|
||||
const { opened, close, confirm } = useConfirmRouteLeaveModal({
|
||||
isEnabled: isLoading,
|
||||
router: router as InjectedRouter,
|
||||
route: currentRoute,
|
||||
isLocationAllowed,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
title={t`Unsaved changes`}
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
>
|
||||
<Stack gap="xl">
|
||||
<Text>{t`Leaving this page may result in losing your changes.`}</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="filled" color="danger" onClick={confirm}>
|
||||
{t`Leave anyway`}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { DatasetColumn } from "metabase-types/api";
|
||||
|
||||
import type { EditableTableColumnConfig } from "../use-editable-column-config";
|
||||
|
||||
export function useEditingModalOrderedVisibleDatasetColumns(
|
||||
datasetColumns: DatasetColumn[],
|
||||
columnsConfig?: EditableTableColumnConfig,
|
||||
) {
|
||||
return useMemo(() => {
|
||||
if (!columnsConfig?.columnOrder.length) {
|
||||
return datasetColumns;
|
||||
}
|
||||
|
||||
return columnsConfig.columnOrder
|
||||
.map((name) => datasetColumns.find((it) => it.name === name))
|
||||
.filter(
|
||||
(it): it is DatasetColumn =>
|
||||
it !== undefined && !columnsConfig.isColumnHidden(it.name),
|
||||
);
|
||||
}, [columnsConfig, datasetColumns]);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import type { RowSelectionState } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type UseTableBulkDeleteConfirmationProps = {
|
||||
selectedRowIndices: number[];
|
||||
setRowSelection: (state: RowSelectionState) => void;
|
||||
handleRowDeleteBulk: (rowIndices: number[]) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function useTableBulkDeleteConfirmation({
|
||||
selectedRowIndices,
|
||||
setRowSelection,
|
||||
handleRowDeleteBulk,
|
||||
}: UseTableBulkDeleteConfirmationProps) {
|
||||
const [
|
||||
isDeleteBulkRequested,
|
||||
{ open: requestDeleteBulk, close: cancelDeleteBulk },
|
||||
] = useDisclosure(false);
|
||||
|
||||
const handleDeleteBulkConfirmation = useCallback(async () => {
|
||||
const success = await handleRowDeleteBulk(selectedRowIndices);
|
||||
|
||||
if (success) {
|
||||
setRowSelection({});
|
||||
}
|
||||
|
||||
cancelDeleteBulk();
|
||||
}, [
|
||||
cancelDeleteBulk,
|
||||
handleRowDeleteBulk,
|
||||
selectedRowIndices,
|
||||
setRowSelection,
|
||||
]);
|
||||
|
||||
return {
|
||||
isDeleteBulkRequested,
|
||||
requestDeleteBulk,
|
||||
cancelDeleteBulk,
|
||||
onDeleteBulkConfirmation: handleDeleteBulkConfirmation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { isPK } from "metabase-lib/v1/types/utils/isa";
|
||||
import type { DatasetData } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
type TableEditingModalController,
|
||||
useTableEditingModalController,
|
||||
} from "./use-table-modal";
|
||||
|
||||
/**
|
||||
* This is a special object ID used to indicate that the create row modal
|
||||
* should be opened. It's not a real object ID, but rather a placeholder.
|
||||
* We use it to keep the logic simple.
|
||||
*/
|
||||
const OBJECT_ID_CREATE_ROW = "create";
|
||||
|
||||
type UseTableEditingModalControllerWithObjectIdProps = {
|
||||
currentObjectId?: string;
|
||||
datasetData: DatasetData | null | undefined;
|
||||
onObjectIdChange: (objectId?: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* In contrast to the `useTableEditingModalController`, this hook relies on the
|
||||
* `currentObjectId` prop to determine which modal to open and when. It's designed
|
||||
* for editing modals with direct URL access, where the `currentObjectId` is passed
|
||||
* as a prop. This allows to keep the URL in sync with the modal state as well as
|
||||
* to support history navigation.
|
||||
*
|
||||
* Basically opening a modal triggers a URL change, which triggers `useEffect` hook
|
||||
* to open the modal based on the current object ID.
|
||||
*/
|
||||
export function useTableEditingModalControllerWithObjectId({
|
||||
currentObjectId,
|
||||
datasetData,
|
||||
onObjectIdChange,
|
||||
}: UseTableEditingModalControllerWithObjectIdProps): TableEditingModalController {
|
||||
const { state, openCreateRowModal, openEditRowModal, closeModal } =
|
||||
useTableEditingModalController();
|
||||
|
||||
useEffect(() => {
|
||||
switch (currentObjectId) {
|
||||
case OBJECT_ID_CREATE_ROW:
|
||||
openCreateRowModal();
|
||||
break;
|
||||
|
||||
case undefined:
|
||||
closeModal();
|
||||
break;
|
||||
|
||||
default:
|
||||
if (datasetData) {
|
||||
const pkColumnIndex = datasetData.cols.findIndex(isPK);
|
||||
const rowIndex = datasetData.rows.findIndex(
|
||||
(row) =>
|
||||
row[pkColumnIndex] === currentObjectId ||
|
||||
row[pkColumnIndex] === Number(currentObjectId),
|
||||
);
|
||||
|
||||
if (rowIndex !== -1) {
|
||||
openEditRowModal(rowIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentObjectId,
|
||||
datasetData,
|
||||
openCreateRowModal,
|
||||
openEditRowModal,
|
||||
closeModal,
|
||||
]);
|
||||
|
||||
const handleOpenEditRowModal = useCallback(
|
||||
(rowIndex: number) => {
|
||||
if (!datasetData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pkColumnIndex = datasetData.cols.findIndex(isPK);
|
||||
const objectId = datasetData.rows[rowIndex][pkColumnIndex];
|
||||
onObjectIdChange(objectId?.toString());
|
||||
},
|
||||
[datasetData, onObjectIdChange],
|
||||
);
|
||||
|
||||
const handleOpenCreateRowModal = useCallback(() => {
|
||||
onObjectIdChange(OBJECT_ID_CREATE_ROW);
|
||||
}, [onObjectIdChange]);
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
onObjectIdChange(undefined);
|
||||
}, [onObjectIdChange]);
|
||||
|
||||
return {
|
||||
state,
|
||||
openCreateRowModal: handleOpenCreateRowModal,
|
||||
openEditRowModal: handleOpenEditRowModal,
|
||||
closeModal: handleCloseModal,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user