Compare commits
140 Commits
python-run
...
event-cont
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b519c1035e | ||
|
|
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 |
@@ -34,7 +34,7 @@
|
||||
{:metabase/modules
|
||||
{actions
|
||||
{:api #{metabase.actions.api metabase.actions.core}
|
||||
:uses #{analytics api driver legacy-mbql lib model-persistence models query-processor search util}}
|
||||
:uses #{analytics api driver events legacy-mbql lib model-persistence models query-processor search util}}
|
||||
|
||||
activity-feed
|
||||
{:api #{metabase.activity-feed.api metabase.activity-feed.init}
|
||||
@@ -211,7 +211,7 @@
|
||||
|
||||
events
|
||||
{:api #{metabase.events metabase.events.init metabase.events.notification}
|
||||
:uses #{api channel driver models notification premium-features util}}
|
||||
:uses #{api config channel driver models notification premium-features util}}
|
||||
|
||||
formatter
|
||||
{:api #{metabase.formatter}
|
||||
@@ -392,6 +392,7 @@
|
||||
driver
|
||||
events
|
||||
models
|
||||
lib
|
||||
permissions
|
||||
premium-features
|
||||
public-settings
|
||||
@@ -681,7 +682,7 @@
|
||||
|
||||
enterprise/core
|
||||
{:api #{metabase-enterprise.core.init}
|
||||
:uses #{enterprise/enhancements}}
|
||||
:uses #{enterprise/enhancements enterprise/data-editing}}
|
||||
|
||||
enterprise/dashboard-subscription-filters
|
||||
{:api #{}
|
||||
@@ -772,7 +773,11 @@
|
||||
|
||||
enterprise/data-editing
|
||||
{:api #{}
|
||||
:uses #{api actions util}}
|
||||
:uses #{api actions driver events lib models query-processor upload util}}
|
||||
|
||||
enterprise/data-editing-public
|
||||
{:api #{}
|
||||
:uses #{api actions enterprise/data-editing driver events lib query-processor upload util}}
|
||||
|
||||
enterprise/task
|
||||
{:api #{metabase-enterprise.task.truncate-audit-tables}
|
||||
|
||||
11
deps.edn
11
deps.edn
@@ -57,11 +57,10 @@
|
||||
com.ibm.icu/icu4j {:mvn/version "76.1"} ; Used to transliterate Unicode characters into Latin characters
|
||||
com.mchange/c3p0 {:mvn/version "0.10.1"} ; DB connection pool
|
||||
com.snowplowanalytics/snowplow-java-tracker ^:antq/exclude ; 2+ depend on apache httpclient 5.x, but saml and clj-http depend on 4.x
|
||||
{:mvn/version "1.0.1"} ; Snowplow analytics
|
||||
{:mvn/version "1.0.1"} ; Snowplow analytics
|
||||
com.taoensso/nippy {:mvn/version "3.4.2"} ; 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.18.0"} ; Apache Commons -- useful IO util fns
|
||||
commons-net/commons-net {:mvn/version "3.11.1"} ; Apache Commons -- useful network utils. Transitive dep of Snowplow, pinned due to CVE-2021-37533
|
||||
@@ -86,7 +85,7 @@
|
||||
:git/url "https://github.com/eerohele/pp"}
|
||||
io.github.metabase/macaw {:mvn/version "0.2.25"} ; Parse native SQL queries
|
||||
io.github.resilience4j/resilience4j-retry ^:antq/exclude ; 2.x do not support Java 8
|
||||
{:mvn/version "1.7.1"} ; Support for retrying operations
|
||||
{:mvn/version "1.7.1"} ; Support for retrying operations
|
||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} ; prometheus jvm collector
|
||||
io.prometheus/simpleclient_jetty {:mvn/version "0.16.0"} ; prometheus jetty collector
|
||||
javax.servlet/servlet-api {:mvn/version "2.5"} ; used by ring's multipart-params (file upload)
|
||||
@@ -841,7 +840,9 @@
|
||||
;; clj -X:dev:ee:ee-dev:test:test/notification
|
||||
:test/notification
|
||||
{:exec-args {:only ["test/metabase/notification"
|
||||
"test/metabase/channel"]}}
|
||||
"test/metabase/channel"
|
||||
metabase.events.notification-test
|
||||
metabase-enterprise.data-editing.notification-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";
|
||||
|
||||
97
e2e/test/scenarios/data-editing/data-editing.cy.spec.ts
Normal file
97
e2e/test/scenarios/data-editing/data-editing.cy.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("scenarios > embedding > dashboard parameters", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.setTokenFeatures("all");
|
||||
|
||||
H.setTableEditingEnabledForDB(SAMPLE_DB_ID);
|
||||
|
||||
cy.intercept("GET", "/api/database").as("getDatabases");
|
||||
cy.intercept("GET", "/api/table/*").as("getTable");
|
||||
});
|
||||
|
||||
it("should allow to open table data page", () => {
|
||||
openDatabaseTable("People");
|
||||
|
||||
cy.wait("@getTable");
|
||||
|
||||
cy.findByTestId("main-navbar-root").should("not.be.visible");
|
||||
|
||||
cy.findByTestId("table-data-view-root")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("People").should("be.visible");
|
||||
|
||||
cy.findByText("Explore").should("be.visible");
|
||||
cy.findByText("Edit").should("be.visible");
|
||||
cy.findByLabelText("Refresh").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",
|
||||
|
||||
@@ -11,6 +11,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]
|
||||
@@ -66,6 +67,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))
|
||||
|
||||
@@ -6,4 +6,5 @@
|
||||
|
||||
See https://metaboat.slack.com/archives/CKZEMT1MJ/p1736556522733279 for rationale behind this pattern."
|
||||
(:require
|
||||
[metabase-enterprise.data-editing.init]
|
||||
[metabase-enterprise.enhancements.init]))
|
||||
|
||||
@@ -1,38 +1,278 @@
|
||||
(ns metabase-enterprise.data-editing.api
|
||||
(:require
|
||||
[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.events.notification :as events.notification]
|
||||
[metabase.models.field-values :as field-values]
|
||||
[metabase.upload :as-alias upload]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :as i18n :refer [tru]]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
[nano-id.core :as nano-id]
|
||||
[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)
|
||||
|
||||
(defmethod events.notification/notification-filter-for-topic :event/action.success
|
||||
[_topic event-info]
|
||||
[:and
|
||||
[:= :table_id (-> event-info :args :table_id)]
|
||||
[:= :action (u/qualified-name (:action event-info))]])
|
||||
|
||||
(defn- invalidate-field-values! [table-id rows]
|
||||
(let [field-name-xf (comp (mapcat keys)
|
||||
(distinct)
|
||||
(map name)
|
||||
(map u/lower-case-en))
|
||||
field-names (into #{} field-name-xf rows)
|
||||
fields (when (seq field-names)
|
||||
(t2/select :model/Field
|
||||
:table_id table-id
|
||||
:name [:in field-names]
|
||||
:has_field_values [:in ["list" "auto-list"]]))]
|
||||
(run! field-values/create-or-update-full-field-values! fields)))
|
||||
|
||||
(defn require-authz?
|
||||
"Temporary hack to have auth be off by default, only on if MB_DATA_EDITING_AUTHZ=true.
|
||||
Remove once auth policy is fixed for dashboards."
|
||||
[]
|
||||
(= "true" (System/getenv "MB_DATA_EDITING_AUTHZ")))
|
||||
|
||||
(defn- check-permissions []
|
||||
(when (require-authz?)
|
||||
(api/check-superuser)))
|
||||
|
||||
(api.macros/defendpoint :post "/table/:table-id"
|
||||
"Insert row(s) into the given table."
|
||||
[{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]
|
||||
{}
|
||||
{:keys [rows]}]
|
||||
(perform-bulk-action! :bulk/create table-id rows))
|
||||
{:keys [rows]} :- [:map [:rows [:sequential {:min 1} :map]]]]
|
||||
(check-permissions)
|
||||
(let [rows' (data-editing/apply-coercions table-id rows)
|
||||
res (data-editing/insert! api/*current-user-id* table-id rows')
|
||||
pk-fields (data-editing/select-table-pk-fields table-id)
|
||||
;; actions code does not return coerced values
|
||||
;; right now the FE works off qp outputs, which coerce output row data
|
||||
;; still feels messy, revisit this
|
||||
pks->db-row (data-editing/query-db-rows table-id pk-fields (map #(update-keys % keyword) (:created-rows res)))]
|
||||
(invalidate-field-values! table-id rows')
|
||||
{:created-rows (vals pks->db-row)}))
|
||||
|
||||
(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]} :- [:map [:rows [:sequential {:min 1} :map]]]]
|
||||
(check-permissions)
|
||||
(if (empty? rows)
|
||||
{:updated []}
|
||||
(let [rows' (data-editing/apply-coercions table-id rows)
|
||||
pk-fields (data-editing/select-table-pk-fields table-id)
|
||||
pks->db-row (data-editing/query-db-rows table-id pk-fields rows')
|
||||
updated-rows (volatile! [])
|
||||
user-id api/*current-user-id*]
|
||||
;; TODO this publishing needs to move down the stack and be generic all :row/delete invocations
|
||||
;; https://linear.app/metabase/issue/WRK-228/publish-events-when-modified-by-action-execution
|
||||
(doseq [row rows']
|
||||
;; TODO fix this to use bulk action properly
|
||||
(let [;; well, this is a trick, but I haven't figured out how to do single row update
|
||||
result (:rows-updated (data-editing/perform-bulk-action! :bulk/update table-id [row]))
|
||||
after-row (-> (data-editing/query-db-rows table-id pk-fields [row]) vals first)
|
||||
row-before (get pks->db-row (data-editing/get-row-pks pk-fields row))]
|
||||
(vswap! updated-rows conj after-row)
|
||||
(when (pos-int? result)
|
||||
(actions/publish-action-success!
|
||||
(nano-id/nano-id)
|
||||
user-id
|
||||
:row/update
|
||||
{:table_id table-id
|
||||
:row row}
|
||||
{:after after-row
|
||||
:before row-before
|
||||
:raw_update row}))))
|
||||
;; TODO this should also become a subscription to the above action's success, e.g. via the system event
|
||||
(let [row-pk->old-new-values (->> (for [row rows']
|
||||
(let [pks (data-editing/get-row-pks pk-fields row)]
|
||||
[pks [(get pks->db-row pks)
|
||||
row]]))
|
||||
(into {}))]
|
||||
(undo/track-change! user-id {table-id row-pk->old-new-values}))
|
||||
|
||||
(api.macros/defendpoint :delete "/table/:table-id"
|
||||
(invalidate-field-values! table-id rows')
|
||||
{:updated @updated-rows})))
|
||||
|
||||
(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]} :- [:map [:rows [:sequential {:min 1} :map]]]]
|
||||
(check-permissions)
|
||||
;; TODO fix for composite keys here too
|
||||
(let [pk-fields (data-editing/select-table-pk-fields table-id)
|
||||
pks->db-rows (data-editing/query-db-rows table-id pk-fields rows)
|
||||
res (data-editing/perform-bulk-action! :bulk/delete table-id rows)
|
||||
user-id api/*current-user-id*]
|
||||
;; TODO this publishing needs to move down the stack and be generic all :row/delete invocations
|
||||
;; https://linear.app/metabase/issue/WRK-228/publish-events-when-modified-by-action-execution
|
||||
(doseq [row rows]
|
||||
(actions/publish-action-success!
|
||||
(nano-id/nano-id)
|
||||
user-id
|
||||
:row/delete
|
||||
{:table_id table-id
|
||||
:row row}
|
||||
{:deleted_row (get pks->db-rows (data-editing/get-row-pks pk-fields row))}))
|
||||
;; TODO this should also become a subscription to the above action's success, e.g. via the system event
|
||||
(let [row-pk->old-new-values (->> (for [row rows]
|
||||
(let [pks (data-editing/get-row-pks pk-fields row)]
|
||||
[pks [(get pks->db-rows pks) nil]]))
|
||||
(into {}))]
|
||||
(undo/track-change! user-id {table-id row-pk->old-new-values}))
|
||||
res))
|
||||
|
||||
;; 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" ::upload/auto-incrementing-int-pk
|
||||
"boolean" ::upload/boolean
|
||||
"int" ::upload/int
|
||||
"float" ::upload/float
|
||||
"varchar255" ::upload/varchar-255
|
||||
"text" ::upload/text
|
||||
"date" ::upload/date
|
||||
"datetime" ::upload/datetime
|
||||
"offset_datetime" ::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 future will support editables for sure.
|
||||
Maybe actions, workflows, etc.
|
||||
Could even generalize to things like edits to dashboard definitions themselves."
|
||||
[_
|
||||
_
|
||||
{:keys [table-id no-op]}] :- [:map
|
||||
[:table-id ms/PositiveInt]
|
||||
[:no-op {:optional true} ms/BooleanValue]]
|
||||
(check-permissions)
|
||||
(api/check-404 (t2/select-one-pk :model/Table table-id))
|
||||
(let [user-id api/*current-user-id*]
|
||||
(if no-op
|
||||
{:batch_num (undo/next-batch-num true user-id table-id)}
|
||||
;; IDEA encapsulate this in an action
|
||||
;; IDEA use generic action calling API instead of having this endpoint
|
||||
(try
|
||||
{:result (undo/undo! user-id table-id)}
|
||||
(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 future will support editables for sure.
|
||||
Maybe actions, workflows, etc.
|
||||
Could even generalize to things like edits to dashboard definitions themselves."
|
||||
[_
|
||||
_
|
||||
{:keys [table-id no-op]}] :- [:map
|
||||
[:table-id ms/PositiveInt]
|
||||
[:no-op {:optional true} ms/BooleanValue]]
|
||||
(check-permissions)
|
||||
(api/check-404 (t2/select-one :model/Table table-id))
|
||||
(let [user-id api/*current-user-id*]
|
||||
(if no-op
|
||||
{:batch_num (undo/next-batch-num false user-id table-id)}
|
||||
;; 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 table-id)}
|
||||
(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)}))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/data-editing routes."
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
(ns metabase-enterprise.data-editing.coerce
|
||||
(:import
|
||||
(clojure.lang BigInt)
|
||||
(java.time ZonedDateTime)
|
||||
(java.time.format DateTimeFormatter)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- json-zdt->unix-millis ^long [s]
|
||||
(-> s ZonedDateTime/parse .toInstant .toEpochMilli))
|
||||
|
||||
(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 [formatter (DateTimeFormatter/ofPattern "yyyyMMddHHmmSS")]
|
||||
(defn- json-zdt->yyyymmddhhmmss [s]
|
||||
(-> s ZonedDateTime/parse (.format formatter))))
|
||||
|
||||
;; 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))
|
||||
|
||||
(def input-coercion-fn
|
||||
"Maps a coercion strategy to a function from input JSON object to the action representation (to be supplied to JDBC).
|
||||
|
||||
Assumes the input JSON object is valid for the coercion strategy, and can fit within the bounds of the target column."
|
||||
{:Coercion/UNIXSeconds->DateTime #'json-zdt->unix-seconds
|
||||
:Coercion/UNIXMilliSeconds->DateTime #'json-zdt->unix-millis
|
||||
:Coercion/UNIXMicroSeconds->DateTime #'json-zdt->unix-micros
|
||||
:Coercion/UNIXNanoSeconds->DateTime #'json-zdt->unix-nanos
|
||||
:Coercion/YYYYMMDDHHMMSSString->Temporal #'json-zdt->yyyymmddhhmmss
|
||||
:Coercion/ISO8601->DateTime identity
|
||||
:Coercion/ISO8601->Date #'json-zdt->date
|
||||
:Coercion/ISO8601->Time #'json-zdt->time})
|
||||
@@ -0,0 +1,181 @@
|
||||
(ns metabase-enterprise.data-editing.data-editing
|
||||
(:require
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.data-editing.coerce :as data-editing.coerce]
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.api.common :as api]
|
||||
[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))
|
||||
(api/check-500 (pos? (count <>)))))
|
||||
|
||||
(defn get-row-pks
|
||||
[pk-fields row]
|
||||
(select-keys row (map (comp keyword :name) pk-fields)))
|
||||
|
||||
(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
|
||||
(m/index-by #(get-row-pks pk-fields %))))))))
|
||||
|
||||
(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)
|
||||
(data-editing.coerce/input-coercion-fn 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: :bulk/create, :bulk/update, :bulk/delete.
|
||||
The input `rows` is given different semantics depending on the action type, see actions/perform-action!."
|
||||
[action-kw table-id rows]
|
||||
;; TODO make this work for multi instances by using the metabase_cluster_lock table
|
||||
;; https://github.com/metabase/metabase/pull/56173/files
|
||||
(locking #'perform-bulk-action!
|
||||
(actions/perform-with-system-events!
|
||||
action-kw
|
||||
{:database (api/check-404 (t2/select-one-fn :db_id [:model/Table :db_id] table-id))
|
||||
:table-id table-id
|
||||
:arg rows}
|
||||
{:policy :data-editing})))
|
||||
|
||||
(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 table-id rows]
|
||||
;; TODO make sure we're always passed a user, and remove this fallback
|
||||
;; hard to make a business case for anonymous lemur's inserting data
|
||||
(let [user-id (or user-id (t2/select-one-pk :model/User :is_superuser true))
|
||||
res (perform-bulk-action! :bulk/create table-id rows)]
|
||||
;; TODO this publishing needs to move down the stack and be generic all :row/delete invocations
|
||||
;; https://linear.app/metabase/issue/WRK-228/publish-events-when-modified-by-action-execution
|
||||
(doseq [row (:created-rows res)]
|
||||
(actions/publish-action-success!
|
||||
(nano-id/nano-id)
|
||||
user-id
|
||||
:row/create
|
||||
{:table_id table-id
|
||||
:row row}
|
||||
{:created_row row}))
|
||||
;; TODO this should also become a subscription to the above action's success, e.g. via the system event
|
||||
(let [pk-cols (t2/select-fn-vec :name [:model/Field :name] :table_id table-id :semantic_type :type/PK)
|
||||
row-pk->old-new-values (->> (for [row (:created-rows res)]
|
||||
(let [pks (zipmap pk-cols (map row pk-cols))]
|
||||
[pks [nil row]]))
|
||||
(into {}))]
|
||||
;; TODO Circular reference will be fixed when we remove the hacks from this method.
|
||||
;; We'll actually delete this whole method, it'll just become an :editable/insert action invocation.
|
||||
((requiring-resolve 'metabase-enterprise.data-editing.undo/track-change!) user-id {table-id row-pk->old-new-values}))
|
||||
res))
|
||||
|
||||
(defn qry-context
|
||||
"TODO remove this hacky glue"
|
||||
[args-map]
|
||||
;; These assumptions about the shape of args-map are not generally true
|
||||
(let [table-id (:table-id args-map) ;
|
||||
pk-fields (when table-id (select-table-pk-fields table-id))
|
||||
rows (:arg args-map)]
|
||||
(when (and table-id pk-fields (seq rows))
|
||||
{:table-id table-id
|
||||
:pk-fields pk-fields
|
||||
:rows rows})))
|
||||
|
||||
(defn query-previous-rows
|
||||
"TODO fix all this hackery"
|
||||
[action-kw {:keys [table-id pk-fields rows]}]
|
||||
(case action-kw
|
||||
(:bulk/update :bulk/delete)
|
||||
(query-db-rows table-id pk-fields rows)
|
||||
|
||||
:bulk/create
|
||||
{}
|
||||
|
||||
;; action does not relate to row updates
|
||||
(throw (ex-info "See, this doesn't make sense" {:dumb :hack}))))
|
||||
|
||||
(defn query-latest-rows
|
||||
"TODO fix all this hackery"
|
||||
[action-kw {:keys [table-id pk-fields rows]} result]
|
||||
(case action-kw
|
||||
:bulk/delete
|
||||
{}
|
||||
|
||||
:bulk/update
|
||||
(query-db-rows table-id pk-fields rows)
|
||||
|
||||
:bulk/create
|
||||
(->> (for [row (:created-rows result)]
|
||||
[(query-db-rows table-id pk-fields [row])
|
||||
(update-keys row keyword)])
|
||||
(into {}))
|
||||
|
||||
;; action does not relate to row updates
|
||||
(throw (ex-info "See, this doesn't make sense" {:dumb :hack}))))
|
||||
@@ -0,0 +1,5 @@
|
||||
(ns metabase-enterprise.data-editing.events)
|
||||
|
||||
(derive ::event :metabase/event)
|
||||
|
||||
;; there will probably come a day
|
||||
@@ -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]))
|
||||
171
enterprise/backend/src/metabase_enterprise/data_editing/undo.clj
Normal file
171
enterprise/backend/src/metabase_enterprise/data_editing/undo.clj
Normal file
@@ -0,0 +1,171 @@
|
||||
(ns metabase-enterprise.data-editing.undo
|
||||
(:require
|
||||
[metabase-enterprise.data-editing.data-editing :as data-editing]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.util :as u]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(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?))
|
||||
|
||||
;; We must enforce the following invariant for the data in the Undo table:
|
||||
;;
|
||||
;; undone` is monotonic in `batch_num`, for each `table_id`, `row_pk` combination.
|
||||
;; i.e., it can only change from `false` to `true` for any logical "cell", as `batch_num` increases.
|
||||
|
||||
(defn- next-batch [undo? user-id table-id]
|
||||
;; 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]
|
||||
[:= table-id :table_id]
|
||||
(if undo?
|
||||
[:not :undone]
|
||||
:undone)]}]))
|
||||
|
||||
(defn track-change!
|
||||
"Insert some snapshot data based on edits made to the given table."
|
||||
[user-id table-id->row-pk->old-new-values]
|
||||
(t2/insert!
|
||||
:model/Undo
|
||||
(for [[table-id table-updates] table-id->row-pk->old-new-values
|
||||
[row-pk [old new]] table-updates]
|
||||
{:batch_num [:+ [:inline 1] [:coalesce {:from [:data_edit_undo_chain] :select [[[:max :batch_num]]]} 0]]
|
||||
:table_id table-id
|
||||
:user_id user-id
|
||||
:row_pk row-pk
|
||||
:raw_before old
|
||||
:raw_after new}))
|
||||
;; TODO delete any orphaned or conflicting "undone" changes (keep in sync with conflict? if we fix false positives)
|
||||
;; We do this in multiple statements because:
|
||||
;; - it's much simpler
|
||||
;; - typically there is only one table, and this is more efficient in that case
|
||||
(doseq [[table-id table-updates] table-id->row-pk->old-new-values
|
||||
:let [row-pks (keys table-updates)]]
|
||||
;; TODO This is the wrong batch number
|
||||
;; - we need to scope ourselves just to these row-pks
|
||||
;; - we want to look across all users
|
||||
;; - we can't rely on `undone` being monotonic if we're searching across multiple row-pks
|
||||
(when-let [{:keys [batch_num]} (first (next-batch true user-id table-id))]
|
||||
(t2/delete! :model/Undo
|
||||
:table_id table-id
|
||||
:row_pk [:in row-pks]
|
||||
:batch_num [:> batch_num]
|
||||
:undone true))))
|
||||
|
||||
(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? user-id table-id]
|
||||
(:batch_num (first (next-batch undo? user-id table-id))))
|
||||
|
||||
;; 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? batch]
|
||||
(doseq [[[table-id category] sub-batch] (u/group-by (juxt :table_id categorize) batch)
|
||||
:let [rows (batch->rows undo? sub-batch)]]
|
||||
(case (if undo? (invert category) category)
|
||||
:create (try (data-editing/perform-bulk-action! :bulk/create table-id rows)
|
||||
(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! :bulk/update table-id rows)
|
||||
:delete (data-editing/perform-bulk-action! :bulk/delete table-id rows))))
|
||||
|
||||
(defn- undo*! [undo? user-id table-id]
|
||||
(let [batch (next-batch undo? user-id table-id)]
|
||||
(cond
|
||||
(not (seq batch)) (throw (ex-info (if undo?
|
||||
"No previous versions found"
|
||||
"No subsequent versions found")
|
||||
{:error :undo/none
|
||||
:user-id user-id
|
||||
:table-id table-id}))
|
||||
(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
|
||||
:table-id table-id}))
|
||||
:else
|
||||
(do (update-table-data! undo? 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 table-id]
|
||||
(undo*! true user-id table-id))
|
||||
|
||||
(defn redo!
|
||||
"Rollback the given user's last change to the given table."
|
||||
[user-id table-id]
|
||||
(undo*! false user-id table-id))
|
||||
@@ -0,0 +1,30 @@
|
||||
(ns metabase-enterprise.data-editing-public.api
|
||||
(:require
|
||||
[metabase-enterprise.data-editing.data-editing :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) (:table_id hook) rows)))}))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/data-editing routes."
|
||||
(api.macros/ns-handler *ns*))
|
||||
@@ -65,6 +65,7 @@
|
||||
"Notification"
|
||||
"NotificationCard"
|
||||
"NotificationSubscription"
|
||||
"NotificationSystemEvent"
|
||||
"NotificationHandler"
|
||||
"NotificationRecipient"
|
||||
"Permissions"
|
||||
@@ -90,6 +91,7 @@
|
||||
"Session"
|
||||
"TablePrivileges"
|
||||
"TaskHistory"
|
||||
"Undo"
|
||||
"User"
|
||||
"UserKeyValue"
|
||||
"UserParameterValue"
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
:sso_jwt true
|
||||
:sso_ldap true
|
||||
:sso_saml true
|
||||
:table_data_editing false
|
||||
:upload_management false
|
||||
: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,504 @@
|
||||
(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))))
|
||||
|
||||
(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 "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"}]})))
|
||||
|
||||
(testing "Initially the table is empty"
|
||||
(is (= [] (table-rows table-id))))
|
||||
(is (= [[1 "Pidgey" "Car alarms"]
|
||||
[2 "Spearow" "Hold music"]
|
||||
[3 "Farfetch'd" "The land of lisp"]]
|
||||
(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"}]})))
|
||||
(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" "Car alarms"]
|
||||
[2 "Spearow" "Hold music"]
|
||||
[3 "Farfetch'd" "The land of lisp"]]
|
||||
(table-rows table-id))))
|
||||
(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 "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"}]})))
|
||||
(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)))))))))
|
||||
|
||||
(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))))
|
||||
(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
|
||||
|
||||
(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)))))
|
||||
post-response
|
||||
(req user :post url {:rows [{:name "Pidgey" :song "Car alarms"}]})
|
||||
|
||||
(finally
|
||||
(try
|
||||
(driver/drop-table! driver (mt/id) table-name)
|
||||
(catch Exception _)))))))))
|
||||
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")
|
||||
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"}])
|
||||
(is (= ["a"] (field-values)))
|
||||
(create! [{:n "b"} {:n "c"}])
|
||||
(is (= ["a" "b" "c"] (field-values)))
|
||||
(update! [{:id 2, :n "d"}])
|
||||
(is (= ["a" "c" "d"] (field-values)))
|
||||
(create! [{:n "a"}])
|
||||
(update! [{:id 1, :n "e"}])
|
||||
(is (= ["a" "c" "d" "e"] (field-values))))))))
|
||||
|
||||
@@ -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,190 @@
|
||||
(ns metabase-enterprise.data-editing.notification-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
|
||||
[metabase.events.notification :as events.notification]
|
||||
[metabase.notification.test-util :as notification.tu]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(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!
|
||||
[action request-fn channel-type->assert-fns]
|
||||
(data-editing.tu/with-temp-test-db!
|
||||
(mt/with-temp [:model/Channel chn {:type :channel/http}]
|
||||
(notification.tu/with-system-event-notification!
|
||||
[_notification {:notification-system-event {:event_name :event/action.success
|
||||
:action action
|
||||
:table_id (mt/id :categories)}
|
||||
: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))]
|
||||
(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!
|
||||
:bulk/create
|
||||
(fn []
|
||||
(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 (=? {:attachments [{: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-id "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(is (=? {:subject "Table CATEGORIES has a new row"
|
||||
:message [{"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!
|
||||
:bulk/update
|
||||
(fn []
|
||||
(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 (=? {:attachments [{:blocks
|
||||
[{:type "section",
|
||||
:text
|
||||
{:type "mrkdwn",
|
||||
:text "*Crowberto Corv has updated a row from CATEGORIES*\n*Update:*\n• NAME : Updated Category"}}]}]
|
||||
:channel-id "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(is (=? {:subject "Table CATEGORIES has been updated"
|
||||
:message [{"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!
|
||||
:bulk/delete
|
||||
(fn []
|
||||
(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 (=? {:attachments [{: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-id "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(is (=? {:subject "Table CATEGORIES has a row deleted"
|
||||
:message [{"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 filter-notifications-test
|
||||
(testing "getting table notifications will return only notifications of a table and action"
|
||||
(doseq [action [:bulk/create
|
||||
:bulk/update
|
||||
:bulk/delete]]
|
||||
(notification.tu/with-system-event-notification!
|
||||
[_create-categories {:notification-system-event {:event_name :event/action.success
|
||||
:action action
|
||||
:table_id (mt/id :categories)}}]
|
||||
|
||||
(notification.tu/with-system-event-notification!
|
||||
[create-orders {:notification-system-event {:event_name :event/action.success
|
||||
:action action
|
||||
:table_id (mt/id :orders)}}]
|
||||
(is (= [(:id create-orders)]
|
||||
(map :id (#'events.notification/notifications-for-topic
|
||||
:event/action.success
|
||||
{:action action
|
||||
:args {:table_id (mt/id :orders)}})))))))))
|
||||
|
||||
(deftest example-payload-row-create-test
|
||||
(is (=? {: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 1 :name "orders"}
|
||||
:records [{:row {:ID 1 :STATUS "approved"} :changes {:STATUS {:before "pending" :after "approved"}}}]
|
||||
:settings {}}
|
||||
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:payload_type :notification/system-event
|
||||
:payload {:event_name :event/action.success
|
||||
:action :bulk/create}
|
||||
:creator_id (mt/user->id :crowberto)})))))
|
||||
|
||||
(deftest example-payload-row-update-test
|
||||
(is (=? {: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 1 :name "orders"}
|
||||
:records [{:row {:ID 1 :STATUS "approved"} :changes {:STATUS {:before "pending" :after "approved"}}}]
|
||||
:settings {}}
|
||||
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:payload_type :notification/system-event
|
||||
:payload {:event_name :event/action.success
|
||||
:action :bulk/update}
|
||||
:creator_id (mt/user->id :crowberto)})))))
|
||||
|
||||
(deftest example-payload-row-delete-test
|
||||
(is (=? {: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 1 :name "orders"}
|
||||
:records [{:row {:ID 1 :STATUS "approved"} :changes {:STATUS {:before "pending" :after "approved"}}}]
|
||||
:settings {}}
|
||||
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:payload_type :notification/system-event
|
||||
:payload {:event_name :event/action.success
|
||||
:action :bulk/delete}
|
||||
:creator_id (mt/user->id :crowberto)})))))
|
||||
@@ -0,0 +1,90 @@
|
||||
(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 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,230 @@
|
||||
(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])
|
||||
(:import
|
||||
(clojure.lang ExceptionInfo)))
|
||||
|
||||
(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}})))
|
||||
|
||||
;; TODO use actual mutations to create the history (TODO subscribe to the relevant events)
|
||||
(defn- write-sequence! [table-id pk states]
|
||||
(loop [prior nil
|
||||
states states]
|
||||
(when (seq states)
|
||||
(let [[user-id value] (first states)]
|
||||
(undo/track-change! user-id {table-id {pk [prior 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)]
|
||||
(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 (undo/next-batch-num true user-id table-id))
|
||||
(is (not (undo/next-batch-num false user-id table-id)))
|
||||
(is (= {table-id [[:create {:id 1, :name "Snorkmaiden", :favourite_food "orc"}]]}
|
||||
(undo/undo! user-id table-id)))
|
||||
(is (= [[1 "Snorkmaiden" "orc"]] (table-rows table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-id table-id))
|
||||
(is (undo/next-batch-num false user-id table-id))
|
||||
(is (= {table-id [[:update {:id 1, :name "Snorkmaiden", :favourite_food "pork"}]]}
|
||||
(undo/undo! user-id table-id)))
|
||||
(is (= [[1 "Snorkmaiden" "pork"]] (table-rows table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-id table-id))
|
||||
(is (undo/next-batch-num false user-id table-id))
|
||||
;; This doesn't tell the FE which rows to hide
|
||||
(is (= {table-id [[:delete {:id 1}]]}
|
||||
(undo/undo! user-id table-id)))
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (not (undo/next-batch-num true user-id table-id)))
|
||||
(is (undo/next-batch-num false user-id table-id))
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo
|
||||
#"No previous versions found"
|
||||
(undo/undo! user-id table-id)))
|
||||
|
||||
(is (not (undo/next-batch-num true user-id table-id)))
|
||||
(is (undo/next-batch-num false user-id table-id))
|
||||
(is (= {table-id [[:create {:id 1, :name "Snorkmaiden", :favourite_food "pork"}]]}
|
||||
(undo/redo! user-id table-id)))
|
||||
(is (= [[1 "Snorkmaiden" "pork"]] (table-rows table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-id table-id))
|
||||
(is (undo/next-batch-num false user-id table-id))
|
||||
(is (= {table-id [[:update {:id 1, :name "Snorkmaiden", :favourite_food "orc"}]]}
|
||||
(undo/redo! user-id table-id)))
|
||||
(is (= [[1 "Snorkmaiden" "orc"]] (table-rows table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-id table-id))
|
||||
(is (undo/next-batch-num false user-id table-id))
|
||||
(is (= {table-id [[:delete {:id 1}]]}
|
||||
(undo/redo! user-id table-id)))
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-id table-id))
|
||||
(is (not (undo/next-batch-num false user-id table-id)))
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo
|
||||
#"No subsequent versions found"
|
||||
(undo/redo! user-id table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-id table-id))
|
||||
(is (not (undo/next-batch-num false user-id table-id)))))))))
|
||||
|
||||
;; 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)]
|
||||
(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 (undo/next-batch-num true user-2 table-id))
|
||||
(is (not (undo/next-batch-num false user-2 table-id)))
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo
|
||||
#"Blocked by other changes"
|
||||
(undo/undo! user-2 table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-1 table-id))
|
||||
(is (not (undo/next-batch-num false user-1 table-id)))
|
||||
(is (= {table-id [[:create {:id 2, :name "Moominswole", :power 9001}]]}
|
||||
(undo/undo! user-1 table-id)))
|
||||
(is (= [[2 "Moominswole" 9001]] (table-rows table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-1 table-id))
|
||||
(is (undo/next-batch-num false user-1 table-id))
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo
|
||||
#"Blocked by other changes"
|
||||
(undo/undo! user-1 table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-1 table-id))
|
||||
(is (undo/next-batch-num false user-1 table-id))
|
||||
(is (undo/next-batch-num true user-2 table-id))
|
||||
(is (not (undo/next-batch-num false user-2 table-id)))
|
||||
(is (= {table-id [[:update {:id 2, :name "Moomintroll", :power 9001}]]}
|
||||
(undo/undo! user-2 table-id)))
|
||||
(is (= [[2 "Moomintroll" 9001]] (table-rows table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-1 table-id))
|
||||
(is (undo/next-batch-num false user-1 table-id))
|
||||
(is (= {table-id [[:update {:id 2, :name "Moomintroll", :power 3}]]}
|
||||
(undo/undo! user-1 table-id)))
|
||||
(is (= [[2 "Moomintroll" 3]] (table-rows table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-1 table-id))
|
||||
(is (undo/next-batch-num false user-1 table-id))
|
||||
(is (= {table-id [[:delete {:id 2}]]}
|
||||
(undo/undo! user-1 table-id)))
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (not (undo/next-batch-num true user-1 table-id)))
|
||||
(is (undo/next-batch-num false user-1 table-id))
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo
|
||||
#"No previous versions found"
|
||||
(undo/undo! user-1 table-id)))
|
||||
|
||||
(is (not (undo/next-batch-num true user-1 table-id)))
|
||||
(is (undo/next-batch-num false user-1 table-id))
|
||||
(is (= {table-id [[:create {:id 2, :name "Moomintroll", :power 3}]]}
|
||||
(undo/redo! user-1 table-id)))
|
||||
(is (= [[2 "Moomintroll" 3]] (table-rows table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-1 table-id))
|
||||
(is (undo/next-batch-num false user-1 table-id))
|
||||
(is (= {table-id [[:update {:id 2, :name "Moomintroll", :power 9001}]]}
|
||||
(undo/redo! user-1 table-id)))
|
||||
(is (= [[2 "Moomintroll" 9001]] (table-rows table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-1 table-id))
|
||||
(is (undo/next-batch-num false user-1 table-id))
|
||||
(is (= {table-id [[:update {:id 2, :name "Moominswole", :power 9001}]]}
|
||||
(undo/redo! user-2 table-id)))
|
||||
(is (= [[2 "Moominswole" 9001]] (table-rows table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-1 table-id))
|
||||
(is (undo/next-batch-num false user-1 table-id))
|
||||
(is (= {table-id [[:delete {:id 2}]]}
|
||||
(undo/redo! user-1 table-id)))
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-1 table-id))
|
||||
(is (not (undo/next-batch-num false user-1 table-id)))
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo
|
||||
#"No subsequent versions found"
|
||||
(undo/redo! user-1 table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-2 table-id))
|
||||
(is (not (undo/next-batch-num false user-2 table-id)))
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo
|
||||
#"No subsequent versions found"
|
||||
(undo/redo! user-2 table-id)))
|
||||
|
||||
(is (undo/next-batch-num true user-1 table-id))
|
||||
(is (not (undo/next-batch-num false user-1 table-id)))))))))
|
||||
@@ -0,0 +1 @@
|
||||
(ns metabase-enterprise.data-editing.util)
|
||||
@@ -56,6 +56,7 @@
|
||||
:model/Notification
|
||||
:model/NotificationCard
|
||||
:model/NotificationSubscription
|
||||
:model/NotificationSystemEvent
|
||||
:model/NotificationHandler
|
||||
:model/NotificationRecipient
|
||||
:model/ParameterCard
|
||||
@@ -82,6 +83,7 @@
|
||||
:model/TablePrivileges
|
||||
: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
|
||||
|
||||
@@ -3,5 +3,6 @@ export * from "./billing-info";
|
||||
export * from "./gdrive";
|
||||
export * from "./db-routing";
|
||||
export * from "./query-validation";
|
||||
export * from "./table-data-edit";
|
||||
export * from "./scim";
|
||||
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 }) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/data-editing/table/${tableId}`,
|
||||
body: { rows },
|
||||
}),
|
||||
}),
|
||||
updateTableRows: builder.mutation<
|
||||
TableUpdateRowsResponse,
|
||||
TableUpdateRowsRequest
|
||||
>({
|
||||
query: ({ tableId, rows }) => ({
|
||||
method: "PUT",
|
||||
url: `/api/ee/data-editing/table/${tableId}`,
|
||||
body: { rows },
|
||||
}),
|
||||
}),
|
||||
deleteTableRows: builder.mutation<
|
||||
TableDeleteRowsResponse,
|
||||
TableDeleteRowsRequest
|
||||
>({
|
||||
query: ({ tableId, rows }) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/data-editing/table/${tableId}/delete`,
|
||||
body: { rows },
|
||||
}),
|
||||
}),
|
||||
tableUndo: builder.mutation<TableUndoRedoResponse, TableUndoRedoRequest>({
|
||||
query: ({ tableId, noOp }) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/data-editing/undo`,
|
||||
body: { "table-id": tableId, "no-op": noOp },
|
||||
}),
|
||||
}),
|
||||
tableRedo: builder.mutation<TableUndoRedoResponse, TableUndoRedoRequest>({
|
||||
query: ({ tableId, noOp }) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/data-editing/redo`,
|
||||
body: { "table-id": tableId, "no-op": noOp },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useInsertTableRowsMutation,
|
||||
useUpdateTableRowsMutation,
|
||||
useDeleteTableRowsMutation,
|
||||
useTableUndoMutation,
|
||||
useTableRedoMutation,
|
||||
} = tableDataEditApi;
|
||||
@@ -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,183 @@
|
||||
import cx from "classnames";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
Icon,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
rem,
|
||||
} from "metabase/ui";
|
||||
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 { EditingBaseRowModal } from "./modals/EditingBaseRowModal";
|
||||
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 = {
|
||||
dashcardId: number;
|
||||
cardId: number;
|
||||
tableId: ConcreteTableId;
|
||||
data: DatasetData;
|
||||
className?: string;
|
||||
visualizationSettings?: VisualizationSettings;
|
||||
question: Question;
|
||||
};
|
||||
|
||||
export const EditTableDashcardVisualization = ({
|
||||
dashcardId,
|
||||
cardId,
|
||||
tableId,
|
||||
data,
|
||||
className,
|
||||
visualizationSettings,
|
||||
question,
|
||||
}: EditTableDashcardVisualizationProps) => {
|
||||
const stateUpdateStrategy = useTableEditingStateDashcardUpdateStrategy(
|
||||
dashcardId,
|
||||
cardId,
|
||||
);
|
||||
|
||||
const {
|
||||
isCreateRowModalOpen,
|
||||
expandedRowIndex,
|
||||
isInserting,
|
||||
closeCreateRowModal,
|
||||
tableFieldMetadataMap,
|
||||
|
||||
handleRowCreate,
|
||||
handleCellValueUpdate,
|
||||
handleExpandedRowDelete,
|
||||
handleModalOpenAndExpandedRow,
|
||||
} = useTableCRUD({ tableId, datasetData: data, stateUpdateStrategy });
|
||||
|
||||
const { undo, redo, isUndoLoading, isRedoLoading } = useTableEditingUndoRedo({
|
||||
tableId,
|
||||
stateUpdateStrategy,
|
||||
});
|
||||
|
||||
const columnsConfig = useEditableTableColumnConfigFromVisualizationSettings(
|
||||
visualizationSettings,
|
||||
);
|
||||
|
||||
const { hasCreateAction, hasDeleteAction } = useTableActions(
|
||||
visualizationSettings,
|
||||
);
|
||||
|
||||
const { getColumnSortDirection } = useTableSorting({
|
||||
question,
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack className={cx(S.container, className)} gap={0}>
|
||||
<Box pos="relative" className={S.gridWrapper}>
|
||||
<EditTableDataGrid
|
||||
data={data}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
onCellValueUpdate={handleCellValueUpdate}
|
||||
onRowExpandClick={handleModalOpenAndExpandedRow}
|
||||
columnsConfig={columnsConfig}
|
||||
getColumnSortDirection={getColumnSortDirection}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Flex
|
||||
p="xs"
|
||||
px="1rem"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
className={S.gridFooterDashcardVisualization}
|
||||
>
|
||||
<Group
|
||||
gap="xs"
|
||||
mih={
|
||||
rem(30) /* to avoid jumping when "New record" button is disabled */
|
||||
}
|
||||
>
|
||||
<ActionIcon onClick={undo} disabled={isUndoLoading || isRedoLoading}>
|
||||
{isUndoLoading ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<Icon
|
||||
name="undo"
|
||||
tooltip={t`Undo changes`}
|
||||
c="var(--mb-color-brand)"
|
||||
/>
|
||||
)}
|
||||
</ActionIcon>
|
||||
<ActionIcon onClick={redo} disabled={isUndoLoading || isRedoLoading}>
|
||||
{isRedoLoading ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<Icon
|
||||
name="redo"
|
||||
tooltip={t`Redo changes`}
|
||||
c="var(--mb-color-brand)"
|
||||
/>
|
||||
)}
|
||||
</ActionIcon>
|
||||
{hasCreateAction && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
fz="sm"
|
||||
leftSection={<Icon name="add" />}
|
||||
onClick={() => handleModalOpenAndExpandedRow()}
|
||||
>{t`New record`}</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz="sm" fw="bold">
|
||||
{getEditTableRowCountMessage(data)}
|
||||
</Text>
|
||||
</Flex>
|
||||
<EditingBaseRowModal
|
||||
opened={isCreateRowModalOpen}
|
||||
hasDeleteAction={hasDeleteAction}
|
||||
onClose={closeCreateRowModal}
|
||||
onEdit={handleCellValueUpdate}
|
||||
onRowCreate={handleRowCreate}
|
||||
onRowDelete={handleExpandedRowDelete}
|
||||
datasetColumns={data.cols}
|
||||
currentRowIndex={expandedRowIndex}
|
||||
currentRowData={
|
||||
expandedRowIndex !== undefined
|
||||
? data.rows[expandedRowIndex]
|
||||
: 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)}`;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
.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 .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);
|
||||
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.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);
|
||||
border-bottom: 1px solid var(--mb-color-border);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.sortIndicator {
|
||||
flex-shrink: 0;
|
||||
margin-left: 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,165 @@
|
||||
import type { Location } from "history";
|
||||
import { useMemo } from "react";
|
||||
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 { EditingBaseRowModal } from "./modals/EditingBaseRowModal";
|
||||
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;
|
||||
};
|
||||
location: Location<{ filter?: string }>;
|
||||
};
|
||||
|
||||
export const EditTableDataContainer = ({
|
||||
params: { dbId: dbIdParam, tableId: tableIdParam },
|
||||
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 stateUpdateStrategy =
|
||||
useTableEditingStateApiUpdateStrategy(fakeTableQuery);
|
||||
|
||||
const {
|
||||
isCreateRowModalOpen,
|
||||
expandedRowIndex,
|
||||
isInserting,
|
||||
closeCreateRowModal,
|
||||
tableFieldMetadataMap,
|
||||
|
||||
handleRowCreate,
|
||||
handleCellValueUpdate,
|
||||
handleExpandedRowDelete,
|
||||
handleModalOpenAndExpandedRow,
|
||||
} = useTableCRUD({ tableId, datasetData, stateUpdateStrategy });
|
||||
|
||||
const { undo, redo, isUndoLoading, isRedoLoading } = useTableEditingUndoRedo({
|
||||
tableId,
|
||||
stateUpdateStrategy,
|
||||
});
|
||||
|
||||
useMount(() => {
|
||||
dispatch(closeNavbar());
|
||||
});
|
||||
|
||||
if (!database || isLoading || !fakeTableQuestion) {
|
||||
// TODO: show loader
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!rawDatasetResult || !datasetData || !tableFieldMetadataMap) {
|
||||
// TODO: show error
|
||||
return null;
|
||||
}
|
||||
|
||||
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={handleModalOpenAndExpandedRow}
|
||||
onQuestionChange={handleQuestionChange}
|
||||
refetchTableDataQuery={refetch}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
/>
|
||||
)}
|
||||
{isDatabaseTableEditingEnabled(database) ? (
|
||||
<>
|
||||
<Box pos="relative" className={S.gridWrapper}>
|
||||
<EditTableDataGrid
|
||||
data={datasetData}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
onCellValueUpdate={handleCellValueUpdate}
|
||||
onRowExpandClick={handleModalOpenAndExpandedRow}
|
||||
/>
|
||||
</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
|
||||
opened={isCreateRowModalOpen}
|
||||
onClose={closeCreateRowModal}
|
||||
onEdit={handleCellValueUpdate}
|
||||
onRowCreate={handleRowCreate}
|
||||
onRowDelete={handleExpandedRowDelete}
|
||||
datasetColumns={datasetData.cols}
|
||||
currentRowIndex={expandedRowIndex}
|
||||
currentRowData={
|
||||
expandedRowIndex !== undefined
|
||||
? datasetData.rows[expandedRowIndex]
|
||||
: 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,214 @@
|
||||
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 type {
|
||||
DatasetColumn,
|
||||
DatasetData,
|
||||
FieldWithMetadata,
|
||||
RowValue,
|
||||
RowValues,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { canEditField } from "../../helpers";
|
||||
import type { UpdatedRowCellsHandlerParams } 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";
|
||||
|
||||
type EditTableDataGridProps = {
|
||||
data: DatasetData;
|
||||
fieldMetadataMap: Record<FieldWithMetadata["name"], FieldWithMetadata>;
|
||||
onCellValueUpdate: (params: UpdatedRowCellsHandlerParams) => void;
|
||||
onRowExpandClick: (rowIndex: number) => void;
|
||||
columnsConfig?: EditableTableColumnConfig;
|
||||
getColumnSortDirection?: (
|
||||
column: DatasetColumn,
|
||||
) => OrderByDirection | undefined;
|
||||
};
|
||||
|
||||
export const EditTableDataGrid = ({
|
||||
data,
|
||||
fieldMetadataMap,
|
||||
onCellValueUpdate,
|
||||
onRowExpandClick,
|
||||
columnsConfig,
|
||||
getColumnSortDirection,
|
||||
}: EditTableDataGridProps) => {
|
||||
const { cols, rows } = data;
|
||||
|
||||
const { editingCellId, onCellClickToEdit, onCellEditCancel } =
|
||||
useTableEditing();
|
||||
|
||||
const columnOrder = useMemo(
|
||||
() =>
|
||||
columnsConfig
|
||||
? columnsConfig.filter((it) => it.enabled).map(({ name }) => name)
|
||||
: cols.map(({ name }) => name),
|
||||
[cols, columnsConfig],
|
||||
);
|
||||
|
||||
const columnVisibility = useMemo(
|
||||
() =>
|
||||
columnsConfig
|
||||
? columnsConfig.reduce(
|
||||
(acc, { name, enabled }) => ({
|
||||
...acc,
|
||||
[name]: enabled,
|
||||
}),
|
||||
{},
|
||||
)
|
||||
: undefined,
|
||||
[columnsConfig],
|
||||
);
|
||||
|
||||
const columnSizingMap = useMemo(() => ({}), []);
|
||||
|
||||
const columnsOptions: ColumnOptions<RowValues, RowValue>[] = useMemo(() => {
|
||||
return cols.map((column, columnIndex) => {
|
||||
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,
|
||||
};
|
||||
|
||||
return options;
|
||||
});
|
||||
}, [
|
||||
cols,
|
||||
getColumnSortDirection,
|
||||
fieldMetadataMap,
|
||||
onCellValueUpdate,
|
||||
onCellEditCancel,
|
||||
editingCellId,
|
||||
]);
|
||||
|
||||
const rowId: RowIdColumnOptions = useMemo(
|
||||
() => ({
|
||||
variant: "expandButton",
|
||||
onRowExpandClick,
|
||||
}),
|
||||
[onRowExpandClick],
|
||||
);
|
||||
|
||||
const tableProps = useDataGridInstance({
|
||||
data: rows,
|
||||
rowId,
|
||||
columnOrder,
|
||||
columnSizingMap,
|
||||
columnsOptions,
|
||||
columnVisibility,
|
||||
});
|
||||
|
||||
// Optional
|
||||
const columnsConfiguredForEditing = useMemo(() => {
|
||||
if (!columnsConfig) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new Set(
|
||||
columnsConfig.filter((it) => it.editable).map(({ name }) => name),
|
||||
);
|
||||
}, [columnsConfig]);
|
||||
|
||||
const handleCellClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
{
|
||||
cellId,
|
||||
columnId,
|
||||
}: {
|
||||
cellId: string;
|
||||
columnId: string;
|
||||
},
|
||||
) => {
|
||||
const field = fieldMetadataMap?.[columnId];
|
||||
|
||||
if (
|
||||
columnsConfiguredForEditing &&
|
||||
!columnsConfiguredForEditing.has(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,
|
||||
columnsConfiguredForEditing,
|
||||
],
|
||||
);
|
||||
|
||||
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,127 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { TableNotificationsTrigger } from "metabase/notifications/modals";
|
||||
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,
|
||||
Loader,
|
||||
Stack,
|
||||
Title,
|
||||
} from "metabase/ui";
|
||||
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);
|
||||
|
||||
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}
|
||||
>{t`New record`}</Button>
|
||||
<Flex gap="xs">
|
||||
<RunButtonWithTooltip
|
||||
iconSize={16}
|
||||
onlyIcon
|
||||
medium
|
||||
compact
|
||||
isRunning={isLoading}
|
||||
onRun={refetchTableDataQuery}
|
||||
/>
|
||||
<TableNotificationsTrigger tableId={table.id} />
|
||||
<ActionIcon
|
||||
onClick={onUndo}
|
||||
disabled={isUndoLoading || isRedoLoading}
|
||||
>
|
||||
{isUndoLoading ? (
|
||||
<Loader size="xs" color="currentColor" />
|
||||
) : (
|
||||
<Icon name="undo" tooltip={t`Undo changes`} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
onClick={onRedo}
|
||||
disabled={isUndoLoading || isRedoLoading}
|
||||
>
|
||||
{isRedoLoading ? (
|
||||
<Loader size="xs" color="currentColor" />
|
||||
) : (
|
||||
<Icon name="redo" tooltip={t`Redo changes`} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
</Group>
|
||||
</Flex>
|
||||
<QuestionFiltersHeader
|
||||
expanded={areFiltersExpanded}
|
||||
question={question}
|
||||
updateQuestion={onQuestionChange}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
.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;
|
||||
|
||||
/* Should be in sync with DEFAULT_FONT_SIZE */
|
||||
font-size: 12.5px;
|
||||
|
||||
/* Properly alignment with BaseCell, to avoid text flickering (BaseCell has only border-bottom 1px) */
|
||||
border-top-width: 0;
|
||||
|
||||
/* Keep outline consistent for inline editing, since focused and non focused state is semantically the same */
|
||||
&:focus {
|
||||
outline: 2px solid var(--mb-base-color-blue-50);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { CellContext } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type {
|
||||
DatasetColumn,
|
||||
FieldWithMetadata,
|
||||
RowValue,
|
||||
RowValues,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import type { UpdatedRowCellsHandlerParams } 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: UpdatedRowCellsHandlerParams) => 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,
|
||||
});
|
||||
}
|
||||
|
||||
// Hide the editing cell after submitting the value
|
||||
onCellEditCancel();
|
||||
},
|
||||
[columnName, onCellEditCancel, onCellValueUpdate, rowIndex, initialValue],
|
||||
);
|
||||
|
||||
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 @@
|
||||
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,257 @@
|
||||
import { useCallback, useEffect, useMemo, 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 { 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,
|
||||
getDropdownLabelText = DefaultItemLabelTextGetter,
|
||||
getSelectedLabelText = DefaultSelectedLabelTextGetter,
|
||||
onSubmit,
|
||||
onChangeValue,
|
||||
onCancel,
|
||||
}: EditingBodyCellCategorySelectProps) => {
|
||||
const [value, setValue] = useState(initialValue?.toString() ?? "");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const combobox = useCombobox({
|
||||
defaultOpened: autoFocus,
|
||||
onDropdownClose: onCancel,
|
||||
});
|
||||
|
||||
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) => {
|
||||
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) {
|
||||
// Type safety, should always be present
|
||||
if (value in optionValueToOptionMap) {
|
||||
return getSelectedLabelText(optionValueToOptionMap[value]);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
isLoading,
|
||||
value,
|
||||
optionValueToOptionMap,
|
||||
inputProps?.placeholder,
|
||||
getSelectedLabelText,
|
||||
]);
|
||||
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
autoFocus
|
||||
/>
|
||||
</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,308 @@
|
||||
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 { UpdatedRowCellsHandlerParams } from "../../types";
|
||||
import { EditingBodyCellConditional } from "../inputs";
|
||||
import type { EditableTableColumnConfig } from "../use-editable-column-config";
|
||||
|
||||
import { DeleteRowConfirmationModal } from "./DeleteRowConfirmationModal";
|
||||
import S from "./EditingBaseRowModal.module.css";
|
||||
|
||||
interface EditingBaseRowModalProps {
|
||||
datasetColumns: DatasetColumn[];
|
||||
datasetTable?: Table;
|
||||
onClose: () => void;
|
||||
onEdit: (data: UpdatedRowCellsHandlerParams) => void;
|
||||
onRowCreate: (data: Record<string, RowValue>) => void;
|
||||
onRowDelete: (rowIndex: number) => void;
|
||||
opened: boolean;
|
||||
currentRowIndex?: number;
|
||||
currentRowData?: RowValues;
|
||||
fieldMetadataMap: Record<FieldWithMetadata["name"], FieldWithMetadata>;
|
||||
hasDeleteAction: boolean;
|
||||
isLoading?: boolean;
|
||||
columnsConfig?: EditableTableColumnConfig;
|
||||
}
|
||||
|
||||
type EditingFormValues = Record<string, RowValue>;
|
||||
|
||||
export function EditingBaseRowModal({
|
||||
datasetColumns,
|
||||
onClose,
|
||||
onEdit,
|
||||
onRowCreate,
|
||||
onRowDelete,
|
||||
opened,
|
||||
currentRowIndex,
|
||||
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 {
|
||||
isValid,
|
||||
resetForm,
|
||||
setFieldValue,
|
||||
handleSubmit,
|
||||
errors,
|
||||
validateForm: revalidateForm,
|
||||
} = useFormik({
|
||||
initialValues: {} as EditingFormValues,
|
||||
onSubmit: onRowCreate,
|
||||
validate: validateForm,
|
||||
validateOnMount: true,
|
||||
});
|
||||
|
||||
// Clear new row data when modal is opened
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
resetForm();
|
||||
revalidateForm();
|
||||
}
|
||||
}, [opened, resetForm, revalidateForm]);
|
||||
|
||||
const handleValueEdit = useCallback(
|
||||
(key: string, value: RowValue) => {
|
||||
if (currentRowIndex != null && isEditingMode) {
|
||||
onEdit({
|
||||
rowIndex: currentRowIndex,
|
||||
updatedData: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[isEditingMode, currentRowIndex, onEdit],
|
||||
);
|
||||
|
||||
const handleDeleteConfirmation = useCallback(() => {
|
||||
if (currentRowIndex !== undefined) {
|
||||
onRowDelete(currentRowIndex);
|
||||
closeDeletionModal();
|
||||
}
|
||||
}, [currentRowIndex, closeDeletionModal, onRowDelete]);
|
||||
|
||||
const disabledColumnNames = useMemo(() => {
|
||||
if (!columnsConfig) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new Set(
|
||||
columnsConfig.filter((it) => !it.editable).map(({ name }) => name),
|
||||
);
|
||||
}, [columnsConfig]);
|
||||
|
||||
// Columns might be reordered to match the order in `columnsConfig`
|
||||
const orderedDatasetColumns = useMemo(() => {
|
||||
if (!columnsConfig) {
|
||||
return datasetColumns;
|
||||
}
|
||||
|
||||
return columnsConfig
|
||||
.filter((it) => it.enabled)
|
||||
.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={opened} 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={disabledColumnNames?.has(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,39 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type {
|
||||
DashCardVisualizationSettings,
|
||||
VisualizationSettings,
|
||||
} from "metabase-types/api";
|
||||
|
||||
export type EditableTableColumnConfig = {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
editable: 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 enableAllEditableColumns = editableColumns.length === 0;
|
||||
const editableColumnsSet = new Set(editableColumns);
|
||||
|
||||
return columnSettings.map((column) => ({
|
||||
name: column.name,
|
||||
enabled: column.enabled,
|
||||
editable: enableAllEditableColumns || editableColumnsSet.has(column.name),
|
||||
}));
|
||||
}, [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,200 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useGetTableQueryMetadataQuery } from "metabase/api";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
import {
|
||||
useDeleteTableRowsMutation,
|
||||
useInsertTableRowsMutation,
|
||||
useUpdateTableRowsMutation,
|
||||
} from "metabase-enterprise/api";
|
||||
import type { UpdatedRowCellsHandlerParams } from "metabase-enterprise/data_editing/tables/types";
|
||||
import { isPK } from "metabase-lib/v1/types/utils/isa";
|
||||
import type {
|
||||
ConcreteTableId,
|
||||
DatasetData,
|
||||
FieldWithMetadata,
|
||||
RowValue,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import type { TableEditingStateUpdateStrategy } from "./use-table-state-update-strategy";
|
||||
|
||||
export const useTableCRUD = ({
|
||||
tableId,
|
||||
datasetData,
|
||||
stateUpdateStrategy,
|
||||
}: {
|
||||
tableId: ConcreteTableId;
|
||||
datasetData: DatasetData | null | undefined;
|
||||
stateUpdateStrategy: TableEditingStateUpdateStrategy;
|
||||
}) => {
|
||||
const [
|
||||
isCreateRowModalOpen,
|
||||
{ open: openCreateRowModal, close: closeCreateRowModal },
|
||||
] = useDisclosure(false);
|
||||
|
||||
const [expandedRowIndex, setExpandedRowIndex] = useState<
|
||||
number | undefined
|
||||
>();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
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 displayErrorIfExists = useCallback(
|
||||
(error: any) => {
|
||||
if (error) {
|
||||
dispatch(
|
||||
addUndo({
|
||||
icon: "warning",
|
||||
toastColor: "error",
|
||||
message: error?.data?.errors?.[0].error ?? t`An error occurred`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleCellValueUpdate = useCallback(
|
||||
async ({ updatedData, rowIndex }: UpdatedRowCellsHandlerParams) => {
|
||||
if (!datasetData) {
|
||||
console.warn(
|
||||
"Failed to update table data - no data is loaded for a table",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = datasetData.cols;
|
||||
const rowData = datasetData.rows[rowIndex];
|
||||
|
||||
const pkColumnIndex = columns.findIndex(isPK);
|
||||
const pkColumn = columns[pkColumnIndex];
|
||||
const rowPkValue = rowData[pkColumnIndex];
|
||||
|
||||
const updatedRowWithPk = {
|
||||
...updatedData,
|
||||
[pkColumn.name]: rowPkValue,
|
||||
};
|
||||
|
||||
const response = await updateTableRows({
|
||||
tableId: tableId,
|
||||
rows: [updatedRowWithPk],
|
||||
});
|
||||
|
||||
stateUpdateStrategy.onRowsUpdated(response.data?.updated);
|
||||
displayErrorIfExists(response.error);
|
||||
},
|
||||
[
|
||||
datasetData,
|
||||
updateTableRows,
|
||||
tableId,
|
||||
displayErrorIfExists,
|
||||
stateUpdateStrategy,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRowCreate = useCallback(
|
||||
async (data: Record<string, RowValue>) => {
|
||||
const response = await insertTableRows({
|
||||
tableId: tableId,
|
||||
rows: [data],
|
||||
});
|
||||
|
||||
displayErrorIfExists(response.error);
|
||||
if (!response.error) {
|
||||
closeCreateRowModal();
|
||||
stateUpdateStrategy.onRowsCreated(response.data?.["created-rows"]);
|
||||
}
|
||||
},
|
||||
[
|
||||
insertTableRows,
|
||||
tableId,
|
||||
displayErrorIfExists,
|
||||
closeCreateRowModal,
|
||||
stateUpdateStrategy,
|
||||
],
|
||||
);
|
||||
|
||||
const handleExpandedRowDelete = useCallback(
|
||||
async (rowIndex: number) => {
|
||||
if (!datasetData) {
|
||||
console.warn(
|
||||
"Failed to update table data - no data is loaded for a table",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = datasetData.cols;
|
||||
const rowData = datasetData.rows[rowIndex];
|
||||
|
||||
const pkColumnIndex = columns.findIndex(isPK);
|
||||
const pkColumn = columns[pkColumnIndex];
|
||||
const rowPkValue = rowData[pkColumnIndex];
|
||||
|
||||
closeCreateRowModal();
|
||||
|
||||
const rows = [{ [pkColumn.name]: rowPkValue }];
|
||||
const response = await deleteTableRows({
|
||||
rows,
|
||||
tableId: tableId,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
stateUpdateStrategy.onRowsDeleted(rows);
|
||||
}
|
||||
|
||||
displayErrorIfExists(response.error);
|
||||
},
|
||||
[
|
||||
datasetData,
|
||||
closeCreateRowModal,
|
||||
deleteTableRows,
|
||||
tableId,
|
||||
displayErrorIfExists,
|
||||
stateUpdateStrategy,
|
||||
],
|
||||
);
|
||||
|
||||
const handleModalOpenAndExpandedRow = useCallback(
|
||||
(rowIndex?: number) => {
|
||||
setExpandedRowIndex(rowIndex);
|
||||
openCreateRowModal();
|
||||
},
|
||||
[openCreateRowModal],
|
||||
);
|
||||
|
||||
return {
|
||||
isCreateRowModalOpen,
|
||||
expandedRowIndex,
|
||||
isInserting,
|
||||
closeCreateRowModal,
|
||||
tableFieldMetadataMap,
|
||||
|
||||
handleRowCreate,
|
||||
handleCellValueUpdate,
|
||||
handleExpandedRowDelete,
|
||||
handleModalOpenAndExpandedRow,
|
||||
};
|
||||
};
|
||||
@@ -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,112 @@
|
||||
import type { ThunkDispatch, UnknownAction } from "@reduxjs/toolkit";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { datasetApi } from "metabase/api";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
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(
|
||||
adhocQuery: 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>[]) => {
|
||||
if (!rows || !adhocQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
datasetApi.util.updateQueryData(
|
||||
"getAdhocQuery",
|
||||
adhocQuery,
|
||||
({ data }) => {
|
||||
data.rows.push(
|
||||
...mapDataEditingRowObjectsToRowValues(rows, data.cols),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
[dispatch, adhocQuery],
|
||||
);
|
||||
|
||||
const onRowsUpdated = useCallback(
|
||||
(rows?: Record<string, RowValue>[]) => {
|
||||
if (!rows || !adhocQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
[dispatch, adhocQuery],
|
||||
);
|
||||
|
||||
const onRowsDeleted = useCallback(
|
||||
(rows?: Record<string, RowValue>[]) => {
|
||||
if (!rows || !adhocQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
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, adhocQuery],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onRowsCreated,
|
||||
onRowsUpdated,
|
||||
onRowsDeleted,
|
||||
}),
|
||||
[onRowsCreated, onRowsUpdated, onRowsDeleted],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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 || !rows) {
|
||||
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 || !rows) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pkColumnIndex = cardData.data.cols.findIndex(isPK);
|
||||
const pkColumnName = cardData.data.cols[pkColumnIndex].name;
|
||||
const primaryKeyToUpdatedRowObjectMap =
|
||||
createPrimaryKeyToUpdatedRowObjectMap(pkColumnName, rows);
|
||||
|
||||
dispatch(
|
||||
updateCardData(cardId, dashcardId, {
|
||||
...cardData,
|
||||
data: {
|
||||
...cardData.data,
|
||||
rows: cardData.data.rows.map((row) => {
|
||||
const updatedRowObject = primaryKeyToUpdatedRowObjectMap.get(
|
||||
row[pkColumnIndex],
|
||||
);
|
||||
|
||||
if (updatedRowObject) {
|
||||
return row.map((value, index) => {
|
||||
const columnName = cardData.data.cols[index].name;
|
||||
|
||||
if (columnName in updatedRowObject) {
|
||||
return updatedRowObject[columnName];
|
||||
}
|
||||
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
[cardData, dashcardId, cardId, dispatch],
|
||||
);
|
||||
|
||||
const onRowsDeleted = useCallback(
|
||||
(rows?: Record<string, RowValue>[]) => {
|
||||
if (!cardData || !rows) {
|
||||
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,23 @@
|
||||
import type { DatasetColumn, RowValue } from "metabase-types/api";
|
||||
|
||||
type DataEditingRow = Record<string, RowValue>;
|
||||
|
||||
export interface TableEditingStateUpdateStrategy {
|
||||
onRowsCreated: (rows?: DataEditingRow[]) => void;
|
||||
onRowsUpdated: (rows?: DataEditingRow[]) => 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,100 @@
|
||||
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 { TableEditingStateUpdateStrategy } from "./use-table-state-update-strategy";
|
||||
|
||||
type UseTableEditingUndoRedoProps = {
|
||||
tableId: ConcreteTableId;
|
||||
stateUpdateStrategy: TableEditingStateUpdateStrategy;
|
||||
};
|
||||
|
||||
export function useTableEditingUndoRedo({
|
||||
tableId,
|
||||
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 });
|
||||
handleResponse("undo", response);
|
||||
}, [undoMutation, tableId, handleResponse]);
|
||||
|
||||
const redo = useCallback(async () => {
|
||||
const response = await redoMutation({ tableId });
|
||||
handleResponse("redo", response);
|
||||
}, [redoMutation, tableId, handleResponse]);
|
||||
|
||||
return { undo, redo, isUndoLoading, isRedoLoading };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { b64hash_to_utf8, utf8_to_b64url } from "metabase/lib/encoding";
|
||||
import type { Filter } from "metabase-types/api";
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
ConcreteTableId,
|
||||
DatasetColumn,
|
||||
RowValue,
|
||||
} from "metabase-types/api";
|
||||
|
||||
export type RowCellsWithPkValue = Record<DatasetColumn["name"], RowValue>;
|
||||
|
||||
export type TableInsertRowsRequest = {
|
||||
tableId: ConcreteTableId;
|
||||
rows: RowCellsWithPkValue[];
|
||||
};
|
||||
|
||||
export type TableInsertRowsResponse = {
|
||||
"created-rows": Record<string, RowValue>[];
|
||||
};
|
||||
|
||||
export type TableUpdateRowsRequest = {
|
||||
tableId: ConcreteTableId;
|
||||
rows: RowCellsWithPkValue[];
|
||||
};
|
||||
|
||||
export type TableUpdateRowsResponse = { updated: Record<string, RowValue>[] };
|
||||
|
||||
export type TableDeleteRowsRequest = {
|
||||
tableId: ConcreteTableId;
|
||||
rows: RowCellsWithPkValue[];
|
||||
};
|
||||
|
||||
export type TableDeleteRowsResponse = { success: boolean };
|
||||
|
||||
export type UpdatedRowCellsHandlerParams = {
|
||||
updatedData: Record<DatasetColumn["name"], RowValue>;
|
||||
rowIndex: number;
|
||||
};
|
||||
|
||||
export type TableUndoRedoRequest = {
|
||||
tableId: ConcreteTableId;
|
||||
/**
|
||||
* When true, the API will only return the batch number of the next undo operation
|
||||
* without actually performing the undo. This is useful for checking if an undo operation
|
||||
* is available before attempting it.
|
||||
*/
|
||||
noOp?: boolean;
|
||||
};
|
||||
|
||||
export type TableOperation = [string, Record<string, RowValue>];
|
||||
|
||||
export type TableUndoRedoResponse = {
|
||||
batch_num?: number;
|
||||
result?: Record<ConcreteTableId, TableOperation[]>;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
.container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridWrapper {
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.gridRoot {
|
||||
border: 1px solid var(--mb-color-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMount } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useGetTableDataQuery, useGetTableQuery } from "metabase/api";
|
||||
import Link from "metabase/core/components/Link";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { TableNotificationsTrigger } from "metabase/notifications/modals";
|
||||
import { closeNavbar } from "metabase/redux/app";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import { getUserIsAdmin } from "metabase/selectors/user";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
Icon,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "metabase/ui";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import { getRowCountMessage } from "metabase-lib/v1/queries/utils/row-count";
|
||||
import * as ML_Urls from "metabase-lib/v1/urls";
|
||||
|
||||
import S from "./BrowseTableData.module.css";
|
||||
import { BrowseTableDataGrid } from "./BrowseTableDataGrid";
|
||||
|
||||
type BrowseTableDataProps = {
|
||||
params: {
|
||||
dbId: string;
|
||||
tableId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const BrowseTableData = ({
|
||||
params: { dbId: dbIdParam, tableId: tableIdParam },
|
||||
}: BrowseTableDataProps) => {
|
||||
const tableId = parseInt(tableIdParam, 10);
|
||||
const databaseId = parseInt(dbIdParam, 10);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const metadata = useSelector(getMetadata);
|
||||
|
||||
const isAdmin = useSelector(getUserIsAdmin);
|
||||
|
||||
const { data: table } = useGetTableQuery({ id: tableId });
|
||||
|
||||
const { data: datasetData } = useGetTableDataQuery({
|
||||
tableId,
|
||||
});
|
||||
|
||||
const editUrl = `/browse/databases/${databaseId}/tables/${tableId}/edit`;
|
||||
|
||||
const exploreUrl = useMemo(() => {
|
||||
const question = Question.create({
|
||||
databaseId,
|
||||
tableId,
|
||||
metadata,
|
||||
}).setDefaultDisplay();
|
||||
|
||||
return ML_Urls.getUrl(question);
|
||||
}, [databaseId, metadata, tableId]);
|
||||
|
||||
useMount(() => {
|
||||
dispatch(closeNavbar());
|
||||
});
|
||||
|
||||
if (!table || !datasetData) {
|
||||
// TODO: add loading and error handling
|
||||
// TODO: add loading state on data refresh
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack className={S.container} gap={0} data-testid="table-data-view-root">
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
px="1.5rem"
|
||||
py="1rem"
|
||||
bg="var(--mb-color-background)"
|
||||
>
|
||||
<Group gap="sm">
|
||||
<Icon
|
||||
name="table"
|
||||
color="var(--mb-color-text-secondary)"
|
||||
size="1.5rem"
|
||||
/>
|
||||
<Title>{table.display_name}</Title>
|
||||
<Box mt="0.4rem">
|
||||
<Icon name="info_filled" color="var(--mb-color-text-secondary)" />
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Button
|
||||
leftSection={<Icon name="insight" />}
|
||||
component={Link}
|
||||
to={exploreUrl}
|
||||
>{t`Explore`}</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
leftSection={<Icon name="pencil" />}
|
||||
component={Link}
|
||||
to={editUrl}
|
||||
>{t`Edit`}</Button>
|
||||
)}
|
||||
|
||||
<Flex gap="xs">
|
||||
<TableNotificationsTrigger tableId={tableId} />
|
||||
</Flex>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box className={S.gridWrapper} p="1.5rem 1.5rem 0.5rem">
|
||||
<BrowseTableDataGrid data={datasetData} />
|
||||
</Box>
|
||||
<Flex
|
||||
py="0.5rem"
|
||||
px="1.5rem"
|
||||
h="2.5rem"
|
||||
justify="flex-end"
|
||||
align="center"
|
||||
>
|
||||
<Text fw="bold" size="md" c="inherit" component="span">
|
||||
{getRowCountMessage(datasetData)}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
type ColumnOptions,
|
||||
DataGrid,
|
||||
useDataGridInstance,
|
||||
} from "metabase/data-grid";
|
||||
import { formatValue } from "metabase/lib/formatting/value";
|
||||
import { extractRemappedColumns } from "metabase/visualizations";
|
||||
import type { Dataset, RowValue, RowValues } from "metabase-types/api";
|
||||
|
||||
import S from "./BrowseTableData.module.css";
|
||||
|
||||
type BrowseTableDataGridProps = {
|
||||
data: Dataset;
|
||||
};
|
||||
|
||||
export const BrowseTableDataGrid = ({ data }: BrowseTableDataGridProps) => {
|
||||
const { cols, rows } = useMemo(
|
||||
() => extractRemappedColumns(data.data),
|
||||
[data.data],
|
||||
);
|
||||
|
||||
const columnOrder = useMemo(() => cols.map(({ name }) => name), [cols]);
|
||||
|
||||
const columnSizingMap = useMemo(() => ({}), []);
|
||||
|
||||
const columnsOptions: ColumnOptions<RowValues, RowValue>[] = useMemo(() => {
|
||||
return cols.map((column, columnIndex) => {
|
||||
const options: ColumnOptions<RowValues, RowValue> = {
|
||||
id: column.name,
|
||||
name: column.display_name,
|
||||
accessorFn: (row: RowValues) => row[columnIndex],
|
||||
formatter: (value) => formatValue(value, { column }),
|
||||
wrap: false,
|
||||
};
|
||||
|
||||
return options;
|
||||
});
|
||||
}, [cols]);
|
||||
|
||||
const tableProps = useDataGridInstance({
|
||||
data: rows,
|
||||
columnOrder,
|
||||
columnSizingMap,
|
||||
columnsOptions,
|
||||
});
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
classNames={{
|
||||
root: S.gridRoot,
|
||||
}}
|
||||
{...tableProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { BrowseTableData } from "./BrowseTableData";
|
||||
@@ -36,3 +36,4 @@ import "./resource_downloads";
|
||||
import "./user_provisioning";
|
||||
import "./clean_up";
|
||||
import "./troubleshooting";
|
||||
import "./data_editing";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as ML from "cljs/metabase.lib.js";
|
||||
import * as ML_MetadataCalculation from "cljs/metabase.lib.metadata.calculation";
|
||||
import type {
|
||||
Field as ApiField,
|
||||
CardId,
|
||||
CardType,
|
||||
DatabaseId,
|
||||
@@ -183,7 +184,6 @@ export function tableOrCardMetadata(
|
||||
): CardMetadata | TableMetadata {
|
||||
return ML.table_or_card_metadata(queryOrMetadataProvider, tableID);
|
||||
}
|
||||
|
||||
export function visibleColumns(
|
||||
query: Query,
|
||||
stageIndex: number,
|
||||
@@ -201,7 +201,7 @@ export function returnedColumns(
|
||||
export function fromLegacyColumn(
|
||||
query: Query,
|
||||
stageIndex: number,
|
||||
columnOrField: DatasetColumn | Field,
|
||||
columnOrField: DatasetColumn | Field | ApiField,
|
||||
): ColumnMetadata {
|
||||
return ML.legacy_column__GT_metadata(query, stageIndex, columnOrField);
|
||||
}
|
||||
|
||||
20
frontend/src/metabase-lib/v1/queries/utils/row-count.ts
Normal file
20
frontend/src/metabase-lib/v1/queries/utils/row-count.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { msgid, ngettext, t } from "ttag";
|
||||
|
||||
import { formatNumber } from "metabase/lib/formatting/numbers";
|
||||
import { HARD_ROW_LIMIT } from "metabase-lib/v1/queries/utils/index";
|
||||
import type { Dataset } from "metabase-types/api";
|
||||
|
||||
export const formatRowCount = (count: number) => {
|
||||
const countString = formatNumber(count);
|
||||
return ngettext(msgid`${countString} row`, `${countString} rows`, count);
|
||||
};
|
||||
|
||||
export function getRowCountMessage(result: Dataset): string {
|
||||
if (result.data.rows_truncated > 0) {
|
||||
return t`Showing first ${formatRowCount(result.row_count)}`;
|
||||
}
|
||||
if (result.row_count === HARD_ROW_LIMIT) {
|
||||
return t`Showing first ${HARD_ROW_LIMIT} rows`;
|
||||
}
|
||||
return t`Showing ${formatRowCount(result.row_count)}`;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import type { PaginationRequest, PaginationResponse } from "./pagination";
|
||||
import type { Parameter } from "./parameters";
|
||||
import type { DatasetQuery, FieldReference, PublicDatasetQuery } from "./query";
|
||||
import type { CollectionEssentials } from "./search";
|
||||
import type { Table, TableId } from "./table";
|
||||
import type { ConcreteTableId, Table, TableId } from "./table";
|
||||
import type { UserInfo } from "./user";
|
||||
import type { CardDisplayType, VisualizationDisplay } from "./visualization";
|
||||
import type { SmartScalarComparison } from "./visualization-settings";
|
||||
@@ -66,6 +66,8 @@ export interface Card<Q extends DatasetQuery = DatasetQuery>
|
||||
cache_ttl: number | null;
|
||||
based_on_upload?: TableId | null; // table id of upload table, if any
|
||||
|
||||
table_id?: ConcreteTableId | null; // table id for editable table
|
||||
|
||||
archived: boolean;
|
||||
|
||||
creator?: CreatorInfo;
|
||||
@@ -236,6 +238,7 @@ export type VisualizationSettings = {
|
||||
|
||||
// Table
|
||||
"table.columns"?: TableColumnOrderSetting[];
|
||||
|
||||
// Keys here can be modern (returned by `getColumnKey`) or legacy (`getLegacyColumnKey`).
|
||||
// Use `getColumnSettings` which checks for both keys.
|
||||
column_settings?: Record<string, ColumnSettings>;
|
||||
|
||||
@@ -12,15 +12,13 @@ import type {
|
||||
ParameterId,
|
||||
ParameterTarget,
|
||||
Table,
|
||||
TableColumnOrderSetting,
|
||||
UserId,
|
||||
VirtualCardDisplay,
|
||||
WritebackActionId,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import type {
|
||||
ActionDisplayType,
|
||||
WritebackAction,
|
||||
WritebackActionId,
|
||||
} from "./actions";
|
||||
import type { ActionDisplayType, WritebackAction } from "./actions";
|
||||
import type { Card, CardId, VisualizationSettings } from "./card";
|
||||
import type { Dataset } from "./dataset";
|
||||
import type { ModerationReview } from "./moderation";
|
||||
@@ -101,10 +99,20 @@ export type DashboardCardLayoutAttrs = {
|
||||
size_y: number;
|
||||
};
|
||||
|
||||
export type EditableTableRowActionDisplaySetting = {
|
||||
id: WritebackActionId | "row/create" | "row/delete";
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type DashCardVisualizationSettings = {
|
||||
[key: string]: unknown;
|
||||
virtual_card?: VirtualCard;
|
||||
iframe?: string;
|
||||
|
||||
// "table-editable" specific settings
|
||||
"table.columns"?: TableColumnOrderSetting[];
|
||||
"table.editableColumns"?: string[]; // list of column names
|
||||
"editableTable.enabledActions"?: EditableTableRowActionDisplaySetting[];
|
||||
};
|
||||
|
||||
export type BaseDashboardCard = DashboardCardLayoutAttrs & {
|
||||
@@ -117,6 +125,7 @@ export type BaseDashboardCard = DashboardCardLayoutAttrs & {
|
||||
entity_id: BaseEntityId;
|
||||
visualization_settings?: DashCardVisualizationSettings;
|
||||
justAdded?: boolean;
|
||||
isAdded?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
@@ -121,6 +121,15 @@ export interface Field {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FieldWithMetadata extends Field {
|
||||
database_default?: string;
|
||||
database_indexed: boolean;
|
||||
database_is_auto_increment: boolean;
|
||||
database_is_generated: boolean;
|
||||
database_is_nullable: boolean;
|
||||
database_required: boolean;
|
||||
}
|
||||
|
||||
export interface FieldFormattingSettings {
|
||||
currency?: string;
|
||||
}
|
||||
@@ -155,7 +164,8 @@ export interface GetFieldValuesResponse {
|
||||
export interface SearchFieldValuesRequest {
|
||||
fieldId: FieldId;
|
||||
searchFieldId: FieldId;
|
||||
value: string;
|
||||
// If value is not provided, the endpoint will return all values with limit
|
||||
value?: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import type {
|
||||
Notification,
|
||||
AlertNotification,
|
||||
NotificationCronSubscription,
|
||||
NotificationHandlerEmail,
|
||||
NotificationHandlerSlack,
|
||||
NotificationRecipientUser,
|
||||
TableNotification,
|
||||
} from "metabase-types/api";
|
||||
import { createMockUserInfo } from "metabase-types/api/mocks/user";
|
||||
|
||||
export const createMockNotification = (
|
||||
opts?: Partial<Notification>,
|
||||
): Notification => ({
|
||||
export const createMockAlertNotification = (
|
||||
opts?: Partial<AlertNotification>,
|
||||
): AlertNotification => ({
|
||||
payload_id: 7,
|
||||
payload: {
|
||||
id: 7,
|
||||
@@ -98,9 +99,29 @@ export const createMockNotificationCronSubscription = (
|
||||
id: 10,
|
||||
notification_id: 10,
|
||||
type: "notification-subscription/cron",
|
||||
event_name: null,
|
||||
created_at: "2025-01-07T18:40:47.245205+03:00",
|
||||
cron_schedule: "0 0 9 * * ?",
|
||||
ui_display_type: "cron/builder",
|
||||
...opts,
|
||||
});
|
||||
|
||||
export const createMockTableNotification = (
|
||||
opts?: Partial<TableNotification>,
|
||||
): TableNotification => ({
|
||||
id: 1,
|
||||
payload_id: null,
|
||||
payload_type: "notification/system-event",
|
||||
payload: {
|
||||
event_name: "event/action.success",
|
||||
table_id: 42,
|
||||
action: "bulk/create",
|
||||
},
|
||||
creator: createMockUserInfo(),
|
||||
creator_id: 3,
|
||||
handlers: [createMockNotificationHandlerEmail()],
|
||||
created_at: "2025-01-07T18:40:47.245205+03:00",
|
||||
updated_at: "2025-01-07T18:40:47.245205+03:00",
|
||||
active: true,
|
||||
condition: ["=", ["value"], true],
|
||||
...opts,
|
||||
});
|
||||
|
||||
@@ -130,6 +130,7 @@ export const createMockTokenFeatures = (
|
||||
query_reference_validation: false,
|
||||
serialization: false,
|
||||
cache_preemptive: false,
|
||||
table_data_editing: false,
|
||||
database_routing: false,
|
||||
...opts,
|
||||
});
|
||||
|
||||
@@ -80,6 +80,7 @@ export type EmailChannelSpec = ChannelSpec & {
|
||||
recipients: ChannelSpecRecipients;
|
||||
};
|
||||
|
||||
export type NotificationChannelKey = "email" | "slack" | "http";
|
||||
export interface ChannelApiResponse {
|
||||
channels: {
|
||||
email?: EmailChannelSpec;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Card, CardId } from "metabase-types/api/card";
|
||||
import type { Channel } from "metabase-types/api/notification-channels";
|
||||
import type { PaginationRequest } from "metabase-types/api/pagination";
|
||||
import type { Table, TableId } from "metabase-types/api/table";
|
||||
import type { UserId, UserInfo } from "metabase-types/api/user";
|
||||
|
||||
export type NotificationId = number;
|
||||
@@ -10,6 +11,14 @@ export type NotificationCardSendCondition =
|
||||
| "goal_below"
|
||||
| "has_result";
|
||||
|
||||
export type SystemEvent = "event/action.success";
|
||||
|
||||
export type ActionType = "bulk/create" | "bulk/update" | "bulk/delete";
|
||||
|
||||
type NotificationPayloadType =
|
||||
| "notification/card"
|
||||
| "notification/system-event";
|
||||
|
||||
//#region Payload union type
|
||||
type NotificationCardPayload = {
|
||||
payload_type: "notification/card";
|
||||
@@ -26,7 +35,16 @@ type NotificationCardPayload = {
|
||||
payload_id?: number;
|
||||
};
|
||||
|
||||
type NotificationPayload = NotificationCardPayload; // will be populated with more variants later on
|
||||
type NotificationSystemEventPayload = {
|
||||
payload_type: "notification/system-event";
|
||||
payload: {
|
||||
event_name: SystemEvent;
|
||||
table_id: TableId;
|
||||
action: ActionType;
|
||||
table?: Table; // hydrated on the BE
|
||||
};
|
||||
payload_id: null;
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
@@ -60,12 +78,24 @@ export type NotificationRecipient =
|
||||
| NotificationRecipientUser
|
||||
| NotificationRecipientRawValue;
|
||||
|
||||
export type TemplateDetails = {
|
||||
type: string;
|
||||
subject?: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type ChannelTemplate = {
|
||||
name: string;
|
||||
channel_type: string;
|
||||
details: TemplateDetails;
|
||||
};
|
||||
|
||||
type NotificationHandlerBase = {
|
||||
notification_id?: NotificationId;
|
||||
template_id?: number | null;
|
||||
channel_id?: number | null;
|
||||
channel?: Channel | null;
|
||||
template?: unknown | null; // TODO: hydrated template
|
||||
template?: ChannelTemplate | null; // TODO: hydrated template
|
||||
active?: boolean;
|
||||
|
||||
id?: number;
|
||||
@@ -105,9 +135,8 @@ export type NotificationHandler =
|
||||
export type ScheduleDisplayType = "cron/builder" | "cron/raw" | null;
|
||||
export type NotificationCronSubscription = {
|
||||
type: "notification-subscription/cron";
|
||||
event_name: null;
|
||||
cron_schedule: string;
|
||||
ui_display_type: ScheduleDisplayType;
|
||||
ui_display_type?: ScheduleDisplayType | null;
|
||||
|
||||
// only for existing notifications
|
||||
id?: number;
|
||||
@@ -116,6 +145,8 @@ export type NotificationCronSubscription = {
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type NotificationSubscription = NotificationCronSubscription;
|
||||
|
||||
//#endregion
|
||||
|
||||
export interface ListNotificationsRequest extends PaginationRequest {
|
||||
@@ -125,6 +156,8 @@ export interface ListNotificationsRequest extends PaginationRequest {
|
||||
creator_or_recipient_id?: UserId;
|
||||
card_id?: CardId;
|
||||
permission_group_id?: number;
|
||||
table_id?: TableId;
|
||||
payload_type?: NotificationPayloadType;
|
||||
}
|
||||
|
||||
export type CreateAlertNotificationRequest = NotificationCardPayload & {
|
||||
@@ -132,7 +165,14 @@ export type CreateAlertNotificationRequest = NotificationCardPayload & {
|
||||
subscriptions: NotificationCronSubscription[];
|
||||
};
|
||||
|
||||
export type CreateNotificationRequest = CreateAlertNotificationRequest; // will be populated with more variants later on
|
||||
export type CreateTableNotificationRequest = NotificationSystemEventPayload & {
|
||||
handlers: NotificationHandler[];
|
||||
condition: Condition;
|
||||
};
|
||||
|
||||
export type CreateNotificationRequest =
|
||||
| CreateAlertNotificationRequest
|
||||
| CreateTableNotificationRequest;
|
||||
|
||||
export type UpdateAlertNotificationRequest = NotificationCardPayload & {
|
||||
id: NotificationId;
|
||||
@@ -141,18 +181,58 @@ export type UpdateAlertNotificationRequest = NotificationCardPayload & {
|
||||
subscriptions: NotificationCronSubscription[];
|
||||
};
|
||||
|
||||
export type UpdateNotificationRequest = UpdateAlertNotificationRequest; // will be populated with more variants later on
|
||||
|
||||
export type Notification = NotificationPayload & {
|
||||
export type UpdateTableNotificationRequest = NotificationSystemEventPayload & {
|
||||
id: NotificationId;
|
||||
active: boolean;
|
||||
|
||||
handlers: NotificationHandler[];
|
||||
subscriptions: NotificationCronSubscription[];
|
||||
condition: Condition;
|
||||
};
|
||||
|
||||
export type UpdateNotificationRequest =
|
||||
| UpdateAlertNotificationRequest
|
||||
| UpdateTableNotificationRequest;
|
||||
|
||||
export type GetNotificationPayloadExampleRequest = {
|
||||
payload_type: NotificationPayloadType;
|
||||
payload: {
|
||||
event_name: SystemEvent;
|
||||
action: ActionType;
|
||||
};
|
||||
creator_id: UserId;
|
||||
};
|
||||
|
||||
export type GetNotificationPayloadExampleResponse = {
|
||||
payload: any;
|
||||
schema: any;
|
||||
};
|
||||
|
||||
type BaseNotification = {
|
||||
id: NotificationId;
|
||||
active: boolean;
|
||||
creator_id: UserId;
|
||||
creator: UserInfo;
|
||||
handlers: NotificationHandler[];
|
||||
|
||||
updated_at?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type AlertNotification = BaseNotification &
|
||||
NotificationCardPayload & { subscriptions: NotificationCronSubscription[] };
|
||||
|
||||
export type TableNotification = BaseNotification &
|
||||
NotificationSystemEventPayload & {
|
||||
condition: Condition;
|
||||
};
|
||||
|
||||
export type Notification = AlertNotification | TableNotification;
|
||||
|
||||
// Initial schema for conditional expression.
|
||||
// Will be updated later.
|
||||
type Operator = "=" | ">" | "<" | ">=" | "<=" | "!=" | "and" | "or";
|
||||
type Path = Array<string>;
|
||||
type Value = string | number | boolean;
|
||||
|
||||
type Expression = [Operator, Path | Expression, Value | Expression];
|
||||
|
||||
type Condition = Expression;
|
||||
|
||||
@@ -206,6 +206,7 @@ export const tokenFeatures = [
|
||||
"cache_granular_controls",
|
||||
"disable_password_login",
|
||||
"content_verification",
|
||||
// "data_editing", // TODO[WRK]: enable this check after this feature token is added on the BE
|
||||
"embedding",
|
||||
"embedding_sdk",
|
||||
"hosting",
|
||||
@@ -228,6 +229,7 @@ export const tokenFeatures = [
|
||||
"collection_cleanup",
|
||||
"query_reference_validation",
|
||||
"cache_preemptive",
|
||||
"table_data_editing",
|
||||
"database_routing",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DatasetData } from "metabase-types/api/dataset";
|
||||
|
||||
import type { Card, CardType } from "./card";
|
||||
import type { Database, DatabaseId, InitialSyncStatus } from "./database";
|
||||
import type { Field, FieldDimensionOption, FieldId } from "./field";
|
||||
@@ -129,3 +131,28 @@ export interface DeleteUploadTableRequest {
|
||||
tableId: TableId;
|
||||
"archive-cards"?: boolean;
|
||||
}
|
||||
|
||||
export interface GetTableDataRequest {
|
||||
tableId: TableId;
|
||||
}
|
||||
|
||||
export type TableData = {
|
||||
data: DatasetData;
|
||||
database_id: DatabaseId;
|
||||
table_id: TableId;
|
||||
row_count: number;
|
||||
running_time: number;
|
||||
error?:
|
||||
| string
|
||||
| {
|
||||
status: number; // HTTP status code
|
||||
data?: string;
|
||||
};
|
||||
error_type?: string;
|
||||
error_is_curated?: boolean;
|
||||
status?: string;
|
||||
/** In milliseconds */
|
||||
average_execution_time?: number;
|
||||
/** A date in ISO 8601 format */
|
||||
started_at?: string;
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const isVirtualCardDisplayType = (
|
||||
|
||||
const cardDisplayTypes = [
|
||||
"table",
|
||||
"table-editable",
|
||||
"bar",
|
||||
"line",
|
||||
"pie",
|
||||
|
||||
@@ -18,7 +18,9 @@ export type DashboardSidebarName =
|
||||
| "editParameter"
|
||||
| "settings"
|
||||
| "sharing"
|
||||
| "info";
|
||||
| "info"
|
||||
| "addEditableTable"
|
||||
| "configureEditableTable";
|
||||
|
||||
interface BaseSidebarState {
|
||||
name?: DashboardSidebarName;
|
||||
@@ -99,6 +101,7 @@ export interface DashboardState {
|
||||
|
||||
dashcards: Record<DashCardId, StoreDashcard>;
|
||||
dashcardData: DashCardDataMap;
|
||||
editingDashcardDataOverride: DashCardDataMap;
|
||||
|
||||
parameterValues: Record<ParameterId, ParameterValueOrArray>;
|
||||
draftParameterValues: Record<ParameterId, ParameterValueOrArray | null>;
|
||||
@@ -107,6 +110,7 @@ export interface DashboardState {
|
||||
loadingControls: DashboardLoadingControls;
|
||||
|
||||
editingDashboard: Dashboard | null;
|
||||
|
||||
isAddParameterPopoverOpen: boolean;
|
||||
isNavigatingBackToDashboard: boolean;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export const createMockDashboardState = (
|
||||
dashboards: {},
|
||||
dashcards: {},
|
||||
dashcardData: {},
|
||||
editingDashcardDataOverride: {},
|
||||
parameterValues: {},
|
||||
draftParameterValues: {},
|
||||
loadingDashCards: {
|
||||
|
||||
@@ -4,6 +4,7 @@ const PREFIX = `/account/notifications`;
|
||||
|
||||
const TYPE_MAP = {
|
||||
"question-notification": "alert",
|
||||
"table-notification": "alert",
|
||||
pulse: "pulse",
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
NotificationMessage,
|
||||
} from "metabase/account/notifications/components/NotificationCard/DashboardNotificationCard.styled";
|
||||
import { formatCreatorMessage } from "metabase/account/notifications/components/NotificationCard/utils";
|
||||
import type { QuestionNotificationListItem } from "metabase/account/notifications/types";
|
||||
import type {
|
||||
QuestionNotificationListItem,
|
||||
TableNotificationListItem,
|
||||
} from "metabase/account/notifications/types";
|
||||
import { isTableNotification } from "metabase/api/notification";
|
||||
import Link from "metabase/core/components/Link/Link";
|
||||
import {
|
||||
canArchive,
|
||||
@@ -17,36 +21,38 @@ import {
|
||||
formatTitle,
|
||||
getNotificationEnabledChannelsMap,
|
||||
} from "metabase/lib/notifications";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { Group, Icon } from "metabase/ui";
|
||||
import type { User } from "metabase-types/api";
|
||||
|
||||
type NotificationCardProps = {
|
||||
listItem: QuestionNotificationListItem;
|
||||
type NotificationCardProps<
|
||||
T extends QuestionNotificationListItem | TableNotificationListItem,
|
||||
> = {
|
||||
listItem: T;
|
||||
user: User;
|
||||
onUnsubscribe: (listItem: QuestionNotificationListItem) => void;
|
||||
onArchive: (listItem: QuestionNotificationListItem) => void;
|
||||
onUnsubscribe: (listItem: T) => void;
|
||||
onArchive: (listItem: T) => void;
|
||||
isEditable: boolean;
|
||||
entityLink: string;
|
||||
};
|
||||
|
||||
export const NotificationCard = ({
|
||||
export const NotificationCard = <
|
||||
T extends QuestionNotificationListItem | TableNotificationListItem,
|
||||
>({
|
||||
listItem,
|
||||
user,
|
||||
isEditable,
|
||||
onUnsubscribe,
|
||||
onArchive,
|
||||
}: NotificationCardProps): JSX.Element => {
|
||||
entityLink,
|
||||
}: NotificationCardProps<T>): JSX.Element => {
|
||||
const { item } = listItem;
|
||||
const hasArchive = canArchive(listItem.item, user);
|
||||
|
||||
const entityLink = Urls.question({
|
||||
id: item.payload.card_id,
|
||||
card_id: item.payload.card_id,
|
||||
});
|
||||
const hasArchive = canArchive(item, user);
|
||||
|
||||
const enabledChannelsMap = getNotificationEnabledChannelsMap(item);
|
||||
|
||||
const subscription = item.subscriptions[0];
|
||||
// Handle different notification types
|
||||
const isTable = isTableNotification(item);
|
||||
const subscription = isTable ? null : item.subscriptions?.[0];
|
||||
|
||||
const onUnsubscribeClick = useCallback(() => {
|
||||
onUnsubscribe(listItem);
|
||||
@@ -73,10 +79,11 @@ export const NotificationCard = ({
|
||||
<Icon name="webhook" size={16} />
|
||||
)}
|
||||
<Group gap="0.25rem" align="center" c="text-medium">
|
||||
{subscription && (
|
||||
<span>{formatNotificationSchedule(subscription)}</span>
|
||||
)}
|
||||
{" · "}
|
||||
{subscription &&
|
||||
subscription.type === "notification-subscription/cron" && (
|
||||
<span>{formatNotificationSchedule(subscription)}</span>
|
||||
)}
|
||||
{!isTable && " · "}
|
||||
{<span>{formatCreatorMessage(item, user.id)}</span>}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -1,26 +1,63 @@
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
|
||||
import { renderWithTheme } from "__support__/ui";
|
||||
import type { QuestionNotificationListItem } from "metabase/account/notifications/types";
|
||||
import type { Notification } from "metabase-types/api";
|
||||
import type { ActionType, AlertNotification } from "metabase-types/api";
|
||||
import {
|
||||
createMockNotification,
|
||||
createMockAlertNotification,
|
||||
createMockNotificationCronSubscription,
|
||||
createMockNotificationHandlerEmail,
|
||||
createMockNotificationHandlerSlack,
|
||||
createMockNotificationRecipientUser,
|
||||
createMockTable,
|
||||
createMockUser,
|
||||
} from "metabase-types/api/mocks";
|
||||
|
||||
import type {
|
||||
QuestionNotificationListItem,
|
||||
TableNotificationListItem,
|
||||
} from "../../types";
|
||||
|
||||
import { NotificationCard } from "./NotificationCard";
|
||||
|
||||
const getQuestionAlertItem = (
|
||||
opts?: Partial<Notification>,
|
||||
opts?: Partial<AlertNotification>,
|
||||
): QuestionNotificationListItem => ({
|
||||
item: createMockNotification(opts),
|
||||
item: createMockAlertNotification(opts),
|
||||
type: "question-notification",
|
||||
});
|
||||
|
||||
const getTableNotificationItem = (
|
||||
action: ActionType,
|
||||
tableName = "Sample Table",
|
||||
): TableNotificationListItem => {
|
||||
const mockTable = createMockTable({
|
||||
id: 42,
|
||||
display_name: tableName,
|
||||
});
|
||||
|
||||
return {
|
||||
item: {
|
||||
id: 123,
|
||||
active: true,
|
||||
creator_id: 1,
|
||||
creator: createMockUser(),
|
||||
handlers: [createMockNotificationHandlerEmail()],
|
||||
created_at: "2025-01-07T12:00:00Z",
|
||||
updated_at: "2025-01-07T12:00:00Z",
|
||||
payload_type: "notification/system-event",
|
||||
payload: {
|
||||
event_name: "event/action.success",
|
||||
action: action,
|
||||
table_id: mockTable.id,
|
||||
table: mockTable,
|
||||
},
|
||||
payload_id: null,
|
||||
condition: ["=", ["field", "id"], 1],
|
||||
} as any, // Cast to any to allow adding the table property
|
||||
type: "table-notification",
|
||||
};
|
||||
};
|
||||
|
||||
describe("NotificationCard", () => {
|
||||
it("should render a question alert", () => {
|
||||
const alert = getQuestionAlertItem();
|
||||
@@ -33,6 +70,7 @@ describe("NotificationCard", () => {
|
||||
isEditable
|
||||
onArchive={jest.fn()}
|
||||
onUnsubscribe={jest.fn()}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -56,6 +94,7 @@ describe("NotificationCard", () => {
|
||||
isEditable
|
||||
onArchive={jest.fn()}
|
||||
onUnsubscribe={jest.fn()}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -80,6 +119,7 @@ describe("NotificationCard", () => {
|
||||
isEditable
|
||||
onArchive={jest.fn()}
|
||||
onUnsubscribe={jest.fn()}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -103,6 +143,7 @@ describe("NotificationCard", () => {
|
||||
isEditable
|
||||
onArchive={jest.fn()}
|
||||
onUnsubscribe={jest.fn()}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -124,6 +165,7 @@ describe("NotificationCard", () => {
|
||||
isEditable
|
||||
onArchive={jest.fn()}
|
||||
onUnsubscribe={jest.fn()}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -158,6 +200,7 @@ describe("NotificationCard", () => {
|
||||
isEditable
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -166,6 +209,88 @@ describe("NotificationCard", () => {
|
||||
expect(onArchive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should render a table notification with 'bulk created' event", () => {
|
||||
const tableNotification = getTableNotificationItem("bulk/create");
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithTheme(
|
||||
<NotificationCard
|
||||
listItem={tableNotification}
|
||||
user={user}
|
||||
isEditable
|
||||
onArchive={jest.fn()}
|
||||
onUnsubscribe={jest.fn()}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Sample Table table - Row created"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("mail icon")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Created by you on January 7, 2025"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a table notification with 'bulk updated' event", () => {
|
||||
const tableNotification = getTableNotificationItem("bulk/update");
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithTheme(
|
||||
<NotificationCard
|
||||
listItem={tableNotification}
|
||||
user={user}
|
||||
isEditable
|
||||
onArchive={jest.fn()}
|
||||
onUnsubscribe={jest.fn()}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Sample Table table - Row updated"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a table notification with 'bulk deleted' event", () => {
|
||||
const tableNotification = getTableNotificationItem("bulk/delete");
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithTheme(
|
||||
<NotificationCard
|
||||
listItem={tableNotification}
|
||||
user={user}
|
||||
isEditable
|
||||
onArchive={jest.fn()}
|
||||
onUnsubscribe={jest.fn()}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Sample Table table - Row deleted"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a table notification with custom table name", () => {
|
||||
const tableNotification = getTableNotificationItem("bulk/create", "Orders");
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithTheme(
|
||||
<NotificationCard
|
||||
listItem={tableNotification}
|
||||
user={user}
|
||||
isEditable
|
||||
onArchive={jest.fn()}
|
||||
onUnsubscribe={jest.fn()}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Orders table - Row created")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should unsubscribe when user is the creator and subscribed with another user", () => {
|
||||
const creator = createMockUser({ id: 1 });
|
||||
const recipient = createMockUser({ id: 2 });
|
||||
@@ -196,6 +321,7 @@ describe("NotificationCard", () => {
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
isEditable
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -217,6 +343,7 @@ describe("NotificationCard", () => {
|
||||
isEditable={false}
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -236,6 +363,7 @@ describe("NotificationCard", () => {
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
isEditable
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -269,6 +397,7 @@ describe("NotificationCard", () => {
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
isEditable
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { t } from "ttag";
|
||||
|
||||
import type { NotificationListItem } from "metabase/account/notifications/types";
|
||||
import { TextButton } from "metabase/components/Button.styled";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import type { User } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
@@ -49,27 +50,53 @@ export const NotificationList = ({
|
||||
{t`Not seeing one here?`}
|
||||
</TextButton>
|
||||
</NotificationHeader>
|
||||
{listItems.map((listItem) =>
|
||||
listItem.type === "pulse" ? (
|
||||
<DashboardNotificationCard
|
||||
key={`${listItem.type}-${listItem.item.id}`}
|
||||
listItem={listItem}
|
||||
user={user}
|
||||
isEditable={canManageSubscriptions}
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
/>
|
||||
) : (
|
||||
<NotificationCard
|
||||
key={`${listItem.type}-${listItem.item.id}`}
|
||||
listItem={listItem}
|
||||
user={user}
|
||||
isEditable={canManageSubscriptions}
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{listItems.map((listItem) => {
|
||||
switch (listItem.type) {
|
||||
case "pulse":
|
||||
return (
|
||||
<DashboardNotificationCard
|
||||
key={`${listItem.type}-${listItem.item.id}`}
|
||||
listItem={listItem}
|
||||
user={user}
|
||||
isEditable={canManageSubscriptions}
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
/>
|
||||
);
|
||||
case "table-notification": {
|
||||
const payload = listItem.item.payload;
|
||||
return (
|
||||
<NotificationCard
|
||||
key={`${listItem.type}-${listItem.item.id}`}
|
||||
listItem={listItem}
|
||||
user={user}
|
||||
isEditable={canManageSubscriptions}
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
entityLink={Urls.tableView(
|
||||
payload.table!.db_id,
|
||||
payload.table_id,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "question-notification":
|
||||
return (
|
||||
<NotificationCard
|
||||
key={`${listItem.type}-${listItem.item.id}`}
|
||||
listItem={listItem}
|
||||
user={user}
|
||||
isEditable={canManageSubscriptions}
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
entityLink={Urls.question({
|
||||
id: listItem.item.payload.card_id,
|
||||
card_id: listItem.item.payload.card_id,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "metabase/api";
|
||||
import { LoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { DeleteAlertConfirmModal } from "metabase/notifications/modals/DeleteAlertConfirmModal";
|
||||
import { DeleteConfirmModal } from "metabase/notifications/modals/shared/DeleteConfirmModal";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
import type { Notification } from "metabase-types/api";
|
||||
|
||||
@@ -65,7 +65,7 @@ export const DeleteAlertModal = ({
|
||||
<LoadingAndErrorWrapper loading={isLoading} error={error}>
|
||||
{() =>
|
||||
notification ? (
|
||||
<DeleteAlertConfirmModal
|
||||
<DeleteConfirmModal
|
||||
title={
|
||||
hasUnsubscribed
|
||||
? t`You’re unsubscribed. Delete this alert as well?`
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { JSX, ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { NotificationListItem } from "metabase/account/notifications/types";
|
||||
import { skipToken, useListNotificationsQuery } from "metabase/api";
|
||||
import {
|
||||
isTableNotification,
|
||||
skipToken,
|
||||
useListNotificationsQuery,
|
||||
} from "metabase/api";
|
||||
import Pulses from "metabase/entities/pulses";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { parseTimestamp } from "metabase/lib/time-dayjs";
|
||||
@@ -33,7 +37,7 @@ const NotificationsAppInner = ({
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { data: questionNotifications = [] } = useListNotificationsQuery(
|
||||
const { data: configuredNotifications = [] } = useListNotificationsQuery(
|
||||
user
|
||||
? {
|
||||
creator_or_recipient_id: user.id,
|
||||
@@ -44,10 +48,18 @@ const NotificationsAppInner = ({
|
||||
|
||||
const items = useMemo(() => {
|
||||
const combinedItems: NotificationListItem[] = [
|
||||
...questionNotifications.map((alert) => ({
|
||||
item: alert,
|
||||
type: "question-notification" as const,
|
||||
})),
|
||||
...configuredNotifications.map((alert) => {
|
||||
if (isTableNotification(alert)) {
|
||||
return {
|
||||
item: alert,
|
||||
type: "table-notification" as const,
|
||||
};
|
||||
}
|
||||
return {
|
||||
item: alert,
|
||||
type: "question-notification" as const,
|
||||
};
|
||||
}),
|
||||
...pulses.map((pulse) => ({
|
||||
item: pulse,
|
||||
type: "pulse" as const,
|
||||
@@ -59,7 +71,7 @@ const NotificationsAppInner = ({
|
||||
parseTimestamp(b.item.created_at).unix() -
|
||||
parseTimestamp(a.item.created_at).unix(),
|
||||
);
|
||||
}, [pulses, questionNotifications]);
|
||||
}, [pulses, configuredNotifications]);
|
||||
|
||||
const onHelp = () => dispatch(navigateToHelp());
|
||||
const onUnsubscribe = ({ item, type }: NotificationListItem) =>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "metabase/api";
|
||||
import { LoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { UnsubscribeConfirmModal } from "metabase/notifications/modals/UnsubscribeConfirmModal";
|
||||
import { UnsubscribeConfirmModal } from "metabase/notifications/modals/shared/UnsubscribeConfirmModal";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
import { getUser } from "metabase/selectors/user";
|
||||
import type { Notification, User } from "metabase-types/api";
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Alert, Notification } from "metabase-types/api";
|
||||
import type {
|
||||
Alert,
|
||||
AlertNotification,
|
||||
TableNotification,
|
||||
} from "metabase-types/api";
|
||||
|
||||
export type DashboardAlertListItem = {
|
||||
item: Alert;
|
||||
@@ -6,10 +10,16 @@ export type DashboardAlertListItem = {
|
||||
};
|
||||
|
||||
export type QuestionNotificationListItem = {
|
||||
item: Notification;
|
||||
item: AlertNotification;
|
||||
type: "question-notification";
|
||||
};
|
||||
|
||||
export type TableNotificationListItem = {
|
||||
item: TableNotification;
|
||||
type: "table-notification";
|
||||
};
|
||||
|
||||
export type NotificationListItem =
|
||||
| DashboardAlertListItem
|
||||
| QuestionNotificationListItem;
|
||||
| QuestionNotificationListItem
|
||||
| TableNotificationListItem;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { hasFeature } from "metabase/admin/databases/utils";
|
||||
import Toggle from "metabase/core/components/Toggle";
|
||||
import { getResponseErrorMessage } from "metabase/lib/errors";
|
||||
import { PLUGIN_DATA_EDITING } from "metabase/plugins";
|
||||
import { Box, Flex } from "metabase/ui";
|
||||
import type { Database, DatabaseData, DatabaseId } from "metabase-types/api";
|
||||
|
||||
import { Description, Error, Label } from "./DatabaseFeatureComponents";
|
||||
import { DatabaseInfoSection } from "./DatabaseInfoSection";
|
||||
|
||||
export function DatabaseTableEditingSection({
|
||||
database,
|
||||
updateDatabase,
|
||||
}: {
|
||||
database: Database;
|
||||
updateDatabase: (
|
||||
database: { id: DatabaseId } & Partial<DatabaseData>,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const isEditingDatabase = !!database.id;
|
||||
const showTableEditingSection =
|
||||
isEditingDatabase &&
|
||||
hasFeature(database, "actions") &&
|
||||
PLUGIN_DATA_EDITING.isEnabled();
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleToggle = async (enabled: boolean) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
await updateDatabase({
|
||||
id: database.id,
|
||||
settings: { "database-enable-table-editing": enabled },
|
||||
});
|
||||
} catch (err) {
|
||||
setError(getResponseErrorMessage(err) || t`An error occurred`);
|
||||
}
|
||||
};
|
||||
|
||||
if (!showTableEditingSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DatabaseInfoSection
|
||||
name={t`Editable table data`}
|
||||
description={t`Allow the data within tables of this database to be edited by Admin users.`}
|
||||
data-testid="database-table-editing-section"
|
||||
>
|
||||
<Flex align="center" justify="space-between" mb="xs">
|
||||
<Label htmlFor="table-editing-toggle">{t`Editable tables`}</Label>
|
||||
<Toggle
|
||||
id="table-editing-toggle"
|
||||
value={PLUGIN_DATA_EDITING.isDatabaseTableEditingEnabled(database)}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
</Flex>
|
||||
<Box maw="22.5rem">
|
||||
{error ? <Error>{error}</Error> : null}
|
||||
<Description>
|
||||
{t`Your database connection will need Write permissions.`}
|
||||
</Description>
|
||||
</Box>
|
||||
</DatabaseInfoSection>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
import { DatabaseConnectionInfoSection } from "../components/DatabaseConnectionInfoSection";
|
||||
import { DatabaseDangerZoneSection } from "../components/DatabaseDangerZoneSection";
|
||||
import { DatabaseModelFeaturesSection } from "../components/DatabaseModelFeaturesSection";
|
||||
import { DatabaseTableEditingSection } from "../components/DatabaseTableEditingSection";
|
||||
import { ExistingDatabaseHeader } from "../components/ExistingDatabaseHeader";
|
||||
import { NewDatabasePermissionsModal } from "../components/NewDatabasePermissionsModal";
|
||||
import { deleteDatabase, updateDatabase } from "../database";
|
||||
@@ -116,6 +117,11 @@ function DatabaseEditAppInner({
|
||||
updateDatabase={updateDatabase}
|
||||
/>
|
||||
|
||||
<DatabaseTableEditingSection
|
||||
database={database}
|
||||
updateDatabase={updateDatabase}
|
||||
/>
|
||||
|
||||
<PLUGIN_DB_ROUTING.DatabaseRoutingSection
|
||||
database={database}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type {
|
||||
AlertNotification,
|
||||
CreateNotificationRequest,
|
||||
GetNotificationPayloadExampleRequest,
|
||||
GetNotificationPayloadExampleResponse,
|
||||
ListNotificationsRequest,
|
||||
Notification,
|
||||
NotificationId,
|
||||
TableNotification,
|
||||
UpdateNotificationRequest,
|
||||
} from "metabase-types/api/notification";
|
||||
|
||||
@@ -87,6 +93,16 @@ export const notificationApi = Api.injectEndpoints({
|
||||
body,
|
||||
}),
|
||||
}),
|
||||
getNotificationPayloadExample: builder.mutation<
|
||||
GetNotificationPayloadExampleResponse,
|
||||
GetNotificationPayloadExampleRequest
|
||||
>({
|
||||
query: (body) => ({
|
||||
method: "POST",
|
||||
url: `/api/notification/payload`,
|
||||
body,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -101,4 +117,37 @@ export const {
|
||||
useUpdateNotificationMutation,
|
||||
useUnsubscribeFromNotificationMutation,
|
||||
useSendUnsavedNotificationMutation,
|
||||
useGetNotificationPayloadExampleMutation,
|
||||
} = notificationApi;
|
||||
|
||||
export const useTableNotificationsQuery = (
|
||||
params: Parameters<typeof useListNotificationsQuery>[0],
|
||||
) => {
|
||||
const { data, ...rest } = useListNotificationsQuery(params);
|
||||
return {
|
||||
data: useMemo(() => data?.filter(isTableNotification), [data]),
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
export const useAlertNotificationsQuery = (
|
||||
params: Parameters<typeof useListNotificationsQuery>[0],
|
||||
) => {
|
||||
const { data, ...rest } = useListNotificationsQuery(params);
|
||||
return {
|
||||
data: useMemo(() => data?.filter(isAlertNotification), [data]),
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
export const isTableNotification = (
|
||||
notification: Notification,
|
||||
): notification is TableNotification => {
|
||||
return notification.payload_type === "notification/system-event";
|
||||
};
|
||||
|
||||
export const isAlertNotification = (
|
||||
notification: Notification,
|
||||
): notification is AlertNotification => {
|
||||
return notification.payload_type === "notification/card";
|
||||
};
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type {
|
||||
Field,
|
||||
FieldWithMetadata,
|
||||
GetTableDataRequest,
|
||||
GetTableQueryMetadataRequest,
|
||||
GetTableRequest,
|
||||
Table,
|
||||
TableData,
|
||||
TableId,
|
||||
TableListQuery,
|
||||
UpdateTableFieldsOrderRequest,
|
||||
@@ -20,6 +23,10 @@ import {
|
||||
tag,
|
||||
} from "./tags";
|
||||
|
||||
interface TableWithFieldMetadata extends Table {
|
||||
fields: FieldWithMetadata[];
|
||||
}
|
||||
|
||||
export const tableApi = Api.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
listTables: builder.query<Table[], TableListQuery | void>({
|
||||
@@ -37,7 +44,10 @@ export const tableApi = Api.injectEndpoints({
|
||||
}),
|
||||
providesTags: (table) => (table ? provideTableTags(table) : []),
|
||||
}),
|
||||
getTableQueryMetadata: builder.query<Table, GetTableQueryMetadataRequest>({
|
||||
getTableQueryMetadata: builder.query<
|
||||
TableWithFieldMetadata,
|
||||
GetTableQueryMetadataRequest
|
||||
>({
|
||||
query: ({ id, ...params }) => ({
|
||||
method: "GET",
|
||||
url: `/api/table/${id}/query_metadata`,
|
||||
@@ -52,6 +62,12 @@ export const tableApi = Api.injectEndpoints({
|
||||
}),
|
||||
providesTags: [listTag("field")],
|
||||
}),
|
||||
getTableData: builder.query<TableData, GetTableDataRequest>({
|
||||
query: ({ tableId }) => ({
|
||||
method: "GET",
|
||||
url: `/api/table/${tableId}/data`,
|
||||
}),
|
||||
}),
|
||||
updateTable: builder.mutation<Table, UpdateTableRequest>({
|
||||
query: ({ id, ...body }) => ({
|
||||
method: "PUT",
|
||||
@@ -115,6 +131,7 @@ export const {
|
||||
useGetTableQuery,
|
||||
useGetTableQueryMetadataQuery,
|
||||
useLazyListTableForeignKeysQuery,
|
||||
useGetTableDataQuery,
|
||||
useUpdateTableMutation,
|
||||
useUpdateTableListMutation,
|
||||
useUpdateTableFieldsOrderMutation,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user