Compare commits
234 Commits
python-lib
...
separate-q
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbd43c7a32 | ||
|
|
93b4ed7c92 | ||
|
|
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 |
@@ -584,6 +584,8 @@
|
||||
metabase.test/with-user-in-groups clojure.core/let
|
||||
metabase.test.gentest/defgentest clojure.test/deftest
|
||||
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
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
{:metabase/modules
|
||||
{actions
|
||||
{:api #{metabase.actions.api metabase.actions.core}
|
||||
:uses #{analytics api driver legacy-mbql lib model-persistence models query-processor search settings util}}
|
||||
:uses #{analytics api driver events legacy-mbql lib model-persistence models query-processor search settings util}}
|
||||
|
||||
activity-feed
|
||||
{:api #{metabase.activity-feed.api metabase.activity-feed.init}
|
||||
@@ -285,9 +285,8 @@
|
||||
util}}
|
||||
|
||||
events
|
||||
{:api #{metabase.events.core
|
||||
metabase.events.init}
|
||||
:uses #{channel driver models util view-log}}
|
||||
{:api #{metabase.events.core metabase.events.init}
|
||||
:uses #{api config channel driver models notification premium-features util view-log}}
|
||||
|
||||
formatter
|
||||
{:api #{metabase.formatter}
|
||||
@@ -456,6 +455,7 @@
|
||||
driver
|
||||
events
|
||||
models
|
||||
lib
|
||||
permissions
|
||||
premium-features
|
||||
settings
|
||||
@@ -463,6 +463,7 @@
|
||||
request
|
||||
task
|
||||
task-history
|
||||
types
|
||||
util}}
|
||||
|
||||
permissions
|
||||
@@ -788,15 +789,21 @@
|
||||
|
||||
enterprise/core
|
||||
{:api #{metabase-enterprise.core.init}
|
||||
:uses :any}
|
||||
:uses #{enterprise/advanced-config enterprise/cache enterprise/enhancements enterprise/data-editing enterprise/stale}}
|
||||
|
||||
enterprise/dashboard-subscription-filters
|
||||
{:api #{}
|
||||
:uses #{premium-features}}
|
||||
|
||||
enterprise/data-editing
|
||||
{:api #{metabase-enterprise.data-editing.api}
|
||||
:uses #{api actions util}}
|
||||
{: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}}
|
||||
|
||||
enterprise/data-editing-public
|
||||
{:api #{}
|
||||
:uses #{api actions enterprise/data-editing driver events lib query-processor upload util}}
|
||||
|
||||
enterprise/database-routing
|
||||
{:api #{metabase-enterprise.database-routing.api}
|
||||
|
||||
@@ -277,6 +277,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
@@ -143,3 +143,4 @@ llm-payloads
|
||||
.aider*
|
||||
/config.yml
|
||||
CLAUDE.local.md
|
||||
.claude/settings.local.json
|
||||
|
||||
18
deps.edn
18
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.1295"} ; Honey SQL 2. SQL generation from Clojure data maps
|
||||
@@ -60,8 +60,7 @@
|
||||
{: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
|
||||
@@ -841,7 +840,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"
|
||||
metabase-enterprise.data-editing.notification-test
|
||||
metabase.dashboard-subscription-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,
|
||||
},
|
||||
],
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export * from "./e2e-email-helpers";
|
||||
export * from "./e2e-embedding-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";
|
||||
|
||||
@@ -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: [
|
||||
|
||||
96
e2e/test/scenarios/data-editing/data-editing.cy.spec.ts
Normal file
96
e2e/test/scenarios/data-editing/data-editing.cy.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
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("Editing People").should("be.visible");
|
||||
|
||||
cy.findByTestId("table-root").should("be.visible");
|
||||
});
|
||||
|
||||
cy.findByTestId("edit-table-data-root")
|
||||
.findByLabelText("Back to 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();
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
(:require
|
||||
[metabase-enterprise.advanced-config.init]
|
||||
[metabase-enterprise.cache.init]
|
||||
[metabase-enterprise.data-editing.init]
|
||||
[metabase-enterprise.enhancements.init]
|
||||
[metabase-enterprise.stale.init]))
|
||||
|
||||
@@ -1,38 +1,310 @@
|
||||
(ns metabase-enterprise.data-editing.api
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[metabase-enterprise.data-editing.data-editing :as data-editing]
|
||||
[metabase-enterprise.data-editing.undo :as undo]
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.models.field-values :as field-values]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :as i18n :refer [tru]]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(clojure.lang ExceptionInfo)))
|
||||
|
||||
(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)
|
||||
|
||||
;; TODO consider moving this down somewhere generic, like in handle-effects!*
|
||||
(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 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)))
|
||||
|
||||
(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 (data-editing/select-table-pk-fields table-id)]
|
||||
(data-editing/query-db-rows table-id pk-fields rows)))
|
||||
|
||||
(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} :map]]]
|
||||
(check-permissions)
|
||||
(let [rows' (data-editing/apply-coercions table-id rows)
|
||||
scope (or scope {:table-id table-id})
|
||||
res (data-editing/insert! api/*current-user-id* scope table-id rows')]
|
||||
{:created-rows (invalidate-and-present! table-id (:created-rows res))}))
|
||||
|
||||
(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 scope]} :- [:map
|
||||
[:rows [:sequential {:min 1} :map]]
|
||||
;; TODO make this non-optional in the future
|
||||
[:scope {:optional true} :map]]]
|
||||
(check-permissions)
|
||||
(if (empty? rows)
|
||||
{:updated []}
|
||||
(let [user-id api/*current-user-id*
|
||||
scope (or scope {:table-id table-id})
|
||||
rows (data-editing/apply-coercions table-id rows)
|
||||
rows (map :after (data-editing/perform-bulk-action! :table.row/update user-id scope table-id rows))]
|
||||
{:updated (invalidate-and-present! table-id 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} :map]]]
|
||||
(check-permissions)
|
||||
(let [user-id api/*current-user-id*
|
||||
scope (or scope {:table-id table-id})]
|
||||
(data-editing/perform-bulk-action! :table.row/delete user-id scope table-id 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}))
|
||||
|
||||
(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))
|
||||
|
||||
(api.macros/defendpoint :post "/undo"
|
||||
"Undo the last change you made.
|
||||
For now only supports tables, but in the future will support editables for sure.
|
||||
Maybe actions, workflows, etc.
|
||||
Could even generalize to things like edits to dashboard definitions themselves."
|
||||
[_
|
||||
_
|
||||
{:keys [table-id scope no-op]}] :- [:map
|
||||
;; deprecated, this will be replaced by scope
|
||||
[:table-id ms/PositiveInt]
|
||||
;; TODO make this non-optional in the future
|
||||
[:scope {:optional true} :map]
|
||||
[:no-op {:optional true} ms/BooleanValue]]
|
||||
(check-permissions)
|
||||
(let [user-id api/*current-user-id*
|
||||
scope (or scope {:table-id table-id})]
|
||||
(if no-op
|
||||
{:batch_num (undo/next-batch-num :undo user-id scope)}
|
||||
;; IDEA encapsulate this in an action
|
||||
;; IDEA use generic action calling API instead of having this endpoint
|
||||
(try
|
||||
{:result (undo/undo! user-id scope)}
|
||||
(catch ExceptionInfo e
|
||||
(throw (translate-undo-error e)))))))
|
||||
|
||||
(api.macros/defendpoint :post "/redo"
|
||||
"Redo the last change you made.
|
||||
For now only supports tables, but in the future will support editables for sure.
|
||||
Maybe actions, workflows, etc.
|
||||
Could even generalize to things like edits to dashboard definitions themselves."
|
||||
[_
|
||||
_
|
||||
{:keys [table-id scope no-op]}] :- [:map
|
||||
;; deprecated, this will be replaced by scope
|
||||
[:table-id ms/PositiveInt]
|
||||
;; TODO: make this non-optional in the future
|
||||
[:scope {:optional true} :map]
|
||||
[:no-op {:optional true} ms/BooleanValue]]
|
||||
(check-permissions)
|
||||
(let [user-id api/*current-user-id*
|
||||
scope (or scope {:table-id table-id})]
|
||||
(if no-op
|
||||
{:batch_num (undo/next-batch-num :redo user-id scope)}
|
||||
;; IDEA encapsulate this in an action
|
||||
;; IDEA use generic action calling API instead of having this endpoint
|
||||
;; TODO translate errors to http codes
|
||||
(try
|
||||
{:result (undo/redo! user-id scope)}
|
||||
(catch ExceptionInfo e
|
||||
(throw (translate-undo-error e)))))))
|
||||
|
||||
(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)
|
||||
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?)))))
|
||||
|
||||
(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]]]
|
||||
(let [action (-> (actions/select-action :id (parse-long action-id) :archived false)
|
||||
(t2/hydrate :creator)
|
||||
api/read-check)
|
||||
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)
|
||||
[row] (data-editing/query-db-rows table-id pk-fields [pk])
|
||||
_ (api/check-404 row)
|
||||
row-params (->> (:parameters action)
|
||||
(keep (fn [{:keys [id slug]}]
|
||||
(when (contains? field-names (or slug id))
|
||||
[id (row (keyword (or slug id)))])))
|
||||
(into {}))
|
||||
param-id (u/index-by (some-fn :slug :id) :id (:parameters action))
|
||||
provided (update-keys params #(api/check-400 (param-id (name %)) "Unexpected parameter provided"))]
|
||||
(actions/execute-action! action (merge row-params provided))))
|
||||
|
||||
(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,8 @@
|
||||
(ns metabase-enterprise.data-editing.core
|
||||
(:require
|
||||
[metabase-enterprise.data-editing.data-editing :as data-editing]
|
||||
[potemkin :as p]))
|
||||
|
||||
(p/import-vars
|
||||
[data-editing
|
||||
insert!])
|
||||
@@ -0,0 +1,192 @@
|
||||
(ns metabase-enterprise.data-editing.data-editing
|
||||
(:require
|
||||
[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]
|
||||
[nano-id.core :as nano-id]
|
||||
[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 k identity))))]
|
||||
(for [row input-rows]
|
||||
(m/map-kv-vals coerce row))))
|
||||
|
||||
(defn perform-bulk-action!
|
||||
"Operates on rows in the database, supply an action-kw: :table.row/create, :table.row/update, :table.row/delete.
|
||||
The input `rows` is given different semantics depending on the action type, see actions/perform-action!."
|
||||
[action-kw user-id scope table-id rows & {:keys [existing-context]}]
|
||||
;; TODO make this work for multi instances by using the metabase_cluster_lock table, or something similar
|
||||
;; https://github.com/metabase/metabase/pull/56173/files
|
||||
(locking #'perform-bulk-action!
|
||||
(let [db-id (api/check-404 (t2/select-one-fn :db_id [:model/Table :db_id] table-id))]
|
||||
(->> (actions/perform-action!
|
||||
action-kw
|
||||
scope
|
||||
(for [row rows]
|
||||
{:database db-id, :table-id table-id, :row row})
|
||||
{:policy :data-editing
|
||||
:existing-context (if existing-context
|
||||
(assoc existing-context :user-id user-id)
|
||||
(let [iid (nano-id/nano-id)]
|
||||
{:invocation-id iid
|
||||
:invocation-stack [[:grid/edit iid]]
|
||||
:user-id user-id
|
||||
:scope scope}))})
|
||||
:effects
|
||||
(filter (comp #{:effect/row.modified} first))
|
||||
(map second)))))
|
||||
|
||||
(defn insert!
|
||||
"Inserts rows into the table, recording their creation as an event. Returns the inserted records.
|
||||
Expects rows that are acceptable directly by [[actions/perform-action!]]. If casts or reversing coercion strategy
|
||||
are required, that work must be done before calling this function."
|
||||
[user-id scope table-id rows]
|
||||
(api/check-500 user-id)
|
||||
(let [res (perform-bulk-action! :table.row/create user-id scope table-id rows)]
|
||||
{:created-rows (map :after res)}))
|
||||
|
||||
(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 #{"undo"} namespace 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
|
||||
:args {:table_id table-id
|
||||
:db_id db-id
|
||||
:timestamp (t/zoned-date-time (t/zone-id "UTC"))}}))))))
|
||||
@@ -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,3 @@
|
||||
(ns metabase-enterprise.data-editing.init
|
||||
(:require
|
||||
[metabase-enterprise.data-editing.events]))
|
||||
232
enterprise/backend/src/metabase_enterprise/data_editing/undo.clj
Normal file
232
enterprise/backend/src/metabase_enterprise/data_editing/undo.clj
Normal file
@@ -0,0 +1,232 @@
|
||||
(ns metabase-enterprise.data-editing.undo
|
||||
(:require
|
||||
[clojure.walk :as walk]
|
||||
[metabase-enterprise.data-editing.data-editing :as data-editing]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.util :as u]
|
||||
[methodical.core :as methodical]
|
||||
[nano-id.core :as nano-id]
|
||||
[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- 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
|
||||
(->> (or scope "unknown") (walk/postwalk #(if-not (map? %) % (apply sorted-map (apply concat %)))) pr-str)))
|
||||
|
||||
(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})
|
||||
|
||||
(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 user-id scope batch]
|
||||
(let [undo? (= :undo undo-or-redo)
|
||||
action-kw (keyword "undo" (name undo-or-redo))]
|
||||
(doseq [[[table-id category] sub-batch] (u/group-by (juxt :table_id categorize) batch)
|
||||
:let [rows (batch->rows undo? sub-batch)
|
||||
iid (nano-id/nano-id)
|
||||
opts {:existing-context {:invocation_id iid
|
||||
:invocation-stack [[action-kw iid]]}}]]
|
||||
(case (if undo? (invert category) category)
|
||||
:create (try (data-editing/perform-bulk-action! :table.row/create user-id scope table-id rows opts)
|
||||
(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 (data-editing/perform-bulk-action! :table.row/update user-id scope table-id rows opts)
|
||||
:delete (data-editing/perform-bulk-action! :table.row/delete user-id scope table-id rows opts)))))
|
||||
|
||||
(defn- undo*! [undo-or-redo 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 user-id scope batch)
|
||||
(t2/update! :model/Undo
|
||||
{:batch_num (:batch_num (first batch))}
|
||||
{:undone undo?})
|
||||
(->> (for [[table-id sub-batch] (u/group-by :table_id batch)]
|
||||
[table-id (map vector
|
||||
(map (comp (if undo? invert identity) categorize) sub-batch)
|
||||
(batch->rows undo? sub-batch))])
|
||||
(into {}))))))
|
||||
|
||||
(defn undo!
|
||||
"Rollback the given user's last change to the given table."
|
||||
[user-id scope]
|
||||
(undo*! :undo user-id scope))
|
||||
|
||||
(defn redo!
|
||||
"Rollback the given user's last change to the given table."
|
||||
[user-id scope]
|
||||
(undo*! :redo user-id scope))
|
||||
@@ -0,0 +1,33 @@
|
||||
(ns metabase-enterprise.data-editing-public.api
|
||||
(:require
|
||||
[metabase-enterprise.data-editing.core :as data-editing]
|
||||
[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.")
|
||||
;; TODO just call the action directly once we have it, get rid of this `insert!` method.
|
||||
{:created (count (:created-rows (data-editing/insert! (:creator_id hook)
|
||||
{:webhook-id (:id hook)}
|
||||
(:table_id hook)
|
||||
rows)))}))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/data-editing routes."
|
||||
(api.macros/ns-handler *ns*))
|
||||
@@ -62,6 +62,7 @@
|
||||
"Notification"
|
||||
"NotificationCard"
|
||||
"NotificationSubscription"
|
||||
"NotificationSystemEvent"
|
||||
"NotificationHandler"
|
||||
"NotificationRecipient"
|
||||
"Permissions"
|
||||
@@ -86,6 +87,7 @@
|
||||
"Secret"
|
||||
"Session"
|
||||
"TaskHistory"
|
||||
"Undo"
|
||||
"User"
|
||||
"UserKeyValue"
|
||||
"UserParameterValue"
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
:sso_jwt true
|
||||
:sso_ldap true
|
||||
:sso_saml true
|
||||
:table_data_editing false
|
||||
:upload_management true
|
||||
:whitelabel true
|
||||
:collection_cleanup true
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
(ns metabase-enterprise.data-editing.api-test
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.data-editing.api :as data-editing.api]
|
||||
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.driver.sql-jdbc :as sql-jdbc]
|
||||
[metabase.models.field-values :as field-values]
|
||||
[metabase.query-processor :as qp]
|
||||
[metabase.sync.core :as sync]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.json :as json]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- table-rows [table-id]
|
||||
(mt/rows
|
||||
(qp/process-query
|
||||
@@ -18,74 +25,666 @@
|
||||
(defn- table-url [table-id]
|
||||
(format "ee/data-editing/table/%d" table-id))
|
||||
|
||||
(use-fixtures :each
|
||||
(fn [f]
|
||||
(mt/with-dynamic-fn-redefs [data-editing.api/require-authz? (constantly true)]
|
||||
(f))))
|
||||
|
||||
(deftest feature-flag-required-test
|
||||
(mt/with-premium-features #{}
|
||||
(let [url (table-url 1)]
|
||||
(let [url (data-editing.tu/table-url 1)]
|
||||
(mt/assert-has-premium-feature-error "Editing Table Data" (mt/user-http-request :crowberto :post 402 url))
|
||||
(mt/assert-has-premium-feature-error "Editing Table Data" (mt/user-http-request :crowberto :put 402 url))
|
||||
(mt/assert-has-premium-feature-error "Editing Table Data" (mt/user-http-request :crowberto :delete 402 url)))))
|
||||
(mt/assert-has-premium-feature-error "Editing Table Data" (mt/user-http-request :crowberto :post 402 (str url "/delete"))))))
|
||||
|
||||
(deftest table-operations-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/with-empty-h2-app-db
|
||||
(let [db (t2/select-one :model/Database (mt/id))
|
||||
driver :h2
|
||||
table-name (str "temp_table_" (u/lower-case-en (random-uuid)))]
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(with-open [table-ref (data-editing.tu/open-test-table!)]
|
||||
(let [table-id @table-ref
|
||||
url (data-editing.tu/table-url table-id)]
|
||||
(data-editing.tu/toggle-data-editing-enabled! true)
|
||||
(testing "Initially the table is empty"
|
||||
(is (= [] (table-rows table-id))))
|
||||
|
||||
(testing "POST should insert new rows"
|
||||
(is (= {:created-rows [{:id 1 :name "Pidgey" :song "Car alarms"}
|
||||
{:id 2 :name "Spearow" :song "Hold music"}
|
||||
{:id 3 :name "Farfetch'd" :song "The land of lisp"}]}
|
||||
(mt/user-http-request :crowberto :post 200 url
|
||||
{:rows [{:name "Pidgey" :song "Car alarms"}
|
||||
{:name "Spearow" :song "Hold music"}
|
||||
{:name "Farfetch'd" :song "The land of lisp"}]})))
|
||||
|
||||
(is (= [[1 "Pidgey" "Car alarms"]
|
||||
[2 "Spearow" "Hold music"]
|
||||
[3 "Farfetch'd" "The land of lisp"]]
|
||||
(table-rows table-id))))
|
||||
|
||||
(testing "PUT should update the relevant rows and columns"
|
||||
(is (= #{{:id 1, :name "Pidgey", :song "Join us now and share the software"}
|
||||
{:id 2, :name "Speacolumn", :song "Hold music"}}
|
||||
(set
|
||||
(:updated
|
||||
(mt/user-http-request :crowberto :put 200 url
|
||||
{:rows [{:id 1 :song "Join us now and share the software"}
|
||||
{:id 2 :name "Speacolumn"}]})))))
|
||||
|
||||
(is (= #{[1 "Pidgey" "Join us now and share the software"]
|
||||
[2 "Speacolumn" "Hold music"]
|
||||
[3 "Farfetch'd" "The land of lisp"]}
|
||||
(set (table-rows table-id)))))
|
||||
|
||||
(testing "DELETE should remove the corresponding rows"
|
||||
(is (= {:success true}
|
||||
(mt/user-http-request :crowberto :post 200 (str url "/delete")
|
||||
{:rows [{:id 1}
|
||||
{:id 2}]})))
|
||||
(is (= [[3 "Farfetch'd" "The land of lisp"]]
|
||||
(table-rows table-id)))))))))
|
||||
|
||||
(deftest editing-allowed-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(testing "40x returned if user/database not configured for editing"
|
||||
(let [test-endpoints
|
||||
(fn [flags]
|
||||
(with-open [table-ref (data-editing.tu/open-test-table!)]
|
||||
(let [actions-enabled (:a flags)
|
||||
editing-enabled (:d flags)
|
||||
superuser (:s flags)
|
||||
url (data-editing.tu/table-url @table-ref)
|
||||
settings {:database-enable-table-editing (boolean editing-enabled)
|
||||
:database-enable-actions (boolean actions-enabled)}
|
||||
_ (data-editing.tu/alter-appdb-settings! merge settings)
|
||||
user (if superuser :crowberto :rasta)
|
||||
req mt/user-http-request-full-response
|
||||
|
||||
post-response
|
||||
(req user :post url {:rows [{:name "Pidgey" :song "Car alarms"}]})
|
||||
|
||||
put-response
|
||||
(req user :put url {:rows [{:id 1 :song "Join us now and share the software"}]})
|
||||
|
||||
del-response
|
||||
(req user :post (str url "/delete") {:rows [{:id 1}]})]
|
||||
{:settings settings
|
||||
:user user
|
||||
:responses {:create post-response
|
||||
:update put-response
|
||||
:delete del-response}})))
|
||||
|
||||
error-or-ok
|
||||
(fn [{:keys [status body]}]
|
||||
(if (<= 200 status 299)
|
||||
:ok
|
||||
[(:message body body) status]))
|
||||
|
||||
;; Shorthand config notation
|
||||
;; :a == action-editing should not affect result
|
||||
;; :d == data-editing only allowed to edit if editing enabled
|
||||
;; :s == super-user only allowed to edit if a superuser
|
||||
tests
|
||||
[#{:a} ["You don't have permissions to do that." 403]
|
||||
#{:d} ["You don't have permissions to do that." 403]
|
||||
#{:a :d} ["You don't have permissions to do that." 403]
|
||||
#{:s} ["Data editing is not enabled." 400]
|
||||
#{:s :a} ["Data editing is not enabled." 400]
|
||||
#{:s :d} :ok
|
||||
#{:s :a :d} :ok]]
|
||||
(doseq [[flags expected] (partition 2 tests)
|
||||
:let [{:keys [settings user responses]} (test-endpoints flags)]
|
||||
[verb response] responses]
|
||||
(testing (format "%s user: %s, settings: %s" verb user settings)
|
||||
(is (= expected (error-or-ok response))))))))))
|
||||
|
||||
(deftest create-table-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(let [run-example
|
||||
(fn [flags req-body]
|
||||
(let [{table-name-prefix :name} req-body
|
||||
table-name (str table-name-prefix "_" (System/currentTimeMillis))
|
||||
req-body' (u/update-if-exists req-body :name (constantly table-name))
|
||||
db-id (mt/id)
|
||||
driver driver/*driver*
|
||||
editing-enabled (:d flags)
|
||||
superuser (:s flags)
|
||||
_ (data-editing.tu/toggle-data-editing-enabled! editing-enabled)
|
||||
user (if superuser :crowberto :rasta)
|
||||
url (format "ee/data-editing/database/%d/table" db-id)
|
||||
res (delay (mt/user-http-request-full-response user :post url req-body'))
|
||||
cleanup! #(try (driver/drop-table! driver db-id table-name) (catch Exception _))
|
||||
describe-table
|
||||
(fn []
|
||||
(-> (driver/describe-table driver (t2/select-one :model/Database db-id) {:name table-name})
|
||||
(update :name {table-name table-name-prefix})
|
||||
(update :fields #(sort-by :name (for [f %] (select-keys f [:name :base-type :pk?]))))))]
|
||||
(try
|
||||
(if (<= 200 (:status @res) 299)
|
||||
(merge
|
||||
{:status 200}
|
||||
(describe-table))
|
||||
(:status @res))
|
||||
(finally
|
||||
(cleanup!)))))]
|
||||
|
||||
(are [flags req-body expected]
|
||||
(= expected (run-example flags req-body))
|
||||
|
||||
#{:s :d}
|
||||
{}
|
||||
400
|
||||
|
||||
#{:s :d}
|
||||
{:name "a"}
|
||||
400
|
||||
|
||||
#{:s :d}
|
||||
{:name "a"
|
||||
:columns [[{:name "id", :type "int"}]]}
|
||||
400
|
||||
|
||||
#{:s :d}
|
||||
{:name "a"
|
||||
:columns [{:name "id", :type "int"}
|
||||
{:name "name", :type "int"}]
|
||||
:primary_key ["id"]}
|
||||
;; =>
|
||||
{:status 200
|
||||
:name "a"
|
||||
:fields [{:name "id"
|
||||
:base-type :type/BigInteger
|
||||
:pk? true}
|
||||
{:name "name"
|
||||
:base-type :type/BigInteger}]}
|
||||
|
||||
#{:s :d}
|
||||
{:name "a"
|
||||
:columns [{:name "id", :type "not-a-type"}]
|
||||
:primary_key ["id"]}
|
||||
;; =>
|
||||
400
|
||||
|
||||
;; escaped quotes are not allowed for now
|
||||
#{:s :d}
|
||||
{:name "a\""
|
||||
:columns [{:name "id", :type "int"}]
|
||||
:primary_key ["id"]}
|
||||
400
|
||||
#{:s :d}
|
||||
{:name "a`"
|
||||
:columns [{:name "id", :type "int"}]
|
||||
:primary_key ["id"]}
|
||||
400
|
||||
|
||||
;; underscores, dashes, spaces allowed
|
||||
#{:s :d}
|
||||
{:name "a_b1 -"
|
||||
:columns [{:name "id", :type "int"}]
|
||||
:primary_key ["id"]}
|
||||
;; =>
|
||||
{:status 200
|
||||
:name "a_b1 -"
|
||||
:fields [{:name "id"
|
||||
:base-type :type/BigInteger
|
||||
:pk? true}]}
|
||||
|
||||
;; if not admin, denied
|
||||
#{:d}
|
||||
{:name "a"
|
||||
:columns [{:name "id", :type "int"}]
|
||||
:primary_key ["id"]}
|
||||
403
|
||||
|
||||
;; data editing disabled, denied
|
||||
#{:s}
|
||||
{:name "a"
|
||||
:columns [{:name "id", :type "int"}]
|
||||
:primary_key ["id"]}
|
||||
400
|
||||
|
||||
;; compound pk
|
||||
#{:s :d}
|
||||
{:name "a"
|
||||
:columns [{:name "id_p1", :type "int"}
|
||||
{:name "id_p2", :type "int"}]
|
||||
:primary_key ["id_p1" "id_p2"]}
|
||||
;; =>
|
||||
{:status 200
|
||||
:name "a"
|
||||
:fields [{:name "id_p1"
|
||||
:base-type :type/BigInteger
|
||||
:pk? true}
|
||||
{:name "id_p2"
|
||||
:base-type :type/BigInteger
|
||||
:pk? true}]})))))
|
||||
|
||||
(deftest create-table-auto-inc-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(data-editing.tu/toggle-data-editing-enabled! true)
|
||||
(let [db-id (mt/id)
|
||||
url (format "ee/data-editing/database/%d/table" db-id)
|
||||
user :crowberto
|
||||
table-name (str "test_table_" (System/currentTimeMillis))
|
||||
req-body {:name table-name
|
||||
:columns [{:name "id", :type "auto_incrementing_int_pk"}
|
||||
{:name "n", :type "int"}]
|
||||
:primary_key ["id"]}
|
||||
_ (mt/user-http-request user :post 200 url req-body)
|
||||
db (t2/select-one :model/Database db-id)
|
||||
table-id (data-editing.tu/sync-new-table! db table-name)
|
||||
create! #(mt/user-http-request user :post 200 (table-url table-id) {:rows %})]
|
||||
(create! [{:n 1} {:n 2}])
|
||||
(is (= [[1 1] [2 2]] (table-rows table-id)))))))
|
||||
|
||||
(deftest coercion-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(data-editing.tu/toggle-data-editing-enabled! true)
|
||||
(let [user :crowberto
|
||||
req mt/user-http-request
|
||||
create!
|
||||
#(req user :post (data-editing.tu/table-url %1) {:rows %2})
|
||||
|
||||
update!
|
||||
#(req user :put (data-editing.tu/table-url %1) {:rows %2})
|
||||
|
||||
always-lossy
|
||||
#{:Coercion/UNIXNanoSeconds->DateTime
|
||||
:Coercion/UNIXMicroSeconds->DateTime
|
||||
:Coercion/ISO8601->Date
|
||||
:Coercion/ISO8601->Time}
|
||||
|
||||
driver-lossy
|
||||
(case driver/*driver*
|
||||
:postgres #{:Coercion/UNIXMilliSeconds->DateTime}
|
||||
#{})
|
||||
|
||||
lossy? (set/union always-lossy driver-lossy)
|
||||
|
||||
do-test
|
||||
(fn [t coercion-strategy input expected]
|
||||
(testing (str t " " coercion-strategy " " input)
|
||||
(with-open [table (data-editing.tu/open-test-table!
|
||||
{:id 'auto-inc-type
|
||||
:o [t :null]}
|
||||
{:primary-key [:id]})]
|
||||
(let [table-id @table
|
||||
table-name-kw (t2/select-one-fn (comp keyword :name) [:model/Table :name] table-id)
|
||||
field-id (t2/select-one-fn :id [:model/Field :id] :table_id table-id :name "o")
|
||||
driver driver/*driver*
|
||||
get-qp-state (fn [] (map #(zipmap [:id :o] %) (table-rows table-id)))
|
||||
get-db-state (fn [] (sql-jdbc/query driver (mt/id) {:select [:*] :from [table-name-kw]}))]
|
||||
(t2/update! :model/Field field-id {:coercion_strategy coercion-strategy})
|
||||
(testing "create"
|
||||
(let [row {:o input}
|
||||
{returned-state :created-rows} (create! table-id [row])
|
||||
qp-state (get-qp-state)
|
||||
_ (is (= 1 (count returned-state)))]
|
||||
(when-not (lossy? coercion-strategy)
|
||||
(is (= qp-state returned-state) "we should return the same coerced output that table/$table-id/data would return")
|
||||
(is (= input (:o (first qp-state))) "the qp value should be the same as the input"))
|
||||
(is (= expected (:o (first (get-db-state)))))))
|
||||
(testing "update"
|
||||
(let [[{id :id}] (:created-rows (create! table-id [{:o nil}]))
|
||||
_ (is (some? id))
|
||||
{returned-state :updated} (update! table-id [{:id id, :o input}])
|
||||
[qp-row] (filter (comp #{id} :id) (get-qp-state))]
|
||||
(is (= 1 (count returned-state)))
|
||||
(is (some? qp-row))
|
||||
(when-not (lossy? coercion-strategy)
|
||||
(is (= [qp-row] returned-state))
|
||||
(is (= input (:o qp-row))))
|
||||
(is (= expected (:o (first (get-db-state)))))))))))]
|
||||
|
||||
;; type coercion input database
|
||||
(->> [:text nil "a" "a"
|
||||
:text :Coercion/YYYYMMDDHHMMSSString->Temporal "2025-03-25T14:34:00Z" "20250325143400"
|
||||
:text :Coercion/ISO8601->DateTime "2025-03-25T14:34:42.314Z" "2025-03-25T14:34:42.314Z"
|
||||
:text :Coercion/ISO8601->Date "2025-03-25T00:00:00Z" "2025-03-25"
|
||||
:text :Coercion/ISO8601->Time "1999-04-05T14:34:42Z" "14:34:42"
|
||||
|
||||
;; note fractional seconds in input, remains undefined for Seconds
|
||||
:int :Coercion/UNIXSeconds->DateTime "2025-03-25T14:34:42Z" (quot (inst-ms #inst "2025-03-25T14:34:42Z") 1000)
|
||||
:bigint :Coercion/UNIXMilliSeconds->DateTime "2025-03-25T14:34:42.314Z" (inst-ms #inst "2025-03-25T14:34:42.314Z")
|
||||
|
||||
;; note fractional secs beyond millis are discarded (lossy)
|
||||
:bigint :Coercion/UNIXMicroSeconds->DateTime "2025-03-25T14:34:42.314121Z" (* (inst-ms #inst "2025-03-25T14:34:42.314Z") 1000)
|
||||
:bigint :Coercion/UNIXNanoSeconds->DateTime "2025-03-25T14:34:42.3141212Z" (* (inst-ms #inst "2025-03-25T14:34:42.314Z") 1000000)
|
||||
|
||||
;; nil safe
|
||||
:text :Coercion/YYYYMMDDHHMMSSString->Temporal nil nil
|
||||
|
||||
;; seconds component does not work properly here, lost by qp output, bug in existing code?
|
||||
#_#_#_#_:text :Coercion/YYYYMMDDHHMMSSString->Temporal "2025-03-25T14:34:42Z" "20250325143442"]
|
||||
(partition 4)
|
||||
(run! #(apply do-test %)))))))
|
||||
|
||||
(deftest webhook-creation-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(with-open [test-table (data-editing.tu/open-test-table!)]
|
||||
(let [url "ee/data-editing/webhook"
|
||||
req #(mt/user-http-request-full-response %1 :post url %2)
|
||||
status (comp :status req)
|
||||
table-id @test-table
|
||||
not-a-table-id Long/MAX_VALUE]
|
||||
(testing "auth fail"
|
||||
(is (= 403 (status :rasta {})))
|
||||
(is (= 403 (status :rasta {:table-id table-id})) "no information leakage"))
|
||||
(testing "creates token"
|
||||
(let [token (:token (:body (req :crowberto {:table-id table-id})))]
|
||||
(is (string? token))
|
||||
(testing "token in database"
|
||||
(is (some? (t2/select-one :table_webhook_token :token token))))
|
||||
(testing "new token if called again"
|
||||
(is (not= token (:token (req :crowberto {:table-id table-id}))))
|
||||
(testing "table does not exist"
|
||||
(is (= 404 (status :crowberto {:table-id not-a-table-id}))))))))))))
|
||||
|
||||
(deftest webhook-list-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(with-open [test-table1 (data-editing.tu/open-test-table!)
|
||||
test-table2 (data-editing.tu/open-test-table!)]
|
||||
(let [url "ee/data-editing/webhook"
|
||||
req #(mt/user-http-request-full-response %1 :get url :table-id %2)
|
||||
create-url "ee/data-editing/webhook"
|
||||
create #(:body (mt/user-http-request-full-response :crowberto :post create-url {:table-id %}))
|
||||
status (comp :status req)
|
||||
table-id1 @test-table1
|
||||
table-id2 @test-table2
|
||||
not-a-table-id Long/MAX_VALUE]
|
||||
(testing "auth fail"
|
||||
(is (= 403 (status :rasta table-id1)))
|
||||
(is (= 403 (status :rasta not-a-table-id)) "no information leakage"))
|
||||
(testing "table does not exist"
|
||||
(is (= 404 (status :crowberto not-a-table-id))))
|
||||
(testing "no tokens"
|
||||
(is (= [] (:tokens (:body (req :crowberto table-id1))))))
|
||||
(testing "n tokens"
|
||||
(let [{token1 :token} (create table-id1)
|
||||
{token2 :token} (create table-id2)
|
||||
{token3 :token} (create table-id1)
|
||||
table1-res (:body (req :crowberto table-id1))
|
||||
table2-res (:body (req :crowberto table-id2))
|
||||
table1-tokens (map :token (:tokens table1-res))
|
||||
table2-tokens (map :token (:tokens table2-res))]
|
||||
(is (= {token1 1 token3 1} (frequencies table1-tokens)))
|
||||
(is (= [token2] table2-tokens)))))))))
|
||||
|
||||
(deftest webhook-delete-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(with-open [test-table (data-editing.tu/open-test-table!)]
|
||||
(let [url #(format "ee/data-editing/webhook/%s" %)
|
||||
req #(mt/user-http-request-full-response %1 :delete (url %2) {})
|
||||
create-url "ee/data-editing/webhook"
|
||||
create #(:body (mt/user-http-request-full-response :crowberto :post create-url {:table-id %}))
|
||||
list-url "ee/data-editing/webhook"
|
||||
list-tokens #(:body (mt/user-http-request-full-response :crowberto :get list-url :table-id %))
|
||||
status (comp :status req)
|
||||
table-id @test-table
|
||||
{token :token} (create table-id)
|
||||
not-a-token (str (random-uuid))]
|
||||
(testing "auth fail"
|
||||
(is (= 403 (status :rasta token)))
|
||||
(is (= 403 (status :rasta not-a-token)) "no information leakage"))
|
||||
(testing "token does not exist"
|
||||
(is (= 404 (status :crowberto not-a-token))))
|
||||
(testing "token does exist"
|
||||
(is (some #{token} (map :token (:tokens (list-tokens table-id)))))
|
||||
(is (= 200 (status :crowberto token)))
|
||||
(is (not-any? #{token} (map :token (:tokens (list-tokens table-id))))))
|
||||
(testing "token does not exist when deleted"
|
||||
(is (= 404 (status :crowberto token)))))))))
|
||||
|
||||
(deftest webhook-ingest-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(data-editing.tu/toggle-data-editing-enabled! true)
|
||||
(with-open [test-table (data-editing.tu/open-test-table!
|
||||
{:id [:int]
|
||||
:v [:text]}
|
||||
{:primary-key [:id]})]
|
||||
(let [url #(format "ee/data-editing-public/webhook/%s/data" %)
|
||||
req #(mt/client-full-response
|
||||
:post (url %1)
|
||||
{:request-options {:body (.getBytes (json/encode %2))}})
|
||||
status (comp :status req)
|
||||
result (comp
|
||||
(fn [req]
|
||||
(is (= 200 (:status req)))
|
||||
(:body req))
|
||||
req)
|
||||
create-url "ee/data-editing/webhook"
|
||||
create #(:body (mt/user-http-request-full-response :crowberto :post create-url {:table-id %}))
|
||||
delete-url #(format "ee/data-editing/webhook/%s" %)
|
||||
delete #(mt/user-http-request :crowberto :delete (delete-url %))
|
||||
table-id @test-table
|
||||
{token :token} (create table-id)
|
||||
not-a-token (str (random-uuid))]
|
||||
(testing "token does not exist"
|
||||
(is (= 404 (status not-a-token [{:v "foo"}]))))
|
||||
(testing "empty rows"
|
||||
(are [input code]
|
||||
(= code (status token input))
|
||||
nil 400
|
||||
{} 400
|
||||
[] 400
|
||||
[{}] 400))
|
||||
(testing "one row in array"
|
||||
(is (= {:created 1} (result token [{:id 1, :v "a"}])))
|
||||
(is (= [[1 "a"]] (table-rows table-id))))
|
||||
(testing "multiple rows in array"
|
||||
(is (= {:created 2} (result token [{:id 2, :v "b"} {:id 3, :v "c"}])))
|
||||
(is (= [[1 "a"] [2 "b"] [3 "c"]] (table-rows table-id))))
|
||||
(testing "missing pk"
|
||||
(is (= 400 (status token [{:v "d"}]))))
|
||||
(testing "insert collision"
|
||||
(is (= 400 (status token [{:id 1, :v "a"}])))
|
||||
(testing "partial failure"
|
||||
(let [rows-before (table-rows table-id)]
|
||||
(is (= 400 (status token [{:id 4, :v "d"} {:id 1, :v "a"}])))
|
||||
(is (= rows-before (table-rows table-id))))))
|
||||
(testing "wrong columns"
|
||||
(is (= 400 (status token [{:id 1, :not_a_column "a"}]))))
|
||||
(testing "data editing disabled"
|
||||
(try
|
||||
(data-editing.tu/toggle-data-editing-enabled! false)
|
||||
(is (= 400 (status token [{:id 4, :v "d"}])))
|
||||
(data-editing.tu/toggle-data-editing-enabled! true)
|
||||
(is (= {:created 1} (result token [{:id 4, :v "d"}])))
|
||||
(finally
|
||||
(data-editing.tu/toggle-data-editing-enabled! true))))
|
||||
(testing "token deleted"
|
||||
(delete token)
|
||||
(is (= 404 (status token [{:id 5, :v "e"}])))))))))
|
||||
;; It would be nice to have for-all config/inputs type tests verifying
|
||||
;; insert behaviour is same as the POST data-editing/table inserts (collision, violation error, event)
|
||||
|
||||
(deftest field-values-invalidated-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(data-editing.tu/toggle-data-editing-enabled! true)
|
||||
(with-open [table (data-editing.tu/open-test-table! {:id 'auto-inc-type, :n [:text]} {:primary-key [:id]})]
|
||||
(let [table-id @table
|
||||
url (data-editing.tu/table-url table-id)
|
||||
field-id (t2/select-one-fn :id :model/Field :table_id table-id :name "n")
|
||||
_ (t2/update! :model/Field {:id field-id} {:semantic_type "type/Category"})
|
||||
field-values #(vec (:values (field-values/get-latest-full-field-values field-id)))
|
||||
create! #(mt/user-http-request :crowberto :post 200 url {:rows %})
|
||||
update! #(mt/user-http-request :crowberto :put 200 url {:rows %})]
|
||||
(is (= [] (field-values)))
|
||||
(create! [{:n "a"}])
|
||||
;; It's async
|
||||
(Thread/sleep 10)
|
||||
(is (= ["a"] (field-values)))
|
||||
(create! [{:n "b"} {:n "c"}])
|
||||
;; It's async
|
||||
(Thread/sleep 10)
|
||||
(is (= ["a" "b" "c"] (field-values)))
|
||||
(update! [{:id 2, :n "d"}])
|
||||
;; It's async
|
||||
(Thread/sleep 10)
|
||||
(is (= ["a" "c" "d"] (field-values)))
|
||||
(create! [{:n "a"}])
|
||||
(update! [{:id 1, :n "e"}])
|
||||
;; It's async
|
||||
(Thread/sleep 10)
|
||||
(is (= ["a" "c" "d" "e"] (field-values))))))))
|
||||
|
||||
(deftest get-row-action-test
|
||||
(let [url #(format "ee/data-editing/row-action/%s" %)
|
||||
action-api #(mt/user-http-request :crowberto :get 200 (format "action/%s" %))]
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(testing "no dashcard"
|
||||
(mt/with-temp [:model/Card model {:type :model}
|
||||
:model/Action action {:type :query
|
||||
:name "test_action"
|
||||
:model_id (:id model)}]
|
||||
(testing "not specified"
|
||||
(is (= 400 (:status (mt/user-http-request-full-response :crowberto :get (url (:id action)))))))
|
||||
(testing "specified but does not exist"
|
||||
(is (= 404 (:status (mt/user-http-request-full-response :crowberto :get (url (:id action)) :dashcard-id 9999999)))))))
|
||||
(testing "no action"
|
||||
(mt/with-temp [:model/Dashboard dash {}
|
||||
:model/DashboardCard dashcard {:dashboard_id (:id dash)}]
|
||||
(is (= 404 (:status (mt/user-http-request-full-response :crowberto :get (url 99999999) :dashcard-id (:id dashcard)))))
|
||||
(testing "no dashcard still results in 400"
|
||||
(is (= 400 (:status (mt/user-http-request-full-response :crowberto :get (url 9999999))))))))
|
||||
(testing "everything exists, no params defined"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Table table {}
|
||||
:model/Card model {:type :model
|
||||
:table_id (:id table)}
|
||||
:model/Action action {:type :query
|
||||
:name "test_action"
|
||||
:model_id (:id model)}
|
||||
:model/Dashboard dash {}
|
||||
:model/DashboardCard dashcard {:dashboard_id (:id dash)
|
||||
:card_id (:id model)}]
|
||||
(testing "no access"
|
||||
(is (= 403 (:status (mt/user-http-request-full-response :rasta :get (url (:id action)) :dashcard-id (:id dashcard))))))
|
||||
(testing "action access"
|
||||
(is (= {:status 200
|
||||
:body (action-api (:id action))}
|
||||
(-> (mt/user-http-request-full-response :crowberto :get (url (:id action)) :dashcard-id (:id dashcard))
|
||||
(select-keys [:status
|
||||
:body]))))))))
|
||||
(testing "parameters defined"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Table table {}
|
||||
:model/Field _a {:table_id (:id table)
|
||||
:name "a"}
|
||||
:model/Field _b {:table_id (:id table)
|
||||
:name "b"}
|
||||
:model/Field _c {:table_id (:id table)
|
||||
:name "c"}
|
||||
:model/Card model {:type :model
|
||||
:table_id (:id table)}
|
||||
:model/Action action {:type :query
|
||||
:name "test_action"
|
||||
:model_id (:id model)
|
||||
:parameters [{:slug "a"}
|
||||
{:slug "e"}
|
||||
{:slug "c"}
|
||||
{:slug "d"}]}
|
||||
:model/Dashboard dash {}
|
||||
:model/DashboardCard dashcard {:dashboard_id (:id dash)
|
||||
:card_id (:id model)}]
|
||||
(let [{:keys [status, body]} (mt/user-http-request-full-response :crowberto :get (url (:id action)) :dashcard-id (:id dashcard))]
|
||||
(is (= 200 status))
|
||||
(is (= ["e" "d"]
|
||||
(->> body
|
||||
:parameters
|
||||
(map :slug))))))))))))
|
||||
|
||||
(deftest row-action-execute-test
|
||||
(let [url #(format "ee/data-editing/row-action/%s/execute" %)
|
||||
req #(apply mt/user-http-request-full-response
|
||||
(:user %)
|
||||
:post
|
||||
(url (:action-id %))
|
||||
(:body %)
|
||||
(mapcat identity (:query-params %)))]
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(data-editing.tu/toggle-data-editing-enabled! true)
|
||||
(mt/with-actions-enabled
|
||||
(try
|
||||
(let [_ (driver/create-table! driver
|
||||
(mt/id)
|
||||
table-name
|
||||
{:id (driver/upload-type->database-type driver :metabase.upload/auto-incrementing-int-pk)
|
||||
:name [:text]
|
||||
:song [:text]}
|
||||
{:primary-key [:id]})
|
||||
table (sync/create-table! db
|
||||
{:name table-name
|
||||
:schema nil
|
||||
:display_name table-name})
|
||||
_ (sync/sync-fields-for-table! db table)
|
||||
table-id (:id table)
|
||||
url (table-url table-id)]
|
||||
(testing "no dashcard"
|
||||
(mt/with-temp [:model/Card model {:type :model}
|
||||
:model/Action action {:type :query
|
||||
:name "test_action"
|
||||
:model_id (:id model)}]
|
||||
(testing "not specified"
|
||||
(is (= 400 (:status (req {:user :crowberto, :action-id (:id action), :body {:pk {}, :params {}}})))))
|
||||
(testing "specified but does not exist"
|
||||
(is (= 404 (:status (req {:user :crowberto
|
||||
:action-id (:id action)
|
||||
:body {:pk {}, :params {}}
|
||||
:query-params {:dashcard-id 999999}})))))))
|
||||
(testing "no action"
|
||||
(mt/with-temp [:model/Dashboard dash {}
|
||||
:model/DashboardCard dashcard {:dashboard_id (:id dash)}]
|
||||
(is (= 404 (:status (req {:user :crowberto
|
||||
:post 999999
|
||||
:body {:pk {}, :params {}}
|
||||
:query-params {:dashcard-id (:id dashcard)}}))))
|
||||
(testing "no dashcard still results in 400"
|
||||
(is (= 400 (:status (req {:user :crowberto, :post 999999, :body {:pk {}, :params {}}})))))))
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(with-open [test-table (data-editing.tu/open-test-table! {:id 'auto-inc-type
|
||||
:name [:text]
|
||||
:status [:text]}
|
||||
{:primary-key [:id]})]
|
||||
(mt/with-temp [:model/Card model {:type :model
|
||||
:table_id @test-table
|
||||
:database_id (mt/id)
|
||||
:dataset_query {:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table @test-table}}}
|
||||
:model/Action action {:type :implicit
|
||||
:name "update"
|
||||
:model_id (:id model)
|
||||
:parameters [{:id "a"
|
||||
:name "Id"
|
||||
:slug "id"}
|
||||
{:id "b"
|
||||
:name "Name"
|
||||
:slug "name"}
|
||||
{:id "c"
|
||||
:name "Status"
|
||||
:slug "status"}]}
|
||||
|
||||
(testing "Initially the table is empty"
|
||||
(is (= [] (table-rows table-id))))
|
||||
|
||||
(testing "POST should insert new rows"
|
||||
(is (= {:created-rows [{:id 1 :name "Pidgey" :song "Car alarms"}
|
||||
{:id 2 :name "Spearow" :song "Hold music"}
|
||||
{:id 3 :name "Farfetch'd" :song "The land of lisp"}]}
|
||||
(mt/user-http-request :crowberto :post 200 url
|
||||
{:rows [{:name "Pidgey" :song "Car alarms"}
|
||||
{:name "Spearow" :song "Hold music"}
|
||||
{:name "Farfetch'd" :song "The land of lisp"}]})))
|
||||
|
||||
(is (= [[1 "Pidgey" "Car alarms"]
|
||||
[2 "Spearow" "Hold music"]
|
||||
[3 "Farfetch'd" "The land of lisp"]]
|
||||
(table-rows table-id))))
|
||||
|
||||
(testing "PUT should update the relevant rows and columns"
|
||||
(is (= {:rows-updated 2}
|
||||
(mt/user-http-request :crowberto :put 200 url
|
||||
{:rows [{:id 1 :song "Join us now and share the software"}
|
||||
{:id 2 :name "Speacolumn"}]})))
|
||||
|
||||
(is (= [[1 "Pidgey" "Join us now and share the software"]
|
||||
[2 "Speacolumn" "Hold music"]
|
||||
[3 "Farfetch'd" "The land of lisp"]]
|
||||
(table-rows table-id))))
|
||||
|
||||
(testing "DELETE should remove the corresponding rows"
|
||||
(is (= {:success true}
|
||||
(mt/user-http-request :crowberto :delete 200 url
|
||||
{:rows [{:id 1}
|
||||
{:id 2}]})))
|
||||
(is (= [[3 "Farfetch'd" "The land of lisp"]]
|
||||
(table-rows table-id)))))
|
||||
|
||||
(finally
|
||||
(try
|
||||
(driver/drop-table! driver (mt/id) table-name)
|
||||
(catch Exception _)))))))))
|
||||
:model/ImplicitAction _ {:action_id (:id action)
|
||||
:kind "row/update"}
|
||||
:model/Dashboard dash {}
|
||||
:model/DashboardCard dashcard {:dashboard_id (:id dash)
|
||||
:card_id (:id model)}]
|
||||
(testing "no access to the model"
|
||||
(is (= 403 (:status (req {:user :rasta
|
||||
:action-id (:id action)
|
||||
:body {:pk {:id 1}
|
||||
:params {:status "approved"}}
|
||||
:query-params {:dashcard-id (:id dashcard)}})))))
|
||||
(testing "row does not exist, action not executed"
|
||||
(is (= 404 (:status (req {:user :crowberto
|
||||
:action-id (:id action)
|
||||
:body {:pk {:id 1}
|
||||
:params {:status "approved"}}
|
||||
:query-params {:dashcard-id (:id dashcard)}})))))
|
||||
(testing "row exists, action executed"
|
||||
(mt/user-http-request :crowberto :post 200 (data-editing.tu/table-url @test-table)
|
||||
{:rows [{:name "Widgets", :status "waiting"}]})
|
||||
(is (= {:status 200
|
||||
:body {:rows-updated 1}}
|
||||
(-> (req {:user :crowberto
|
||||
:action-id (:id action)
|
||||
:body {:pk {:id 1}
|
||||
:params {:status "approved"}}
|
||||
:query-params {:dashcard-id (:id dashcard)}})
|
||||
(select-keys [:status :body])))))))))))))
|
||||
|
||||
@@ -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,407 @@
|
||||
(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.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 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 "*Crowberto Corv has created a row for CATEGORIES*\n*Created row:*\n• ID : 76\n• NAME : New Category"}}]
|
||||
:channel "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(is (=? {:subject "Table CATEGORIES has a new row"
|
||||
:body [{"Crowberto Corv has created a row for CATEGORIES" true
|
||||
"NAME: New Category" true}]}
|
||||
(mt/summarize-multipart-single-email
|
||||
email
|
||||
#"Crowberto Corv has created a row for CATEGORIES"
|
||||
#"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 "*Crowberto Corv has updated a row from CATEGORIES*\n*Update:*\n• ID : 1\n• NAME : Updated Category"}}]
|
||||
:channel "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(is (=? {:subject "Table CATEGORIES has been updated"
|
||||
:body [{"Crowberto Corv has updated a row in CATEGORIES" true
|
||||
"NAME: Updated Category" true}]}
|
||||
(mt/summarize-multipart-single-email
|
||||
email
|
||||
#"Crowberto Corv has updated a row in CATEGORIES"
|
||||
#"NAME: Updated Category"))))
|
||||
: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 "*Crowberto Corv has deleted a row from CATEGORIES*\n*Deleted row:*\n• ID : 1\n• NAME : African"}}]
|
||||
:channel "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(is (=? {:subject "Table CATEGORIES has a row deleted"
|
||||
:body [{"Crowberto Corv has deleted a row from CATEGORIES" true
|
||||
"NAME: African" true}]}
|
||||
(mt/summarize-multipart-single-email
|
||||
email
|
||||
#"Crowberto Corv has deleted a row from CATEGORIES"
|
||||
#"NAME: African"))))
|
||||
:channel/http (fn [[req :as reqs]]
|
||||
(is (= 1 (count reqs)))
|
||||
(is (=? {:body (mt/malli=? :map)} req)))}))
|
||||
|
||||
(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: ID,ADDRESS,EMAIL,PASSWORD,NAME,CITY,LONGITUDE,STATE,SOURCE,BIRTH_DATE,ZIP,LATITUDE,CREATED_AT,")
|
||||
: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: ADDRESS,BIRTH_DATE,CITY,CREATED_AT,EMAIL,ID,LATITUDE,LONGITUDE,NAME,PASSWORD,SOURCE,STATE,ZIP,")
|
||||
: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 "*Crowberto Corv has created a row for CATEGORIES*\n*Created row:*\n• ID : 76\n• NAME : New Category"}}]
|
||||
:channel "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(is (=? {:subject "Table CATEGORIES has a new row"
|
||||
:body [{"Crowberto Corv has created a row for CATEGORIES" true
|
||||
"NAME: New Category" true}]}
|
||||
(mt/summarize-multipart-single-email
|
||||
email
|
||||
#"Crowberto Corv has created a row for CATEGORIES"
|
||||
#"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
|
||||
(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 %)))])}
|
||||
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.created
|
||||
:table_id (mt/id :orders)}
|
||||
:creator_id (mt/user->id :crowberto)})))))
|
||||
|
||||
(deftest example-payload-row-update-test
|
||||
(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 %)))])}
|
||||
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.updated
|
||||
:table_id (mt/id :orders)}
|
||||
:creator_id (mt/user->id :crowberto)})))))
|
||||
|
||||
(deftest example-payload-row-delete-test
|
||||
(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 %)))])}
|
||||
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.deleted
|
||||
:table_id (mt/id :orders)}
|
||||
:creator_id (mt/user->id :crowberto)})))))
|
||||
|
||||
(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-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"}
|
||||
: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 (=? {:message "Value does not match schema"
|
||||
:data {:error (mt/malli=? :map)}}
|
||||
(mt/user-http-request :crowberto :post 500 "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,385 @@
|
||||
(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]
|
||||
(:batch_num (mt/user-http-request user-id :post 200 (str "/ee/data-editing/" (name undo-or-redo))
|
||||
{:scope scope
|
||||
:no-op true})))
|
||||
|
||||
(defn- fast-reload-response [response]
|
||||
(if-let [table-id->diffs (:result response)]
|
||||
(update-vals table-id->diffs #(mapv (fn [diff] (update (vec diff) 0 keyword)) %))
|
||||
response))
|
||||
|
||||
(defn- undo-via-api! [user-id scope]
|
||||
(fast-reload-response
|
||||
(mt/user-http-request user-id :post "/ee/data-editing/undo" {:scope scope})))
|
||||
|
||||
(defn- redo-via-api! [user-id scope]
|
||||
(fast-reload-response
|
||||
(mt/user-http-request user-id :post "/ee/data-editing/redo" {:scope scope})))
|
||||
|
||||
(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 (= {table-id [[:create {: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 (= {table-id [[:update {: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 (= {table-id [[:delete {: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 (= {table-id [[:create {: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 (= {table-id [[:update {: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 (= {table-id [[:delete {: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 (= {table-id [[:create {: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 (= {table-id [[:update {: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 (= {table-id [[:update {: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 (= {table-id [[:delete {: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 (= {table-id [[:create {: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 (= {table-id [[:update {: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 (= {table-id [[:update {: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 (= {table-id [[:delete {: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)
|
||||
test-scope {:shared true}]
|
||||
(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
|
||||
test-scope
|
||||
{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
|
||||
test-scope
|
||||
{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)
|
||||
test-scope
|
||||
{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)
|
||||
test-scope
|
||||
{(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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from "./metabot";
|
||||
export * from "./gdrive";
|
||||
export * from "./db-routing";
|
||||
export * from "./query-validation";
|
||||
export * from "./table-data-edit";
|
||||
export * from "./scim";
|
||||
export * from "./tags";
|
||||
export * from "./upload-management";
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
TableDeleteRowsRequest,
|
||||
TableDeleteRowsResponse,
|
||||
TableInsertRowsRequest,
|
||||
TableInsertRowsResponse,
|
||||
TableUndoRedoRequest,
|
||||
TableUndoRedoResponse,
|
||||
TableUpdateRowsRequest,
|
||||
TableUpdateRowsResponse,
|
||||
} from "metabase-enterprise/data_editing/tables/types";
|
||||
|
||||
import { EnterpriseApi } from "./api";
|
||||
|
||||
export const tableDataEditApi = EnterpriseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
insertTableRows: builder.mutation<
|
||||
TableInsertRowsResponse,
|
||||
TableInsertRowsRequest
|
||||
>({
|
||||
query: ({ tableId, rows, scope }) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/data-editing/table/${tableId}`,
|
||||
body: { rows, scope },
|
||||
}),
|
||||
}),
|
||||
updateTableRows: builder.mutation<
|
||||
TableUpdateRowsResponse,
|
||||
TableUpdateRowsRequest
|
||||
>({
|
||||
query: ({ tableId, rows, scope }) => ({
|
||||
method: "PUT",
|
||||
url: `/api/ee/data-editing/table/${tableId}`,
|
||||
body: { rows, scope },
|
||||
}),
|
||||
}),
|
||||
deleteTableRows: builder.mutation<
|
||||
TableDeleteRowsResponse,
|
||||
TableDeleteRowsRequest
|
||||
>({
|
||||
query: ({ tableId, rows, scope }) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/data-editing/table/${tableId}/delete`,
|
||||
body: { rows, scope },
|
||||
}),
|
||||
}),
|
||||
tableUndo: builder.mutation<TableUndoRedoResponse, TableUndoRedoRequest>({
|
||||
query: ({ tableId, scope, noOp }) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/data-editing/undo`,
|
||||
body: { "table-id": tableId, scope, "no-op": noOp },
|
||||
}),
|
||||
}),
|
||||
tableRedo: builder.mutation<TableUndoRedoResponse, TableUndoRedoRequest>({
|
||||
query: ({ tableId, scope, noOp }) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/data-editing/redo`,
|
||||
body: { "table-id": tableId, scope, "no-op": noOp },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useInsertTableRowsMutation,
|
||||
useUpdateTableRowsMutation,
|
||||
useDeleteTableRowsMutation,
|
||||
useTableUndoMutation,
|
||||
useTableRedoMutation,
|
||||
} = tableDataEditApi;
|
||||
@@ -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,628 @@
|
||||
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 = requestBody?.handlers
|
||||
? requestBody.handlers
|
||||
.map((h) => h.channel_type)
|
||||
.filter((type): type is NotificationChannelType => !!type)
|
||||
: [];
|
||||
|
||||
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
|
||||
? {
|
||||
payload_type: requestBody.payload_type,
|
||||
payload: requestBody.payload,
|
||||
creator_id: user.id,
|
||||
}
|
||||
: 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
|
||||
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}
|
||||
>
|
||||
{isEditMode ? t`Update` : t`Create`}
|
||||
</ButtonWithStatus>
|
||||
</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}
|
||||
confirmButtonPrimary={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,764 @@
|
||||
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 {
|
||||
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,
|
||||
NotificationTriggerEvent,
|
||||
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: "" }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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,
|
||||
conditions,
|
||||
logicalOperator,
|
||||
eventType,
|
||||
);
|
||||
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[2]
|
||||
: 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[3] 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[3] 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,
|
||||
conditions?: ConditionRow[],
|
||||
logicalOperator?: LogicalOperator,
|
||||
eventType?: NotificationTriggerEvent,
|
||||
): ConditionalAlertExpression {
|
||||
const baseExpression: ConditionalAlertExpression = [
|
||||
"=",
|
||||
["context", "table_id"],
|
||||
tableId,
|
||||
];
|
||||
|
||||
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",
|
||||
"row_change",
|
||||
eventType === "event/row.deleted" ? "before" : "after",
|
||||
];
|
||||
|
||||
if (validConditions.length === 1) {
|
||||
const condition = validConditions[0];
|
||||
|
||||
return [
|
||||
"and",
|
||||
baseExpression,
|
||||
[
|
||||
condition.operator as ComparisonOperator,
|
||||
[...payloadPath, condition.columnName],
|
||||
condition.value,
|
||||
] as ConditionalAlertExpression,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
"and",
|
||||
baseExpression,
|
||||
[
|
||||
// @ts-expect-error Write proper typing for Expression
|
||||
logicalOperator,
|
||||
...validConditions.map(
|
||||
(condition) =>
|
||||
[
|
||||
condition.operator as ComparisonOperator,
|
||||
[...payloadPath, condition.columnName],
|
||||
condition.value,
|
||||
] as ConditionalAlertExpression,
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
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[2]);
|
||||
}
|
||||
|
||||
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,56 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
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 {
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.previewStack {
|
||||
background: var(--mb-color-bg-white);
|
||||
width: 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;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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" h="100%" gap="md" className={S.root}>
|
||||
<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 && (
|
||||
<Text c="error">
|
||||
{t`Error during generation of template preview. Check your template.
|
||||
We will provide better error messages soon 🙏.`}
|
||||
{/* TODO: Un-comment this line when BE return human-readable error without stack trace */}
|
||||
{/* {JSON.stringify(error)} */}
|
||||
</Text>
|
||||
)}
|
||||
{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>
|
||||
<Box>
|
||||
{htmlContent ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
|
||||
) : (
|
||||
<Text c="text-medium">{t`(No body content)`}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</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,185 @@
|
||||
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
|
||||
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,305 @@
|
||||
import cx from "classnames";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import { useLocation } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
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 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,
|
||||
DatasetData,
|
||||
VisualizationSettings,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import S from "./EditTableData.module.css";
|
||||
import { EditTableDataGrid } from "./EditTableDataGrid";
|
||||
import { EditTableDataOverlay } from "./EditTableDataOverlay";
|
||||
import { EditingBaseRowModal } from "./modals/EditingBaseRowModal";
|
||||
import { useTableEditingModalControllerWithObjectId } from "./modals/use-table-modal-with-object-id";
|
||||
import { useEditableTableColumnConfigFromVisualizationSettings } from "./use-editable-column-config";
|
||||
import { useTableActions } from "./use-table-actions";
|
||||
import { useTableCRUD } from "./use-table-crud";
|
||||
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?: VisualizationSettings;
|
||||
question: Question;
|
||||
};
|
||||
|
||||
export const EditTableDashcardVisualization = ({
|
||||
title,
|
||||
dashcardId,
|
||||
cardId,
|
||||
tableId,
|
||||
data,
|
||||
className,
|
||||
visualizationSettings,
|
||||
question,
|
||||
}: 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,
|
||||
tableFieldMetadataMap,
|
||||
cellsWithFailedUpdatesMap,
|
||||
|
||||
handleCellValueUpdate,
|
||||
handleRowCreate,
|
||||
handleRowUpdate,
|
||||
handleRowDelete,
|
||||
} = 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 } = useTableActions(
|
||||
visualizationSettings,
|
||||
);
|
||||
|
||||
const { getColumnSortDirection } = useTableSorting({
|
||||
question,
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<Group gap="sm" align="center">
|
||||
<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>
|
||||
)}
|
||||
{hasCreateAction && (
|
||||
<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}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
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,88 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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,209 @@
|
||||
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 { EditingBaseRowModal } from "./modals/EditingBaseRowModal";
|
||||
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 { 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);
|
||||
|
||||
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,
|
||||
});
|
||||
// console.log({ datasetData });
|
||||
|
||||
const stateUpdateStrategy =
|
||||
useTableEditingStateApiUpdateStrategy(fakeTableQuery);
|
||||
|
||||
const editingScope = useMemo(() => {
|
||||
return { "table-id": tableId };
|
||||
}, [tableId]);
|
||||
|
||||
const {
|
||||
isInserting,
|
||||
tableFieldMetadataMap,
|
||||
cellsWithFailedUpdatesMap,
|
||||
|
||||
handleCellValueUpdate,
|
||||
handleRowCreate,
|
||||
handleRowUpdate,
|
||||
handleRowDelete,
|
||||
} = useTableCRUD({
|
||||
tableId,
|
||||
scope: editingScope,
|
||||
datasetData,
|
||||
stateUpdateStrategy,
|
||||
});
|
||||
|
||||
const { undo, redo, isUndoLoading, isRedoLoading, currentActionLabel } =
|
||||
useTableEditingUndoRedo({
|
||||
tableId,
|
||||
scope: editingScope,
|
||||
stateUpdateStrategy,
|
||||
});
|
||||
|
||||
useMount(() => {
|
||||
dispatch(closeNavbar());
|
||||
});
|
||||
|
||||
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
|
||||
table={table}
|
||||
question={fakeTableQuestion}
|
||||
isLoading={isFetching}
|
||||
isUndoLoading={isUndoLoading}
|
||||
isRedoLoading={isRedoLoading}
|
||||
onCreate={openCreateRowModal}
|
||||
onQuestionChange={handleQuestionChange}
|
||||
refetchTableDataQuery={refetch}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
/>
|
||||
)}
|
||||
{isDatabaseTableEditingEnabled(database) ? (
|
||||
<>
|
||||
<Box pos="relative" className={S.gridWrapper}>
|
||||
<EditTableDataOverlay
|
||||
show={shouldDisableTable}
|
||||
message={currentActionLabel ?? ""}
|
||||
/>
|
||||
<EditTableDataGrid
|
||||
data={datasetData}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
cellsWithFailedUpdatesMap={cellsWithFailedUpdatesMap}
|
||||
onCellValueUpdate={handleCellValueUpdate}
|
||||
onRowExpandClick={openEditRowModal}
|
||||
/>
|
||||
</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
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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,212 @@
|
||||
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 { 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,
|
||||
FieldWithMetadata,
|
||||
RowValue,
|
||||
RowValues,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { canEditField } from "../../helpers";
|
||||
import type { RowPkValue, UpdateCellValueHandlerParams } from "../types";
|
||||
|
||||
import S from "./EditTableData.module.css";
|
||||
import { EditingBodyCellWrapper } from "./EditingBodyCell";
|
||||
import type { EditableTableColumnConfig } from "./use-editable-column-config";
|
||||
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>;
|
||||
};
|
||||
|
||||
export const EditTableDataGrid = ({
|
||||
data,
|
||||
fieldMetadataMap,
|
||||
onCellValueUpdate,
|
||||
onRowExpandClick,
|
||||
columnsConfig,
|
||||
getColumnSortDirection,
|
||||
cellsWithFailedUpdatesMap,
|
||||
}: EditTableDataGridProps) => {
|
||||
const { cols, rows } = data;
|
||||
|
||||
const { editingCellId, onCellClickToEdit, onCellEditCancel } =
|
||||
useTableEditing();
|
||||
|
||||
const columnOrder = useMemo(
|
||||
() =>
|
||||
columnsConfig ? columnsConfig.columnOrder : 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 tableProps = useDataGridInstance({
|
||||
data: rows,
|
||||
rowId,
|
||||
columnOrder,
|
||||
columnSizingMap,
|
||||
columnsOptions,
|
||||
columnVisibility,
|
||||
});
|
||||
|
||||
const handleCellClick = 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],
|
||||
);
|
||||
|
||||
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={handleCellClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
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,
|
||||
Title,
|
||||
} from "metabase/ui";
|
||||
import { TableNotificationsTrigger } from "metabase-enterprise/data_editing/alerts/TableNotificationsModals/TableNotificationsTrigger/TableNotificationsTrigger";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import type Table from "metabase-lib/v1/metadata/Table";
|
||||
import type { Table as ApiTable } from "metabase-types/api";
|
||||
|
||||
import { EditTableDataBackButton } from "./EditTableDataBackButton";
|
||||
import { EditTableDataFilterButton } from "./EditTableDataFilterButton";
|
||||
|
||||
interface EditTableDataHeaderProps {
|
||||
table: Table | ApiTable;
|
||||
question: Question;
|
||||
isLoading: boolean;
|
||||
isUndoLoading: boolean;
|
||||
isRedoLoading: boolean;
|
||||
onCreate: () => void;
|
||||
onQuestionChange: (newQuestion: Question) => void;
|
||||
refetchTableDataQuery: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
}
|
||||
|
||||
export const EditTableDataHeader = ({
|
||||
table,
|
||||
question,
|
||||
isLoading,
|
||||
isUndoLoading,
|
||||
isRedoLoading,
|
||||
onCreate,
|
||||
onQuestionChange,
|
||||
refetchTableDataQuery,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}: 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}>
|
||||
<Flex
|
||||
p="lg"
|
||||
data-testid="table-data-view-header"
|
||||
bd="1px solid var(--mb-color-border)"
|
||||
justify="space-between"
|
||||
>
|
||||
<Group gap="sm">
|
||||
<EditTableDataBackButton table={table} />
|
||||
<Title>{t`Editing ${table.display_name}`}</Title>
|
||||
</Group>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</Flex>
|
||||
<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,76 @@
|
||||
import type { CellContext } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type {
|
||||
DatasetColumn,
|
||||
FieldWithMetadata,
|
||||
RowValue,
|
||||
RowValues,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import type { UpdateCellValueHandlerParams } from "../types";
|
||||
|
||||
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()}
|
||||
{...inputProps}
|
||||
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()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{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,64 @@
|
||||
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 type { EditingBodyPrimitiveProps } from "./types";
|
||||
|
||||
export const EditingBodyCellConditional = (
|
||||
props: EditingBodyPrimitiveProps,
|
||||
) => {
|
||||
const { datasetColumn: column, field, inputProps } = props;
|
||||
|
||||
const disabled = !canEditField(field);
|
||||
const placeholder = field?.database_default
|
||||
? `<${field.database_default}>`
|
||||
: field?.database_is_nullable
|
||||
? t`Optional`
|
||||
: t`Required`;
|
||||
|
||||
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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EditingBodyCellBasicInput
|
||||
{...props}
|
||||
inputProps={{ placeholder, disabled, ...inputProps }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { type KeyboardEvent, useCallback, useState } from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_DATE_STYLE,
|
||||
DEFAULT_TIME_STYLE,
|
||||
} from "metabase/lib/formatting/datetime-utils";
|
||||
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 initialDateValue = initialValue
|
||||
? new Date(initialValue?.toString())
|
||||
: null;
|
||||
|
||||
const valueFormat = isDateTime ? DEFAULT_DATETIME_STYLE : DEFAULT_DATE_STYLE;
|
||||
|
||||
const [value, setValue] = useState<Date | null>(initialDateValue);
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: Date | null) => {
|
||||
setValue(value);
|
||||
onChangeValue?.(value ? value.toISOString() : null);
|
||||
},
|
||||
[onChangeValue],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
}, [setFocused]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
onSubmit(value ? value.toISOString() : null);
|
||||
}, [value, onSubmit]);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onCancel();
|
||||
} else if (event.key === "Enter") {
|
||||
onSubmit(value ? value.toISOString() : null);
|
||||
}
|
||||
},
|
||||
[value, onCancel, onSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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,5 @@
|
||||
export { EditingBodyCellBasicInput } from "./EditingBodyCellBasicInput";
|
||||
export { EditingBodyCellCategorySelect } from "./EditingBodyCellCategorySelect";
|
||||
export { EditingBodyCellFKSelect } from "./EditingBodyCellFKSelect";
|
||||
export { EditingBodyCellDatetime } from "./EditingBodyCellDatetime";
|
||||
export { EditingBodyCellConditional } from "./EditingBodyCellConditional";
|
||||
@@ -0,0 +1,30 @@
|
||||
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 {
|
||||
autoFocus?: boolean;
|
||||
datasetColumn: DatasetColumn;
|
||||
field?: FieldWithMetadata;
|
||||
initialValue: RowValue;
|
||||
inputProps?: {
|
||||
size?: MantineSize;
|
||||
variant?: TextInputProps["variant"];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
error?: TextInputProps["error"];
|
||||
};
|
||||
classNames?: { [key in EditingBodyCellConditionalStylesNames]?: string };
|
||||
onChangeValue?: (value: RowValue) => unknown;
|
||||
onSubmit: (value: RowValue) => unknown;
|
||||
onCancel: () => unknown;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Button, Group, Modal, Stack, Text } 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}>
|
||||
<Stack>
|
||||
<Text>{t`This action can not be undone.`}</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="subtle" onClick={onCancel}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="danger"
|
||||
onClick={onConfirm}
|
||||
>{t`Delete record`}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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: rem(32) auto 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,309 @@
|
||||
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,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import type { UpdatedRowHandlerParams } from "../../types";
|
||||
import { EditingBodyCellConditional } from "../inputs";
|
||||
import type { EditableTableColumnConfig } from "../use-editable-column-config";
|
||||
|
||||
import { DeleteRowConfirmationModal } from "./DeleteRowConfirmationModal";
|
||||
import S from "./EditingBaseRowModal.module.css";
|
||||
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,
|
||||
errors,
|
||||
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 orderedDatasetColumns = useMemo(() => {
|
||||
if (!columnsConfig) {
|
||||
return datasetColumns;
|
||||
}
|
||||
|
||||
return columnsConfig.columnOrder
|
||||
.map((name) => datasetColumns.find((it) => it.name === name))
|
||||
.filter((it): it is DatasetColumn => it !== undefined);
|
||||
}, [columnsConfig, datasetColumns]);
|
||||
|
||||
// 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 })}
|
||||
>
|
||||
{orderedDatasetColumns.map((column) => {
|
||||
const field = fieldMetadataMap?.[column.name];
|
||||
|
||||
return (
|
||||
<Fragment key={column.id}>
|
||||
<Icon
|
||||
className={S.modalBodyColumn}
|
||||
name={
|
||||
column.semantic_type
|
||||
? FIELD_SEMANTIC_TYPES_MAP[column.semantic_type].icon
|
||||
: "string"
|
||||
}
|
||||
/>
|
||||
<Text className={S.modalBodyColumn}>
|
||||
{column.display_name}
|
||||
</Text>
|
||||
<ModalEditingInput
|
||||
isEditingMode={isEditingMode}
|
||||
initialValue={currentRowDataMap[column.name] ?? null}
|
||||
datasetColumn={column}
|
||||
field={field}
|
||||
onSubmitValue={handleValueEdit}
|
||||
onChangeValue={setFieldValue}
|
||||
error={!isEditingMode && !!errors[column.name]}
|
||||
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;
|
||||
error?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
function ModalEditingInput({
|
||||
isEditingMode,
|
||||
initialValue,
|
||||
datasetColumn,
|
||||
field,
|
||||
onSubmitValue,
|
||||
onChangeValue,
|
||||
error,
|
||||
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={{ error, disabled }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export enum TableEditingModalAction {
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
}
|
||||
|
||||
export type TableEditingModalState = {
|
||||
action: TableEditingModalAction | null;
|
||||
rowIndex?: number;
|
||||
};
|
||||
|
||||
export type TableEditingModalController = {
|
||||
state: TableEditingModalState;
|
||||
openCreateRowModal: () => void;
|
||||
openEditRowModal: (rowIndex: number) => void;
|
||||
closeModal: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple controller to manage the state of the table editing modal.
|
||||
* See `useTableEditingModalControllerWithObjectId` for a more complex
|
||||
* implementation that relies on the `currentObjectId` prop.
|
||||
*/
|
||||
export function useTableEditingModalController(): TableEditingModalController {
|
||||
const [modalState, setModalState] = useState<TableEditingModalState>({
|
||||
action: null,
|
||||
rowIndex: undefined, // TODO: replace with row: Record<string, RowValue>
|
||||
});
|
||||
|
||||
const handleOpenCreateRowModal = useCallback(() => {
|
||||
setModalState({
|
||||
action: TableEditingModalAction.Create,
|
||||
rowIndex: undefined,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOpenEditRowModal = useCallback((rowIndex: number) => {
|
||||
setModalState({ action: TableEditingModalAction.Edit, rowIndex });
|
||||
}, []);
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setModalState((state) => ({
|
||||
...state,
|
||||
action: null,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state: modalState,
|
||||
openCreateRowModal: handleOpenCreateRowModal,
|
||||
openEditRowModal: handleOpenEditRowModal,
|
||||
closeModal: handleCloseModal,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function getTableEditPathname(
|
||||
databaseId: number,
|
||||
tableId: number,
|
||||
objectId?: string,
|
||||
): string {
|
||||
return `/browse/databases/${databaseId}/tables/${tableId}/edit${
|
||||
objectId ? `/${objectId}` : ""
|
||||
}`;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type {
|
||||
DashCardVisualizationSettings,
|
||||
VisualizationSettings,
|
||||
} from "metabase-types/api";
|
||||
|
||||
export type EditableTableColumnConfig = {
|
||||
columnOrder: string[];
|
||||
columnVisibilityMap: Record<string, boolean>;
|
||||
isColumnReadonly: (columnName: string) => boolean;
|
||||
isColumnHidden: (columnName: string) => boolean;
|
||||
};
|
||||
|
||||
export function useEditableTableColumnConfigFromVisualizationSettings(
|
||||
visualizationSettings?: VisualizationSettings & DashCardVisualizationSettings,
|
||||
): EditableTableColumnConfig | undefined {
|
||||
return useMemo(() => {
|
||||
if (!visualizationSettings) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const columnSettings = visualizationSettings["table.columns"] ?? [];
|
||||
const editableColumns =
|
||||
visualizationSettings["table.editableColumns"] ?? [];
|
||||
|
||||
if (columnSettings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isAllColumnsEditable = editableColumns.length === 0;
|
||||
const editableColumnSet = new Set(editableColumns);
|
||||
|
||||
const columnOrder: string[] = [];
|
||||
const columnVisibilityMap: Record<string, boolean> = {};
|
||||
const readonlyColumnSet = new Set<string>();
|
||||
const hiddenColumnSet = new Set<string>();
|
||||
|
||||
for (const column of columnSettings) {
|
||||
columnOrder.push(column.name);
|
||||
columnVisibilityMap[column.name] = column.enabled;
|
||||
|
||||
if (!column.enabled) {
|
||||
hiddenColumnSet.add(column.name);
|
||||
}
|
||||
|
||||
if (!isAllColumnsEditable && !editableColumnSet.has(column.name)) {
|
||||
readonlyColumnSet.add(column.name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
columnOrder,
|
||||
columnVisibilityMap,
|
||||
isColumnHidden: (columnName: string) => hiddenColumnSet.has(columnName),
|
||||
isColumnReadonly: (columnName: string) =>
|
||||
readonlyColumnSet.has(columnName),
|
||||
};
|
||||
}, [visualizationSettings]);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Location } from "history";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { loadMetadataForTable } from "metabase/questions/actions";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import {
|
||||
deserializeTableFilter,
|
||||
serializeTableFilter,
|
||||
} from "metabase-enterprise/data_editing/tables/edit/utils";
|
||||
import * as Lib from "metabase-lib";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import type { StructuredDatasetQuery } from "metabase-types/api";
|
||||
|
||||
type StandaloneTableQueryProps = {
|
||||
tableId: number;
|
||||
databaseId: number;
|
||||
location: Location<{ filter?: string }>;
|
||||
};
|
||||
|
||||
export const useStandaloneTableQuery = ({
|
||||
tableId,
|
||||
databaseId,
|
||||
location,
|
||||
}: StandaloneTableQueryProps) => {
|
||||
const filterQueryParam = useMemo(() => {
|
||||
return location.query?.filter
|
||||
? deserializeTableFilter(location.query.filter)
|
||||
: null;
|
||||
}, [location.query.filter]);
|
||||
|
||||
const metadata = useSelector(getMetadata);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const table = metadata.table(tableId);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadMetadataForTable(tableId));
|
||||
}, [dispatch, tableId]);
|
||||
|
||||
const fakeTableQuestion = useMemo(() => {
|
||||
if (table) {
|
||||
let question = Question.create({ databaseId, tableId, metadata });
|
||||
|
||||
if (filterQueryParam) {
|
||||
const legacyQuery = Lib.toLegacyQuery(
|
||||
question.query(),
|
||||
) as StructuredDatasetQuery;
|
||||
legacyQuery.query.filter = filterQueryParam;
|
||||
question = question.setDatasetQuery(legacyQuery);
|
||||
}
|
||||
|
||||
return question;
|
||||
}
|
||||
}, [databaseId, filterQueryParam, metadata, tableId, table]);
|
||||
|
||||
const handleQuestionChange = useCallback(
|
||||
(newQuestion: Question) => {
|
||||
const legacyQuery = Lib.toLegacyQuery(
|
||||
newQuestion.query(),
|
||||
) as StructuredDatasetQuery;
|
||||
const newFilterMbql = legacyQuery.query.filter;
|
||||
|
||||
if (newFilterMbql) {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append("filter", serializeTableFilter(newFilterMbql));
|
||||
// don't set filter param if it is empty
|
||||
dispatch(
|
||||
push(`${window.location.pathname}?${searchParams.toString()}`),
|
||||
);
|
||||
} else {
|
||||
dispatch(push(window.location.pathname));
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const fakeTableQuery = useMemo(() => {
|
||||
if (fakeTableQuestion) {
|
||||
return Lib.toLegacyQuery(fakeTableQuestion.query());
|
||||
}
|
||||
}, [fakeTableQuestion]);
|
||||
|
||||
return {
|
||||
fakeTableQuestion,
|
||||
fakeTableQuery,
|
||||
table,
|
||||
handleQuestionChange,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {
|
||||
DashCardVisualizationSettings,
|
||||
VisualizationSettings,
|
||||
} from "metabase-types/api";
|
||||
|
||||
export const useTableActions = (
|
||||
visualizationSettings?: VisualizationSettings & DashCardVisualizationSettings,
|
||||
) => {
|
||||
const hasCreateAction =
|
||||
visualizationSettings?.["editableTable.enabledActions"]?.find(
|
||||
({ id }) => id === "row/create",
|
||||
)?.enabled || false;
|
||||
|
||||
const hasDeleteAction =
|
||||
visualizationSettings?.["editableTable.enabledActions"]?.find(
|
||||
({ id }) => id === "row/delete",
|
||||
)?.enabled || false;
|
||||
|
||||
return {
|
||||
hasCreateAction,
|
||||
hasDeleteAction,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
|
||||
import type { CellUniqKey, RowPkValue } from "../types";
|
||||
|
||||
import { ErrorUpdateToast } from "./ErrorUpdateToast";
|
||||
import type { OptimisticUpdatePatchResult } from "./use-table-state-update-strategy";
|
||||
import { getCellUniqKey, getUpdateApiErrorMessage } from "./utils";
|
||||
|
||||
export const useTableCrudOptimisticUpdate = () => {
|
||||
const [cellsWithFailedUpdatesMap, setCellsWithFailedUpdatesMap] = useState<
|
||||
Record<CellUniqKey, number>
|
||||
>({});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const remappedCellsWithFailedUpdatesMap = useMemo(
|
||||
() =>
|
||||
Object.keys(cellsWithFailedUpdatesMap).reduce(
|
||||
(result, key) => {
|
||||
result[key] = true;
|
||||
return result;
|
||||
},
|
||||
{} as Record<CellUniqKey, true>,
|
||||
),
|
||||
[cellsWithFailedUpdatesMap],
|
||||
);
|
||||
|
||||
const handleCellValueUpdateError = useCallback(
|
||||
(
|
||||
error: unknown,
|
||||
cellUpdateContext: {
|
||||
rowPkValue: RowPkValue;
|
||||
columnName: string;
|
||||
patchResult: OptimisticUpdatePatchResult | undefined;
|
||||
},
|
||||
) => {
|
||||
const { columnName, rowPkValue, patchResult } = cellUpdateContext;
|
||||
|
||||
const cellUniqKey = getCellUniqKey(rowPkValue, columnName);
|
||||
|
||||
patchResult?.revert();
|
||||
|
||||
dispatch(
|
||||
addUndo({
|
||||
toastColor: "bg-black",
|
||||
icon: null,
|
||||
renderChildren: () => <ErrorUpdateToast error={error} />,
|
||||
timeout: null, // removes automatic toast hide
|
||||
undo: false,
|
||||
onDismiss: () => {
|
||||
setCellsWithFailedUpdatesMap((prevState) => {
|
||||
const currentFailedUpdatesCount = prevState[cellUniqKey];
|
||||
const newCount = currentFailedUpdatesCount - 1;
|
||||
const newMap = { ...prevState };
|
||||
|
||||
if (newCount === 0) {
|
||||
delete newMap[cellUniqKey];
|
||||
} else {
|
||||
newMap[cellUniqKey] = newCount;
|
||||
}
|
||||
|
||||
return newMap;
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const currentFailedUpdatesCount =
|
||||
cellsWithFailedUpdatesMap[cellUniqKey] || 0;
|
||||
|
||||
setCellsWithFailedUpdatesMap({
|
||||
...cellsWithFailedUpdatesMap,
|
||||
[cellUniqKey]: currentFailedUpdatesCount + 1,
|
||||
});
|
||||
},
|
||||
[cellsWithFailedUpdatesMap, dispatch],
|
||||
);
|
||||
|
||||
const handleGenericUpdateError = useCallback(
|
||||
(error: unknown) => {
|
||||
if (error) {
|
||||
dispatch(
|
||||
addUndo({
|
||||
icon: "warning",
|
||||
toastColor: "error",
|
||||
message: getUpdateApiErrorMessage(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return {
|
||||
cellsWithFailedUpdatesMap: remappedCellsWithFailedUpdatesMap,
|
||||
handleCellValueUpdateError,
|
||||
handleGenericUpdateError,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { useGetTableQueryMetadataQuery } from "metabase/api";
|
||||
import {
|
||||
useDeleteTableRowsMutation,
|
||||
useInsertTableRowsMutation,
|
||||
useUpdateTableRowsMutation,
|
||||
} from "metabase-enterprise/api";
|
||||
import { isPK } from "metabase-lib/v1/types/utils/isa";
|
||||
import type {
|
||||
ConcreteTableId,
|
||||
DatasetData,
|
||||
FieldWithMetadata,
|
||||
RowValue,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import type {
|
||||
RowPkValue,
|
||||
TableEditingScope,
|
||||
UpdateCellValueHandlerParams,
|
||||
UpdatedRowHandlerParams,
|
||||
} from "../types";
|
||||
|
||||
import { useTableCrudOptimisticUpdate } from "./use-table-crud-optimistic-update";
|
||||
import type { TableEditingStateUpdateStrategy } from "./use-table-state-update-strategy";
|
||||
import { getRowPkKeyValue } from "./utils";
|
||||
|
||||
export const useTableCRUD = ({
|
||||
tableId,
|
||||
scope,
|
||||
datasetData,
|
||||
stateUpdateStrategy,
|
||||
}: {
|
||||
tableId: ConcreteTableId;
|
||||
scope?: TableEditingScope;
|
||||
datasetData: DatasetData | null | undefined;
|
||||
stateUpdateStrategy: TableEditingStateUpdateStrategy;
|
||||
}) => {
|
||||
const {
|
||||
cellsWithFailedUpdatesMap,
|
||||
handleCellValueUpdateError,
|
||||
handleGenericUpdateError,
|
||||
} = useTableCrudOptimisticUpdate();
|
||||
|
||||
const [deleteTableRows] = useDeleteTableRowsMutation();
|
||||
const [updateTableRows] = useUpdateTableRowsMutation();
|
||||
const [insertTableRows, { isLoading: isInserting }] =
|
||||
useInsertTableRowsMutation();
|
||||
|
||||
const { data: tableMetadata } = useGetTableQueryMetadataQuery({
|
||||
id: tableId,
|
||||
});
|
||||
|
||||
const tableFieldMetadataMap = useMemo(() => {
|
||||
return (
|
||||
tableMetadata?.fields?.reduce(
|
||||
(acc, item) => ({
|
||||
...acc,
|
||||
[item.name]: item,
|
||||
}),
|
||||
{} as Record<FieldWithMetadata["name"], FieldWithMetadata>,
|
||||
) || {}
|
||||
);
|
||||
}, [tableMetadata]);
|
||||
|
||||
const handleCellValueUpdate = useCallback(
|
||||
async ({
|
||||
updatedData,
|
||||
rowIndex,
|
||||
columnName,
|
||||
}: UpdateCellValueHandlerParams): Promise<boolean> => {
|
||||
// mostly the same as "handleRowUpdate", but has optimistic update and special error handling
|
||||
if (!datasetData) {
|
||||
console.warn(
|
||||
"Failed to update table data - no data is loaded for a table",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const pkRecord = getRowPkKeyValue(datasetData, rowIndex);
|
||||
const updatedRowWithPk = {
|
||||
...updatedData,
|
||||
...pkRecord,
|
||||
};
|
||||
|
||||
const rowPkValue = Object.values(pkRecord)[0] as RowPkValue;
|
||||
|
||||
const patchResult = stateUpdateStrategy.onRowsUpdated([updatedRowWithPk]);
|
||||
|
||||
try {
|
||||
const response = await updateTableRows({
|
||||
tableId: tableId,
|
||||
rows: [updatedRowWithPk],
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
handleCellValueUpdateError(response.error, {
|
||||
columnName,
|
||||
rowPkValue,
|
||||
patchResult: patchResult || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (response.data?.updated) {
|
||||
stateUpdateStrategy.onRowsUpdated(response.data.updated);
|
||||
}
|
||||
|
||||
return !response.error;
|
||||
} catch (e) {
|
||||
handleCellValueUpdateError(e, {
|
||||
columnName,
|
||||
rowPkValue,
|
||||
patchResult: patchResult || undefined,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[
|
||||
datasetData,
|
||||
handleCellValueUpdateError,
|
||||
stateUpdateStrategy,
|
||||
tableId,
|
||||
updateTableRows,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRowUpdate = useCallback(
|
||||
async ({ updatedData, rowIndex }: UpdatedRowHandlerParams) => {
|
||||
if (!datasetData) {
|
||||
console.warn(
|
||||
"Failed to update table data - no data is loaded for a table",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const pkRecord = getRowPkKeyValue(datasetData, rowIndex);
|
||||
const updatedRowWithPk = {
|
||||
...updatedData,
|
||||
...pkRecord,
|
||||
};
|
||||
|
||||
const response = await updateTableRows({
|
||||
tableId: tableId,
|
||||
rows: [updatedRowWithPk],
|
||||
scope,
|
||||
});
|
||||
|
||||
if (!response.error && response.data) {
|
||||
stateUpdateStrategy.onRowsUpdated(response.data.updated);
|
||||
} else {
|
||||
handleGenericUpdateError(response.error);
|
||||
}
|
||||
|
||||
return !response.error;
|
||||
},
|
||||
[
|
||||
datasetData,
|
||||
updateTableRows,
|
||||
tableId,
|
||||
scope,
|
||||
handleGenericUpdateError,
|
||||
stateUpdateStrategy,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRowCreate = useCallback(
|
||||
async (data: Record<string, RowValue>): Promise<boolean> => {
|
||||
const response = await insertTableRows({
|
||||
tableId: tableId,
|
||||
rows: [data],
|
||||
scope,
|
||||
});
|
||||
|
||||
if (!response.error && response.data) {
|
||||
stateUpdateStrategy.onRowsCreated(response.data["created-rows"]);
|
||||
} else {
|
||||
handleGenericUpdateError(response.error);
|
||||
}
|
||||
|
||||
return !response.error;
|
||||
},
|
||||
[
|
||||
insertTableRows,
|
||||
tableId,
|
||||
handleGenericUpdateError,
|
||||
scope,
|
||||
stateUpdateStrategy,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRowDelete = useCallback(
|
||||
async (rowIndex: number) => {
|
||||
if (!datasetData) {
|
||||
console.warn(
|
||||
"Failed to update table data - no data is loaded for a table",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const columns = datasetData.cols;
|
||||
const rowData = datasetData.rows[rowIndex];
|
||||
|
||||
const pkColumnIndex = columns.findIndex(isPK);
|
||||
const pkColumn = columns[pkColumnIndex];
|
||||
const rowPkValue = rowData[pkColumnIndex];
|
||||
|
||||
const rows = [{ [pkColumn.name]: rowPkValue }];
|
||||
const response = await deleteTableRows({
|
||||
rows,
|
||||
tableId: tableId,
|
||||
scope,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
stateUpdateStrategy.onRowsDeleted(rows);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
handleGenericUpdateError(response.error);
|
||||
}
|
||||
|
||||
return !response.error;
|
||||
},
|
||||
[
|
||||
datasetData,
|
||||
deleteTableRows,
|
||||
tableId,
|
||||
scope,
|
||||
stateUpdateStrategy,
|
||||
handleGenericUpdateError,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
isInserting,
|
||||
tableFieldMetadataMap,
|
||||
cellsWithFailedUpdatesMap,
|
||||
|
||||
handleCellValueUpdate,
|
||||
handleRowCreate,
|
||||
handleRowUpdate,
|
||||
handleRowDelete,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { DataGridCellId } from "metabase/data-grid";
|
||||
|
||||
export const useTableEditing = () => {
|
||||
const [editingCellId, setEditingCellId] = useState<DataGridCellId | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const onCellClickToEdit = useCallback((cellId: DataGridCellId) => {
|
||||
setEditingCellId(cellId);
|
||||
}, []);
|
||||
|
||||
const onCellEditCancel = useCallback(() => {
|
||||
setEditingCellId(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
onCellClickToEdit,
|
||||
onCellEditCancel,
|
||||
editingCellId,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as Lib from "metabase-lib";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import type Field from "metabase-lib/v1/metadata/Field";
|
||||
import type { DatasetColumn } from "metabase-types/api";
|
||||
|
||||
export const useTableSorting = ({ question }: { question: Question }) => {
|
||||
const getColumnSortDirection = (columnOrField: DatasetColumn | Field) => {
|
||||
if (!question || !columnOrField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = question.query();
|
||||
const stageIndex = -1;
|
||||
const column = Lib.findMatchingColumn(
|
||||
query,
|
||||
stageIndex,
|
||||
Lib.fromLegacyColumn(query, stageIndex, columnOrField),
|
||||
Lib.orderableColumns(query, stageIndex),
|
||||
);
|
||||
|
||||
if (column != null) {
|
||||
const columnInfo = Lib.displayInfo(query, stageIndex, column);
|
||||
if (columnInfo.orderByPosition != null) {
|
||||
const orderBys = Lib.orderBys(query, stageIndex);
|
||||
const orderBy = orderBys[columnInfo.orderByPosition];
|
||||
const orderByInfo = Lib.displayInfo(query, stageIndex, orderBy);
|
||||
return orderByInfo.direction;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { getColumnSortDirection };
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ThunkDispatch, UnknownAction } from "@reduxjs/toolkit";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { datasetApi } from "metabase/api";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { checkNotNull } from "metabase/lib/types";
|
||||
import { isPK } from "metabase-lib/v1/types/utils/isa";
|
||||
import type { DatasetQuery, RowValue } from "metabase-types/api";
|
||||
|
||||
import type { TableEditingStateUpdateStrategy } from "./use-table-state-update-strategy";
|
||||
import {
|
||||
createPrimaryKeyToUpdatedRowObjectMap,
|
||||
mapDataEditingRowObjectsToRowValues,
|
||||
} from "./use-table-state-update-strategy";
|
||||
|
||||
export function useTableEditingStateApiUpdateStrategy(
|
||||
nullableAdhocQuery: DatasetQuery | undefined,
|
||||
): TableEditingStateUpdateStrategy {
|
||||
// "metabase-api" state is not typed, so we need to use the `any` type
|
||||
const dispatch = useDispatch() as ThunkDispatch<any, any, UnknownAction>;
|
||||
|
||||
const onRowsCreated = useCallback(
|
||||
(rows: Record<string, RowValue>[]) => {
|
||||
const adhocQuery = checkNotNull(nullableAdhocQuery);
|
||||
|
||||
dispatch(
|
||||
datasetApi.util.updateQueryData(
|
||||
"getAdhocQuery",
|
||||
adhocQuery,
|
||||
({ data }) => {
|
||||
data.rows.push(
|
||||
...mapDataEditingRowObjectsToRowValues(rows, data.cols),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
[dispatch, nullableAdhocQuery],
|
||||
);
|
||||
|
||||
const onRowsUpdated = useCallback(
|
||||
(rows: Record<string, RowValue>[]) => {
|
||||
const adhocQuery = checkNotNull(nullableAdhocQuery);
|
||||
|
||||
const patchResult = dispatch(
|
||||
datasetApi.util.updateQueryData(
|
||||
"getAdhocQuery",
|
||||
adhocQuery,
|
||||
({ data }) => {
|
||||
const pkColumnIndex = data.cols.findIndex(isPK);
|
||||
const pkColumnName = data.cols[pkColumnIndex].name;
|
||||
const primaryKeyToUpdatedRowObjectMap =
|
||||
createPrimaryKeyToUpdatedRowObjectMap(pkColumnName, rows);
|
||||
|
||||
for (const rowArray of data.rows) {
|
||||
const updatedRowObject = primaryKeyToUpdatedRowObjectMap.get(
|
||||
rowArray[pkColumnIndex],
|
||||
);
|
||||
|
||||
if (updatedRowObject) {
|
||||
for (let i = 0; i < data.cols.length; i++) {
|
||||
const columnName = data.cols[i].name;
|
||||
if (columnName in updatedRowObject) {
|
||||
rowArray[i] = updatedRowObject[columnName];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
revert: patchResult.undo,
|
||||
};
|
||||
},
|
||||
[dispatch, nullableAdhocQuery],
|
||||
);
|
||||
|
||||
const onRowsDeleted = useCallback(
|
||||
(rows: Record<string, RowValue>[]) => {
|
||||
const adhocQuery = checkNotNull(nullableAdhocQuery);
|
||||
|
||||
dispatch(
|
||||
datasetApi.util.updateQueryData(
|
||||
"getAdhocQuery",
|
||||
adhocQuery,
|
||||
({ data }) => {
|
||||
const pkColumnIndex = data.cols.findIndex(isPK);
|
||||
const pkColumnName = data.cols[pkColumnIndex].name;
|
||||
const deletedPKs = new Set(rows.map((row) => row[pkColumnName]));
|
||||
|
||||
data.rows = data.rows.filter(
|
||||
(row) => !deletedPKs.has(row[pkColumnIndex]),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
[dispatch, nullableAdhocQuery],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onRowsCreated,
|
||||
onRowsUpdated,
|
||||
onRowsDeleted,
|
||||
}),
|
||||
[onRowsCreated, onRowsUpdated, onRowsDeleted],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { updateCardData } from "metabase/dashboard/actions/data-fetching";
|
||||
import { getDashcardData } from "metabase/dashboard/selectors";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { isPK } from "metabase-lib/v1/types/utils/isa";
|
||||
import type { RowValue } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
type TableEditingStateUpdateStrategy,
|
||||
createPrimaryKeyToUpdatedRowObjectMap,
|
||||
mapDataEditingRowObjectsToRowValues,
|
||||
} from "./use-table-state-update-strategy";
|
||||
|
||||
export function useTableEditingStateDashcardUpdateStrategy(
|
||||
dashcardId: number,
|
||||
cardId: number,
|
||||
): TableEditingStateUpdateStrategy {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const cardData = useSelector(
|
||||
(state) => getDashcardData(state, dashcardId)[cardId],
|
||||
);
|
||||
|
||||
const onRowsCreated = useCallback(
|
||||
(rows: Record<string, RowValue>[]) => {
|
||||
if (!cardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
updateCardData(cardId, dashcardId, {
|
||||
...cardData,
|
||||
data: {
|
||||
...cardData.data,
|
||||
rows: [
|
||||
...cardData.data.rows,
|
||||
...mapDataEditingRowObjectsToRowValues(rows, cardData.data.cols),
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
[cardData, dashcardId, cardId, dispatch],
|
||||
);
|
||||
|
||||
const onRowsUpdated = useCallback(
|
||||
(rows: Record<string, RowValue>[]) => {
|
||||
if (!cardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pkColumnIndex = cardData.data.cols.findIndex(isPK);
|
||||
const pkColumnName = cardData.data.cols[pkColumnIndex].name;
|
||||
const primaryKeyToUpdatedRowObjectMap =
|
||||
createPrimaryKeyToUpdatedRowObjectMap(pkColumnName, rows);
|
||||
|
||||
const originalRowsPkMap: Map<RowValue, RowValue[]> = new Map();
|
||||
|
||||
dispatch(
|
||||
updateCardData(cardId, dashcardId, {
|
||||
...cardData,
|
||||
data: {
|
||||
...cardData.data,
|
||||
rows: cardData.data.rows.map((row) => {
|
||||
const rowPkValue = row[pkColumnIndex];
|
||||
const updatedRowObject =
|
||||
primaryKeyToUpdatedRowObjectMap.get(rowPkValue);
|
||||
|
||||
if (updatedRowObject) {
|
||||
originalRowsPkMap.set(rowPkValue, row);
|
||||
|
||||
const updatedRow = row.map((value, index) => {
|
||||
const columnName = cardData.data.cols[index].name;
|
||||
|
||||
if (columnName in updatedRowObject) {
|
||||
return updatedRowObject[columnName];
|
||||
}
|
||||
|
||||
return value;
|
||||
});
|
||||
|
||||
primaryKeyToUpdatedRowObjectMap.delete(rowPkValue);
|
||||
|
||||
return updatedRow;
|
||||
}
|
||||
|
||||
return row;
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
revert: () =>
|
||||
dispatch(
|
||||
updateCardData(cardId, dashcardId, {
|
||||
...cardData,
|
||||
data: {
|
||||
...cardData.data,
|
||||
rows: cardData.data.rows.map((row) => {
|
||||
const rowPkValue = row[pkColumnIndex];
|
||||
const originalRowObject = originalRowsPkMap.get(rowPkValue);
|
||||
return originalRowObject || row;
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
};
|
||||
},
|
||||
[cardData, dashcardId, cardId, dispatch],
|
||||
);
|
||||
|
||||
const onRowsDeleted = useCallback(
|
||||
(rows: Record<string, RowValue>[]) => {
|
||||
if (!cardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pkColumnIndex = cardData.data.cols.findIndex(isPK);
|
||||
const pkColumnName = cardData.data.cols[pkColumnIndex].name;
|
||||
const deletedPKs = new Set(rows.map((row) => row[pkColumnName]));
|
||||
|
||||
dispatch(
|
||||
updateCardData(cardId, dashcardId, {
|
||||
...cardData,
|
||||
data: {
|
||||
...cardData.data,
|
||||
rows: cardData.data.rows.filter(
|
||||
(row) => !deletedPKs.has(row[pkColumnIndex]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
[cardData, dashcardId, cardId, dispatch],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onRowsCreated,
|
||||
onRowsUpdated,
|
||||
onRowsDeleted,
|
||||
}),
|
||||
[onRowsCreated, onRowsUpdated, onRowsDeleted],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { DatasetColumn, RowValue } from "metabase-types/api";
|
||||
|
||||
type DataEditingRow = Record<string, RowValue>;
|
||||
|
||||
export type OptimisticUpdatePatchResult = {
|
||||
revert: () => void;
|
||||
};
|
||||
|
||||
export interface TableEditingStateUpdateStrategy {
|
||||
onRowsCreated: (rows: DataEditingRow[]) => void;
|
||||
onRowsUpdated: (rows: DataEditingRow[]) => OptimisticUpdatePatchResult | void;
|
||||
onRowsDeleted: (rows: DataEditingRow[]) => void;
|
||||
}
|
||||
|
||||
export function mapDataEditingRowObjectsToRowValues(
|
||||
rows: Record<string, RowValue>[],
|
||||
columns: DatasetColumn[],
|
||||
) {
|
||||
return rows.map((row) => columns.map((column) => row[column.name]));
|
||||
}
|
||||
|
||||
export function createPrimaryKeyToUpdatedRowObjectMap(
|
||||
pkColumnName: string,
|
||||
rows: Record<string, RowValue>[],
|
||||
) {
|
||||
return new Map(rows.map((row) => [row[pkColumnName], row]));
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
import {
|
||||
useTableRedoMutation,
|
||||
useTableUndoMutation,
|
||||
} from "metabase-enterprise/api/table-data-edit";
|
||||
import type { ConcreteTableId, RowValue } from "metabase-types/api";
|
||||
|
||||
import type { TableEditingScope } from "../types";
|
||||
|
||||
import type { TableEditingStateUpdateStrategy } from "./use-table-state-update-strategy";
|
||||
|
||||
type UseTableEditingUndoRedoProps = {
|
||||
tableId: ConcreteTableId;
|
||||
scope?: TableEditingScope;
|
||||
stateUpdateStrategy: TableEditingStateUpdateStrategy;
|
||||
};
|
||||
|
||||
export function useTableEditingUndoRedo({
|
||||
tableId,
|
||||
scope,
|
||||
stateUpdateStrategy,
|
||||
}: UseTableEditingUndoRedoProps) {
|
||||
const [undoMutation, { isLoading: isUndoLoading }] = useTableUndoMutation();
|
||||
const [redoMutation, { isLoading: isRedoLoading }] = useTableRedoMutation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleResponse = useCallback(
|
||||
(
|
||||
operationName: "undo" | "redo",
|
||||
response:
|
||||
| Awaited<ReturnType<typeof undoMutation>>
|
||||
| Awaited<ReturnType<typeof redoMutation>>,
|
||||
) => {
|
||||
if (response.error) {
|
||||
dispatch(
|
||||
addUndo({
|
||||
icon: "warning",
|
||||
toastColor: "error",
|
||||
message:
|
||||
operationName === "undo"
|
||||
? t`Unable to perform undo operation`
|
||||
: t`Unable to perform redo operation`,
|
||||
}),
|
||||
);
|
||||
} else if (!response.data) {
|
||||
dispatch(
|
||||
addUndo({
|
||||
icon: "warning",
|
||||
toastColor: "warning",
|
||||
message:
|
||||
operationName === "undo"
|
||||
? t`Nothing to undo`
|
||||
: t`Nothing to redo`,
|
||||
}),
|
||||
);
|
||||
} else if (response.data?.result?.[tableId]) {
|
||||
const operations = response.data?.result?.[tableId].reduce(
|
||||
(acc, [operationType, row]) => {
|
||||
if (operationType === "create") {
|
||||
acc.created.push(row);
|
||||
} else if (operationType === "update") {
|
||||
acc.updated.push(row);
|
||||
} else if (operationType === "delete") {
|
||||
acc.deleted.push(row);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
created: [] as Record<string, RowValue>[],
|
||||
updated: [] as Record<string, RowValue>[],
|
||||
deleted: [] as Record<string, RowValue>[],
|
||||
},
|
||||
);
|
||||
|
||||
if (operations.created.length > 0) {
|
||||
stateUpdateStrategy.onRowsCreated(operations.created);
|
||||
}
|
||||
if (operations.updated.length > 0) {
|
||||
stateUpdateStrategy.onRowsUpdated(operations.updated);
|
||||
}
|
||||
if (operations.deleted.length > 0) {
|
||||
stateUpdateStrategy.onRowsDeleted(operations.deleted);
|
||||
}
|
||||
}
|
||||
},
|
||||
[dispatch, stateUpdateStrategy, tableId],
|
||||
);
|
||||
const undo = useCallback(async () => {
|
||||
const response = await undoMutation({ tableId, scope });
|
||||
handleResponse("undo", response);
|
||||
}, [undoMutation, tableId, scope, handleResponse]);
|
||||
|
||||
const redo = useCallback(async () => {
|
||||
const response = await redoMutation({ tableId, scope });
|
||||
handleResponse("redo", response);
|
||||
}, [redoMutation, tableId, scope, handleResponse]);
|
||||
|
||||
const currentActionLabel = isUndoLoading
|
||||
? t`Undoing actions...`
|
||||
: isRedoLoading
|
||||
? t`Redoing actions...`
|
||||
: undefined;
|
||||
|
||||
return { undo, redo, isUndoLoading, isRedoLoading, currentActionLabel };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { b64hash_to_utf8, utf8_to_b64url } from "metabase/lib/encoding";
|
||||
import type { GenericErrorResponse } from "metabase/lib/errors";
|
||||
import { isPK } from "metabase-lib/v1/types/utils/isa";
|
||||
import type { DatasetData, Filter } from "metabase-types/api";
|
||||
|
||||
import type { CellUniqKey, RowPkValue } from "../types";
|
||||
|
||||
export const serializeTableFilter = (filterMbql: Filter): string => {
|
||||
return utf8_to_b64url(JSON.stringify(filterMbql));
|
||||
};
|
||||
|
||||
export const deserializeTableFilter = (filterParam: string): Filter | null => {
|
||||
const maybeFilter = JSON.parse(b64hash_to_utf8(filterParam));
|
||||
|
||||
// simple and hacky way to test if param is a valid filter
|
||||
return Array.isArray(maybeFilter) && typeof maybeFilter[0] === "string"
|
||||
? (maybeFilter as Filter)
|
||||
: null;
|
||||
};
|
||||
|
||||
export const getRowPkKeyValue = (
|
||||
datasetData: DatasetData,
|
||||
rowIndex: number,
|
||||
) => {
|
||||
const columns = datasetData.cols;
|
||||
const rowData = datasetData.rows[rowIndex];
|
||||
|
||||
const pkColumnIndex = columns.findIndex(isPK);
|
||||
const pkColumn = columns[pkColumnIndex];
|
||||
const rowPkValue = rowData[pkColumnIndex];
|
||||
|
||||
return { [pkColumn.name]: rowPkValue };
|
||||
};
|
||||
|
||||
export const getUpdateApiErrorMessage = (
|
||||
error: GenericErrorResponse | unknown,
|
||||
): string => {
|
||||
const maybeError = error as GenericErrorResponse;
|
||||
|
||||
if (typeof maybeError.data === "string") {
|
||||
return maybeError.data;
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(maybeError.data?.errors) &&
|
||||
"error" in maybeError.data?.errors[0]
|
||||
) {
|
||||
return maybeError.data.errors[0].error;
|
||||
}
|
||||
|
||||
if (Array.isArray(maybeError.errors) && "error" in maybeError.errors[0]) {
|
||||
return maybeError.errors[0].error;
|
||||
}
|
||||
|
||||
return t`Unknown error`;
|
||||
};
|
||||
|
||||
export const getCellUniqKey = (
|
||||
rowPkValue: RowPkValue,
|
||||
columnName: string,
|
||||
): CellUniqKey => {
|
||||
// DataGrid uses rowIndex + column name key, which is not unique, so we have to use pk value
|
||||
return `${rowPkValue}_${columnName}`;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user