Compare commits
125 Commits
python-run
...
edit-event
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a98aee8fe | ||
|
|
35c28efd42 | ||
|
|
a6468cd87b | ||
|
|
37e635c0e9 | ||
|
|
3606d76448 | ||
|
|
b4c469106b | ||
|
|
12d0769af7 | ||
|
|
00b6c61a2c | ||
|
|
7bf406f7ce | ||
|
|
6bf89dc316 | ||
|
|
7dc39fad70 | ||
|
|
b0189956b2 | ||
|
|
95c3693642 | ||
|
|
349d0503fc | ||
|
|
5eca4d39df | ||
|
|
5b2124da6e | ||
|
|
53873c22b1 | ||
|
|
cc84410ac3 | ||
|
|
3f5333cd22 | ||
|
|
f0e4f71834 | ||
|
|
61288e4919 | ||
|
|
6269563254 | ||
|
|
de6588701b | ||
|
|
b061cc3b9b | ||
|
|
71e72f6645 | ||
|
|
02d6eb0e59 | ||
|
|
60d8da092d | ||
|
|
fc5a81afe5 | ||
|
|
46500e2157 | ||
|
|
71437e758e | ||
|
|
a70646d50d | ||
|
|
a3748a3d2b | ||
|
|
a2ee4eaa22 | ||
|
|
91aa513d43 | ||
|
|
129510e174 | ||
|
|
4f997cf438 | ||
|
|
b618a16569 | ||
|
|
a1b21329ec | ||
|
|
98aaa51b26 | ||
|
|
81dee319cb | ||
|
|
e88dc44592 | ||
|
|
86dffe61d9 | ||
|
|
429ade95bd | ||
|
|
5cf63ef589 | ||
|
|
caf3b31f11 | ||
|
|
c228954ffe | ||
|
|
1f867c6e64 | ||
|
|
a6fb0715e1 | ||
|
|
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 |
@@ -674,7 +674,7 @@
|
||||
|
||||
enterprise/core
|
||||
{:api #{metabase-enterprise.core.init}
|
||||
:uses #{enterprise/enhancements}}
|
||||
:uses #{enterprise/enhancements enterprise/data-editing}}
|
||||
|
||||
enterprise/dashboard-subscription-filters
|
||||
{:api #{}
|
||||
@@ -761,7 +761,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}
|
||||
|
||||
8
deps.edn
8
deps.edn
@@ -42,14 +42,14 @@
|
||||
com.github.albfernandez/juniversalchardet {:mvn/version "2.5.0"}
|
||||
com.github.blagerweij/liquibase-sessionlock {:mvn/version "1.6.9"} ; session lock for liquibase migrations
|
||||
com.github.jknack/handlebars ^:antq/exclude ; 4.4.0 requires java 17+
|
||||
{:mvn/version "4.3.1"} ; templating engine
|
||||
{:mvn/version "4.3.1"} ; templating engine
|
||||
com.github.seancorfield/honeysql {:mvn/version "2.7.1295"} ; Honey SQL 2. SQL generation from Clojure data maps
|
||||
com.github.seancorfield/next.jdbc {:mvn/version "1.3.1002"} ; Talk to JDBC DBs
|
||||
com.github.steffan-westcott/clj-otel-api {:mvn/version "0.2.7"} ; Telemetry library
|
||||
com.github.vertical-blank/sql-formatter {:mvn/version "2.0.5"} ; Java SQL formatting library https://github.com/vertical-blank/sql-formatter
|
||||
com.google.guava/guava {:mvn/version "33.4.5-jre"} ; dep for BigQuery, Spark, and GA. Require here rather than letting different dep versions stomp on each other — see comments on #9697
|
||||
com.h2database/h2 ^:antq/exclude ; https://metaboat.slack.com/archives/CKZEMT1MJ/p1727191010259979
|
||||
{:mvn/version "2.1.214"} ; embedded SQL database
|
||||
{:mvn/version "2.1.214"} ; embedded SQL database
|
||||
com.ibm.icu/icu4j {:mvn/version "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
|
||||
@@ -834,7 +834,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.gsheets.api :as gsheets.api]
|
||||
[metabase-enterprise.llm.api]
|
||||
@@ -64,6 +65,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,226 @@
|
||||
(ns metabase-enterprise.data-editing.api
|
||||
(:require
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.data-editing.data-editing :as data-editing]
|
||||
[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.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.models.field-values :as field-values]
|
||||
[metabase.query-processor :as qp]
|
||||
[metabase.query-processor.store :as qp.store]
|
||||
[metabase.upload :as-alias upload]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :as i18n]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[nano-id.core :as nano-id]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- perform-bulk-action! [action-kw table-id rows]
|
||||
(actions/perform-action! action-kw
|
||||
{:database (api/check-404 (t2/select-one-fn :db_id [:model/Table :db_id] table-id))
|
||||
:table-id table-id
|
||||
:arg rows}))
|
||||
(defmethod events.notification/notification-filter-for-topic :event/action.success
|
||||
[_topic event-info]
|
||||
[:and
|
||||
[:= :table_id (-> event-info :result :table_id)]
|
||||
[:= :action (u/qualified-name (:action event-info))]])
|
||||
|
||||
(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- table-id->pk
|
||||
[table-id]
|
||||
;; TODO: support composite PKs
|
||||
(let [pks (api/check-404 (t2/select :model/Field :table_id table-id :semantic_type :type/PK))]
|
||||
(api/check-500 (= 1 (count pks)))
|
||||
(first pks)))
|
||||
|
||||
(defn- get-row-pk
|
||||
[pk-field row]
|
||||
(get row (keyword (:name pk-field))))
|
||||
|
||||
(defn- query-db-rows
|
||||
[table-id pk-field rows]
|
||||
(let [{:keys [db_id]} (api/check-404 (t2/select-one :model/Table table-id))]
|
||||
(assert pk-field "Table must have a primary key")
|
||||
(assert (every? (partial get-row-pk pk-field) rows) "All rows must have the primary key")
|
||||
(when-let [pk-values (seq (map (partial get-row-pk pk-field) rows))]
|
||||
(qp.store/with-metadata-provider db_id
|
||||
(let [mp (qp.store/metadata-provider)
|
||||
query (-> (lib/query mp (lib.metadata/table mp table-id))
|
||||
(lib/filter (apply lib/in (lib.metadata/field mp (:id pk-field)) pk-values))
|
||||
qp/userland-query-with-default-constraints)]
|
||||
(->> (qp/process-query query)
|
||||
:data
|
||||
qp-result->row-map
|
||||
(m/index-by #(get-row-pk pk-field %))))))))
|
||||
|
||||
(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)))
|
||||
|
||||
(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]]]]
|
||||
(api/check-superuser)
|
||||
(let [rows' (data-editing/apply-coercions table-id rows)
|
||||
res (data-editing/insert! table-id rows')
|
||||
pk-field (table-id->pk 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
|
||||
id->db-row (query-db-rows table-id pk-field (map #(update-keys % keyword) (:created-rows res)))]
|
||||
(invalidate-field-values! table-id rows')
|
||||
{:created-rows (vals id->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]]]]
|
||||
(api/check-superuser)
|
||||
(if (empty? rows)
|
||||
{:updated []}
|
||||
(let [rows' (data-editing/apply-coercions table-id rows)
|
||||
pk-field (table-id->pk table-id)
|
||||
id->db-row (query-db-rows table-id pk-field rows')
|
||||
updated-rows (volatile! [])]
|
||||
(doseq [row rows']
|
||||
(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 (-> (query-db-rows table-id pk-field [row]) vals first)
|
||||
row-before (get id->db-row (get-row-pk pk-field row))]
|
||||
(vswap! updated-rows conj after-row)
|
||||
(when (pos-int? result)
|
||||
(actions/publish-action-success!
|
||||
(nano-id/nano-id)
|
||||
api/*current-user-id*
|
||||
:row/update
|
||||
{:table_id table-id
|
||||
:after after-row
|
||||
:before row-before
|
||||
:raw_update row}))))
|
||||
|
||||
(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]]]]
|
||||
(api/check-superuser)
|
||||
(let [pk-field (table-id->pk table-id)
|
||||
id->db-rows (query-db-rows table-id pk-field rows)
|
||||
res (data-editing/perform-bulk-action! :bulk/delete table-id rows)]
|
||||
(doseq [row rows]
|
||||
(actions/publish-action-success!
|
||||
(nano-id/nano-id)
|
||||
api/*current-user-id*
|
||||
:row/delete
|
||||
{:table_id table-id
|
||||
:deleted_row (get id->db-rows (get-row-pk pk-field row))}))
|
||||
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]]]]]]
|
||||
(api/check-superuser)
|
||||
(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]]
|
||||
(api/check-superuser)
|
||||
(let [_ (api/check-404 (t2/select-one :model/Table table-id))
|
||||
token (u/generate-nano-id)
|
||||
user-id api/*current-user-id*]
|
||||
(t2/insert! :table_webhook_token {:token token, :table_id table-id, :creator_id user-id})
|
||||
{:table_id table-id
|
||||
:token token}))
|
||||
|
||||
(api.macros/defendpoint :delete "/webhook/:token"
|
||||
"Deletes a webhook endpoint token."
|
||||
[{:keys [token]}
|
||||
_
|
||||
_]
|
||||
(api/check-superuser)
|
||||
(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]]]
|
||||
(api/check-superuser)
|
||||
(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,60 @@
|
||||
(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]
|
||||
[nano-id.core :as nano-id]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(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)
|
||||
;; TODO not sure how to do an :in clause with toucan2
|
||||
fields (mapv #(t2/select-one :model/Field :table_id table-id :name %) 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."
|
||||
[table-id rows]
|
||||
(let [res (perform-bulk-action! :bulk/create table-id rows)]
|
||||
(when-some [user-id api/*current-user-id*]
|
||||
(doseq [row (:created-rows res)]
|
||||
(actions/publish-action-success!
|
||||
(nano-id/nano-id)
|
||||
user-id
|
||||
:row/create
|
||||
{:table_id table-id
|
||||
:created_row row})))
|
||||
res))
|
||||
@@ -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 has-undo?
|
||||
"Return whether we have any saved modifications to (un-)revert.
|
||||
NOTE: this does not check whether there is a conflict preventing us from actually performing it."
|
||||
[undo? user-id table-id]
|
||||
(boolean (seq (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.")
|
||||
(binding [api/*current-user-id* (:creator_id hook)]
|
||||
{:created (count (:created-rows (data-editing/insert! (:table_id hook) rows)))})))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/data-editing routes."
|
||||
(api.macros/ns-handler *ns*))
|
||||
@@ -64,6 +64,7 @@
|
||||
"Notification"
|
||||
"NotificationCard"
|
||||
"NotificationSubscription"
|
||||
"NotificationSystemEvent"
|
||||
"NotificationHandler"
|
||||
"NotificationRecipient"
|
||||
"Permissions"
|
||||
@@ -89,6 +90,7 @@
|
||||
"Session"
|
||||
"TablePrivileges"
|
||||
"TaskHistory"
|
||||
"Undo"
|
||||
"User"
|
||||
"UserKeyValue"
|
||||
"UserParameterValue"
|
||||
|
||||
@@ -65,6 +65,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,18 @@
|
||||
(ns metabase-enterprise.data-editing.api-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[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
|
||||
@@ -20,72 +25,448 @@
|
||||
|
||||
(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)))]
|
||||
(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 (= {:updated [{:id 1, :name "Pidgey", :song "Join us now and share the software"}
|
||||
{:id 2, :name "Speacolumn", :song "Hold music"}]}
|
||||
(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"]]
|
||||
(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/with-empty-h2-app-db
|
||||
(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)}
|
||||
_ (t2/update! :model/Database (mt/id) {:settings 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/with-empty-h2-app-db
|
||||
(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))
|
||||
driver :h2
|
||||
db-id (mt/id)
|
||||
editing-enabled (:d flags)
|
||||
superuser (:s flags)
|
||||
settings {:database-enable-table-editing (boolean editing-enabled)}
|
||||
_ (t2/update! :model/Database (mt/id) {:settings settings})
|
||||
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 (partial mapv #(select-keys % [:name :base-type])))))]
|
||||
(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"}]
|
||||
:primary_key ["id"]}
|
||||
;; =>
|
||||
{:status 200
|
||||
:name "a"
|
||||
:fields [{:name "id"
|
||||
: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}]}
|
||||
|
||||
;; 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)))))
|
||||
|
||||
(deftest coercion-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/with-empty-h2-app-db
|
||||
(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})
|
||||
|
||||
lossy?
|
||||
#{:Coercion/UNIXNanoSeconds->DateTime
|
||||
:Coercion/UNIXMicroSeconds->DateTime
|
||||
:Coercion/ISO8601->Date
|
||||
:Coercion/ISO8601->Time}
|
||||
|
||||
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")
|
||||
get-qp-state (fn [] (map #(zipmap [:id :o] %) (table-rows table-id)))
|
||||
get-db-state (fn [] (sql-jdbc/query :h2 (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/with-empty-h2-app-db
|
||||
(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/with-empty-h2-app-db
|
||||
(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/with-empty-h2-app-db
|
||||
(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/with-empty-h2-app-db
|
||||
(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/with-empty-h2-app-db
|
||||
(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,269 @@
|
||||
(ns metabase-enterprise.data-editing.notification-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[malli.core :as mc]
|
||||
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
|
||||
[metabase.events :as events]
|
||||
[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!
|
||||
:row/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• 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 {:type "system_event",
|
||||
:payload {:action :row/create
|
||||
:invocation_id (mt/malli=? :string)
|
||||
:actor_id (mt/user->id :crowberto)
|
||||
:result {:table_id (mt/id :categories)
|
||||
:created_row {"ID" (mt/malli=? int?) "NAME" "New Category"}
|
||||
:table {:name "CATEGORIES"}}
|
||||
:actor {:first_name "Crowberto"
|
||||
:last_name "Corv"
|
||||
:email "crowberto@metabase.com"
|
||||
:common_name "Crowberto Corv"}
|
||||
:event_name :event/action.success
|
||||
:custom {}}}}
|
||||
req)))}))
|
||||
|
||||
(deftest update-row-notification-test
|
||||
(test-row-notification!
|
||||
:row/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 from CATEGORIES*\n*Update:*\n• ID : 1\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 {:type "system_event"
|
||||
:payload {:action :row/update
|
||||
:invocation_id (mt/malli=? :string)
|
||||
:actor_id (mt/user->id :crowberto)
|
||||
:result {:table_id (mt/id :categories)
|
||||
:after {:ID (mt/malli=? int?) :NAME "Updated Category"}
|
||||
:before {:ID (mt/malli=? int?) :NAME "African"}
|
||||
:raw_update {:ID (mt/malli=? int?) :NAME "Updated Category"}
|
||||
:table {:name "CATEGORIES"}}
|
||||
:actor {:first_name "Crowberto"
|
||||
:last_name "Corv"
|
||||
:email "crowberto@metabase.com"
|
||||
:common_name "Crowberto Corv"}
|
||||
:event_name :event/action.success
|
||||
:custom {}}}}
|
||||
req)))}))
|
||||
|
||||
(deftest delete-row-notification-test
|
||||
(test-row-notification!
|
||||
:row/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 from CATEGORIES*\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 {:type "system_event"
|
||||
:payload {:action :row/delete
|
||||
:invocation_id (mt/malli=? :string)
|
||||
:actor_id (mt/user->id :crowberto)
|
||||
:result {:table_id (mt/id :categories)
|
||||
:deleted_row {:ID 1 :NAME "African"}
|
||||
:table {:name "CATEGORIES"}}
|
||||
:actor {:first_name "Crowberto"
|
||||
:last_name "Corv"
|
||||
:email "crowberto@metabase.com"
|
||||
:common_name "Crowberto Corv"}
|
||||
:event_name :event/action.success
|
||||
:custom {}}}}
|
||||
req)))}))
|
||||
|
||||
(deftest filter-notifications-test
|
||||
(testing "getting table notifications will return only notifications of a table and action"
|
||||
(doseq [action [:row/create
|
||||
:row/update
|
||||
:row/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
|
||||
:result {:table_id (mt/id :orders)}})))))))))
|
||||
|
||||
(deftest example-payload-row-create-test
|
||||
(is (=? {:payload_type "notification/system-event"
|
||||
:creator {:first_name "Crowberto"
|
||||
:last_name "Corv"
|
||||
:email "crowberto@metabase.com"
|
||||
:common_name "Crowberto Corv"}
|
||||
:payload {:action "row/create"
|
||||
:invocation_id (mt/malli=? :string)
|
||||
:actor_id (mt/malli=? :int)
|
||||
:actor (mt/malli=? [:map
|
||||
[:first_name :string]
|
||||
[:last_name :string]
|
||||
[:email :string]])
|
||||
:result {:table_id (mt/malli=? :int)
|
||||
:created_row (mt/malli=? [:map-of :keyword :any])}
|
||||
:event_name "event/action.success"}
|
||||
:context (mt/malli=? :map)}
|
||||
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:payload_type :notification/system-event
|
||||
:payload {:event_name :event/action.success
|
||||
:action :row/create}
|
||||
:creator_id (mt/user->id :crowberto)})))))
|
||||
|
||||
(deftest example-payload-row-update-test
|
||||
(is (=? {:payload_type "notification/system-event"
|
||||
:creator {:first_name "Crowberto"
|
||||
:last_name "Corv"
|
||||
:email "crowberto@metabase.com"
|
||||
:common_name "Crowberto Corv"}
|
||||
:payload {:action "row/update"
|
||||
:invocation_id (mt/malli=? :string)
|
||||
:actor_id (mt/malli=? :int)
|
||||
:actor (mt/malli=? [:map
|
||||
[:first_name :string]
|
||||
[:last_name :string]
|
||||
[:email :string]])
|
||||
:result {:table_id (mt/malli=? :int)
|
||||
:raw_update (mt/malli=? [:map-of :keyword :any])
|
||||
:after (mt/malli=? [:map-of :keyword :any])
|
||||
:before (mt/malli=? [:map-of :keyword :any])}
|
||||
:event_name "event/action.success"
|
||||
:custom {}}
|
||||
:context (mt/malli=? :map)}
|
||||
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:payload_type :notification/system-event
|
||||
:payload {:event_name :event/action.success
|
||||
:action :row/update}
|
||||
:creator_id (mt/user->id :crowberto)})))))
|
||||
|
||||
(deftest example-payload-row-delete-test
|
||||
(is (=? {:payload_type "notification/system-event"
|
||||
:creator {:first_name "Crowberto"
|
||||
:last_name "Corv"
|
||||
:email "crowberto@metabase.com"
|
||||
:common_name "Crowberto Corv"}
|
||||
:payload {:action "row/delete"
|
||||
:invocation_id (mt/malli=? :string)
|
||||
:actor_id (mt/malli=? :int)
|
||||
:actor (mt/malli=? [:map
|
||||
[:first_name :string]
|
||||
[:last_name :string]
|
||||
[:email :string]])
|
||||
:result {:table_id (mt/malli=? :int)
|
||||
:deleted_row (mt/malli=? [:map-of :keyword :any])}
|
||||
:event_name "event/action.success"}
|
||||
:context (mt/malli=? :map)}
|
||||
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:payload_type :notification/system-event
|
||||
:payload {:event_name :event/action.success
|
||||
:action :row/delete}
|
||||
:creator_id (mt/user->id :crowberto)})))))
|
||||
@@ -0,0 +1,77 @@
|
||||
(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- create-test-table! [driver db table-name column-map create-table-opts]
|
||||
(let [_ (driver/create-table! driver
|
||||
(mt/id)
|
||||
table-name
|
||||
column-map
|
||||
create-table-opts)
|
||||
table (sync/create-table! db
|
||||
{:name table-name
|
||||
:schema nil
|
||||
:display_name table-name})]
|
||||
(sync/sync-fields-for-table! db table)
|
||||
(:id table)))
|
||||
|
||||
(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 toggle-data-editing-enabled! [on-or-off]
|
||||
(t2/update! :model/Database (mt/id) {:settings {:database-enable-table-editing (boolean on-or-off)}}))
|
||||
|
||||
(defn open-test-table!
|
||||
"Sets up an anonymous table in the appdb. 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 [driver :h2
|
||||
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)
|
||||
db (t2/select-one :model/Database (mt/id))
|
||||
table-name (str "temp_table_" (str/replace (random-uuid) "-" "_"))
|
||||
cleanup #(try (driver/drop-table! driver (mt/id) table-name) (catch Exception _))]
|
||||
(try
|
||||
(let [table-id (create-test-table! driver db table-name column-map create-table-opts)]
|
||||
(reify Closeable
|
||||
IDeref
|
||||
(deref [_] table-id)
|
||||
(close [_] (cleanup))))
|
||||
(catch Exception e
|
||||
(cleanup)
|
||||
(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
|
||||
(t2/update! :model/Database (mt/id) {:settings {:database-enable-table-editing 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/has-undo? true user-id table-id))
|
||||
(is (not (undo/has-undo? 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/has-undo? true user-id table-id))
|
||||
(is (undo/has-undo? 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/has-undo? true user-id table-id))
|
||||
(is (undo/has-undo? 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/has-undo? true user-id table-id)))
|
||||
(is (undo/has-undo? false user-id table-id))
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo
|
||||
#"No previous versions found"
|
||||
(undo/undo! user-id table-id)))
|
||||
|
||||
(is (not (undo/has-undo? true user-id table-id)))
|
||||
(is (undo/has-undo? 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/has-undo? true user-id table-id))
|
||||
(is (undo/has-undo? 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/has-undo? true user-id table-id))
|
||||
(is (undo/has-undo? false user-id table-id))
|
||||
(is (= {table-id [[:delete {:id 1}]]}
|
||||
(undo/redo! user-id table-id)))
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (undo/has-undo? true user-id table-id))
|
||||
(is (not (undo/has-undo? false user-id table-id)))
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo
|
||||
#"No subsequent versions found"
|
||||
(undo/redo! user-id table-id)))
|
||||
|
||||
(is (undo/has-undo? true user-id table-id))
|
||||
(is (not (undo/has-undo? 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/has-undo? true user-2 table-id))
|
||||
(is (not (undo/has-undo? false user-2 table-id)))
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo
|
||||
#"Blocked by other changes"
|
||||
(undo/undo! user-2 table-id)))
|
||||
|
||||
(is (undo/has-undo? true user-1 table-id))
|
||||
(is (not (undo/has-undo? 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/has-undo? true user-1 table-id))
|
||||
(is (undo/has-undo? false user-1 table-id))
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo
|
||||
#"Blocked by other changes"
|
||||
(undo/undo! user-1 table-id)))
|
||||
|
||||
(is (undo/has-undo? true user-1 table-id))
|
||||
(is (undo/has-undo? false user-1 table-id))
|
||||
(is (undo/has-undo? true user-2 table-id))
|
||||
(is (not (undo/has-undo? 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/has-undo? true user-1 table-id))
|
||||
(is (undo/has-undo? 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/has-undo? true user-1 table-id))
|
||||
(is (undo/has-undo? 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/has-undo? true user-1 table-id)))
|
||||
(is (undo/has-undo? false user-1 table-id))
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo
|
||||
#"No previous versions found"
|
||||
(undo/undo! user-1 table-id)))
|
||||
|
||||
(is (not (undo/has-undo? true user-1 table-id)))
|
||||
(is (undo/has-undo? 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/has-undo? true user-1 table-id))
|
||||
(is (undo/has-undo? 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/has-undo? true user-1 table-id))
|
||||
(is (undo/has-undo? 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/has-undo? true user-1 table-id))
|
||||
(is (undo/has-undo? false user-1 table-id))
|
||||
(is (= {table-id [[:delete {:id 2}]]}
|
||||
(undo/redo! user-1 table-id)))
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (undo/has-undo? true user-1 table-id))
|
||||
(is (not (undo/has-undo? false user-1 table-id)))
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo
|
||||
#"No subsequent versions found"
|
||||
(undo/redo! user-1 table-id)))
|
||||
|
||||
(is (undo/has-undo? true user-2 table-id))
|
||||
(is (not (undo/has-undo? false user-2 table-id)))
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo
|
||||
#"No subsequent versions found"
|
||||
(undo/redo! user-2 table-id)))
|
||||
|
||||
(is (undo/has-undo? true user-1 table-id))
|
||||
(is (not (undo/has-undo? false user-1 table-id)))))))))
|
||||
@@ -0,0 +1 @@
|
||||
(ns metabase-enterprise.data-editing.util)
|
||||
@@ -55,6 +55,7 @@
|
||||
:model/Notification
|
||||
:model/NotificationCard
|
||||
:model/NotificationSubscription
|
||||
:model/NotificationSystemEvent
|
||||
:model/NotificationHandler
|
||||
:model/NotificationRecipient
|
||||
:model/ParameterCard
|
||||
@@ -81,6 +82,7 @@
|
||||
:model/TablePrivileges
|
||||
:model/TaskHistory
|
||||
:model/TimelineEvent
|
||||
:model/Undo
|
||||
:model/User
|
||||
:model/UserParameterValue
|
||||
:model/UserKeyValue
|
||||
|
||||
@@ -2,5 +2,6 @@ export * from "./audit-info";
|
||||
export * from "./billing-info";
|
||||
export * from "./gdrive";
|
||||
export * from "./query-validation";
|
||||
export * from "./table-data-edit";
|
||||
export * from "./scim";
|
||||
export * from "./upload-management";
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { tableApi } from "metabase/api";
|
||||
import type {
|
||||
TableDeleteRowsRequest,
|
||||
TableDeleteRowsResponse,
|
||||
TableInsertRowsRequest,
|
||||
TableInsertRowsResponse,
|
||||
TableUpdateRowsRequest,
|
||||
TableUpdateRowsResponse,
|
||||
} from "metabase-enterprise/data_editing/tables/types";
|
||||
import type { DatasetColumn, RowValue } from "metabase-types/api";
|
||||
|
||||
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 },
|
||||
}),
|
||||
onQueryStarted: async ({ tableId }, { dispatch, queryFulfilled }) => {
|
||||
const { data: response } = await queryFulfilled;
|
||||
|
||||
dispatch(
|
||||
tableApi.util.updateQueryData(
|
||||
"getTableData",
|
||||
{ tableId },
|
||||
({ data }) => {
|
||||
const createdRows = mapRowObjectsToRowValuesArray(
|
||||
response["created-rows"],
|
||||
data.cols,
|
||||
);
|
||||
|
||||
data.rows.push(...createdRows);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
updateTableRows: builder.mutation<
|
||||
TableUpdateRowsResponse,
|
||||
TableUpdateRowsRequest
|
||||
>({
|
||||
query: ({ tableId, rows }) => ({
|
||||
method: "PUT",
|
||||
url: `/api/ee/data-editing/table/${tableId}`,
|
||||
body: { rows },
|
||||
}),
|
||||
onQueryStarted: async (
|
||||
{ primaryKeyColumnName, tableId },
|
||||
{ dispatch, queryFulfilled },
|
||||
) => {
|
||||
const { data: response } = await queryFulfilled;
|
||||
|
||||
dispatch(
|
||||
tableApi.util.updateQueryData(
|
||||
"getTableData",
|
||||
{ tableId },
|
||||
({ data }) => {
|
||||
const updatedRows = mapRowObjectsToRowValuesArray(
|
||||
response.updated,
|
||||
data.cols,
|
||||
);
|
||||
|
||||
const primaryKeyIndex = pkColumnNameToPkColumnIndex(
|
||||
primaryKeyColumnName,
|
||||
data.cols,
|
||||
);
|
||||
|
||||
for (const row of data.rows) {
|
||||
for (const updatedRow of updatedRows) {
|
||||
if (row[primaryKeyIndex] === updatedRow[primaryKeyIndex]) {
|
||||
// Update row values array with updated values
|
||||
for (let i = 0; i < data.cols.length; i++) {
|
||||
row[i] = updatedRow[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
deleteTableRows: builder.mutation<
|
||||
TableDeleteRowsResponse,
|
||||
TableDeleteRowsRequest
|
||||
>({
|
||||
query: ({ tableId, rows }) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/data-editing/table/${tableId}/delete`,
|
||||
body: { rows },
|
||||
}),
|
||||
onQueryStarted: async (
|
||||
{ tableId, primaryKeyColumnName, rows },
|
||||
{ dispatch, queryFulfilled },
|
||||
) => {
|
||||
const { data: response } = await queryFulfilled;
|
||||
|
||||
if (response.success) {
|
||||
dispatch(
|
||||
tableApi.util.updateQueryData(
|
||||
"getTableData",
|
||||
{ tableId },
|
||||
({ data }) => {
|
||||
const deletedKeys = rows.map(
|
||||
(row) => row[primaryKeyColumnName],
|
||||
);
|
||||
const primaryKeyIndex = pkColumnNameToPkColumnIndex(
|
||||
primaryKeyColumnName,
|
||||
data.cols,
|
||||
);
|
||||
|
||||
data.rows = data.rows.filter(
|
||||
(row) => !deletedKeys.includes(row[primaryKeyIndex]),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
function mapRowObjectsToRowValuesArray(
|
||||
rows: Record<string, RowValue>[],
|
||||
columns: DatasetColumn[],
|
||||
) {
|
||||
return rows.map((row) => columns.map((column) => row[column.name]));
|
||||
}
|
||||
|
||||
function pkColumnNameToPkColumnIndex(
|
||||
primaryKeyColumnName: string,
|
||||
columns: DatasetColumn[],
|
||||
) {
|
||||
return columns.findIndex((column) => column.name === primaryKeyColumnName);
|
||||
}
|
||||
|
||||
export const {
|
||||
useInsertTableRowsMutation,
|
||||
useUpdateTableRowsMutation,
|
||||
useDeleteTableRowsMutation,
|
||||
} = 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 { EditTableDataWithUpdate } from "./tables/edit/EditTableDataWithUpdate";
|
||||
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 = EditTableDataWithUpdate;
|
||||
}
|
||||
@@ -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,64 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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-types/api";
|
||||
|
||||
export const EditTableDataBackButton = ({ table }: { table: Table }) => {
|
||||
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,139 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMount } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
useGetDatabaseMetadataQuery,
|
||||
useGetTableDataQuery,
|
||||
useGetTableQuery,
|
||||
} 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 { useTableCRUD } from "./use-table-crud";
|
||||
|
||||
type EditTableDataContainerProps = {
|
||||
params: {
|
||||
dbId: string;
|
||||
tableId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const EditTableDataContainer = ({
|
||||
params: { dbId: dbIdParam, tableId: tableIdParam },
|
||||
}: EditTableDataContainerProps) => {
|
||||
const dbId = parseInt(dbIdParam, 10);
|
||||
const tableId = parseInt(tableIdParam, 10);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { data: database } = useGetDatabaseMetadataQuery({ id: dbId });
|
||||
const { data: table, isLoading: tableIdLoading } = useGetTableQuery({
|
||||
id: tableId,
|
||||
});
|
||||
|
||||
const { data: rawDatasetResult, isLoading } = useGetTableDataQuery({
|
||||
tableId,
|
||||
});
|
||||
|
||||
const datasetData = useMemo(() => {
|
||||
return rawDatasetResult
|
||||
? extractRemappedColumns(rawDatasetResult.data)
|
||||
: undefined;
|
||||
}, [rawDatasetResult]);
|
||||
|
||||
const {
|
||||
isCreateRowModalOpen,
|
||||
expandedRowIndex,
|
||||
isInserting,
|
||||
closeCreateRowModal,
|
||||
tableFieldMetadataMap,
|
||||
|
||||
handleRowCreate,
|
||||
handleCellValueUpdate,
|
||||
handleExpandedRowDelete,
|
||||
handleModalOpenAndExpandedRow,
|
||||
} = useTableCRUD({ tableId, datasetData });
|
||||
|
||||
useMount(() => {
|
||||
dispatch(closeNavbar());
|
||||
});
|
||||
|
||||
if (!database || isLoading || tableIdLoading) {
|
||||
// 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}
|
||||
onCreate={handleModalOpenAndExpandedRow}
|
||||
/>
|
||||
)}
|
||||
{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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
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 } from "metabase/ui";
|
||||
import type {
|
||||
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;
|
||||
};
|
||||
|
||||
export const EditTableDataGrid = ({
|
||||
data,
|
||||
fieldMetadataMap,
|
||||
onCellValueUpdate,
|
||||
onRowExpandClick,
|
||||
columnsConfig,
|
||||
}: 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 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,
|
||||
header: function EditingHeader() {
|
||||
return (
|
||||
<Box className={S.headerCellContainer}>
|
||||
<Ellipsified>{column.display_name}</Ellipsified>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
editingCell: (cellContext) => (
|
||||
<EditingBodyCellWrapper
|
||||
cellContext={cellContext}
|
||||
column={column}
|
||||
field={fieldMetadataMap?.[column.name]}
|
||||
onCellValueUpdate={onCellValueUpdate}
|
||||
onCellEditCancel={onCellEditCancel}
|
||||
/>
|
||||
),
|
||||
getIsCellEditing: (cellId: string) => editingCellId === cellId,
|
||||
};
|
||||
|
||||
return options;
|
||||
});
|
||||
}, [
|
||||
cols,
|
||||
fieldMetadataMap,
|
||||
editingCellId,
|
||||
onCellEditCancel,
|
||||
onCellValueUpdate,
|
||||
]);
|
||||
|
||||
const rowId: RowIdColumnOptions = useMemo(
|
||||
() => ({
|
||||
variant: "expandButton",
|
||||
onRowExpandClick,
|
||||
}),
|
||||
[onRowExpandClick],
|
||||
);
|
||||
|
||||
const tableProps = useDataGridInstance({
|
||||
data: rows,
|
||||
rowId,
|
||||
columnOrder,
|
||||
columnSizingMap,
|
||||
columnsOptions,
|
||||
});
|
||||
|
||||
// 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,40 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { TableNotificationsTrigger } from "metabase/notifications/modals";
|
||||
import { Button, Flex, Group, Icon, Title } from "metabase/ui";
|
||||
import type { Table } from "metabase-types/api";
|
||||
|
||||
import { EditTableDataBackButton } from "./EditTableDataBackButton";
|
||||
|
||||
interface EditTableDataHeaderProps {
|
||||
table: Table;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
export const EditTableDataHeader = ({
|
||||
table,
|
||||
onCreate,
|
||||
}: EditTableDataHeaderProps) => {
|
||||
return (
|
||||
<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>
|
||||
<Button
|
||||
leftSection={<Icon name="add" />}
|
||||
variant="filled"
|
||||
onClick={onCreate}
|
||||
>{t`New record`}</Button>
|
||||
<TableNotificationsTrigger tableId={table.id} />
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import cx from "classnames";
|
||||
|
||||
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 { useTableCRUD } from "./use-table-crud";
|
||||
|
||||
type EditTableDataWithUpdateProps = {
|
||||
tableId: ConcreteTableId;
|
||||
data: DatasetData;
|
||||
className?: string;
|
||||
refetchTableDataQuery: () => void;
|
||||
visualizationSettings?: VisualizationSettings;
|
||||
};
|
||||
|
||||
export const EditTableDataWithUpdate = ({
|
||||
tableId,
|
||||
data,
|
||||
className,
|
||||
refetchTableDataQuery,
|
||||
visualizationSettings,
|
||||
}: EditTableDataWithUpdateProps) => {
|
||||
const {
|
||||
isCreateRowModalOpen,
|
||||
expandedRowIndex,
|
||||
isInserting,
|
||||
closeCreateRowModal,
|
||||
tableFieldMetadataMap,
|
||||
|
||||
handleRowCreate,
|
||||
handleCellValueUpdate,
|
||||
handleExpandedRowDelete,
|
||||
handleModalOpenAndExpandedRow,
|
||||
} = useTableCRUD({ tableId, datasetData: data, refetchTableDataQuery });
|
||||
|
||||
const columnsConfig = useEditableTableColumnConfigFromVisualizationSettings(
|
||||
visualizationSettings,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(S.tableRoot, className)}>
|
||||
<EditTableDataGrid
|
||||
data={data}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
onCellValueUpdate={handleCellValueUpdate}
|
||||
onRowExpandClick={handleModalOpenAndExpandedRow}
|
||||
columnsConfig={columnsConfig}
|
||||
/>
|
||||
<EditingBaseRowModal
|
||||
opened={isCreateRowModalOpen}
|
||||
onClose={closeCreateRowModal}
|
||||
onEdit={handleCellValueUpdate}
|
||||
onRowCreate={handleRowCreate}
|
||||
onRowDelete={handleExpandedRowDelete}
|
||||
datasetColumns={data.cols}
|
||||
currentRowIndex={expandedRowIndex}
|
||||
currentRowData={
|
||||
expandedRowIndex !== undefined
|
||||
? data.rows[expandedRowIndex]
|
||||
: undefined
|
||||
}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
isLoading={isInserting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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,262 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import cx from "classnames";
|
||||
import { useFormik } from "formik";
|
||||
import { Fragment, useCallback, useEffect } 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 { 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>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
type EditingFormValues = Record<string, RowValue>;
|
||||
|
||||
export function EditingBaseRowModal({
|
||||
datasetColumns,
|
||||
onClose,
|
||||
onEdit,
|
||||
onRowCreate,
|
||||
onRowDelete,
|
||||
opened,
|
||||
currentRowIndex,
|
||||
currentRowData,
|
||||
fieldMetadataMap,
|
||||
isLoading,
|
||||
}: 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]);
|
||||
|
||||
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 && (
|
||||
<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 })}
|
||||
>
|
||||
{datasetColumns.map((column, index) => {
|
||||
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={currentRowData ? currentRowData[index] : null}
|
||||
datasetColumn={column}
|
||||
field={field}
|
||||
onSubmitValue={handleValueEdit}
|
||||
onChangeValue={setFieldValue}
|
||||
error={!isEditingMode && !!errors[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;
|
||||
};
|
||||
function ModalEditingInput({
|
||||
isEditingMode,
|
||||
initialValue,
|
||||
datasetColumn,
|
||||
field,
|
||||
onSubmitValue,
|
||||
onChangeValue,
|
||||
error,
|
||||
}: 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { VisualizationSettings } from "metabase-types/api";
|
||||
|
||||
export type EditableTableColumnConfig = {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
editable: boolean;
|
||||
}[];
|
||||
|
||||
export function useEditableTableColumnConfigFromVisualizationSettings(
|
||||
visualizationSettings?: VisualizationSettings,
|
||||
): 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,198 @@
|
||||
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";
|
||||
|
||||
export const useTableCRUD = ({
|
||||
tableId,
|
||||
datasetData,
|
||||
refetchTableDataQuery,
|
||||
}: {
|
||||
tableId: ConcreteTableId;
|
||||
datasetData: DatasetData | null | undefined;
|
||||
refetchTableDataQuery?: () => void;
|
||||
}) => {
|
||||
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],
|
||||
primaryKeyColumnName: pkColumn.name,
|
||||
});
|
||||
|
||||
displayErrorIfExists(response.error);
|
||||
|
||||
refetchTableDataQuery?.();
|
||||
},
|
||||
[
|
||||
datasetData,
|
||||
updateTableRows,
|
||||
tableId,
|
||||
displayErrorIfExists,
|
||||
refetchTableDataQuery,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRowCreate = useCallback(
|
||||
async (data: Record<string, RowValue>) => {
|
||||
const response = await insertTableRows({
|
||||
tableId: tableId,
|
||||
rows: [data],
|
||||
});
|
||||
|
||||
displayErrorIfExists(response.error);
|
||||
if (!response.error) {
|
||||
closeCreateRowModal();
|
||||
} else {
|
||||
refetchTableDataQuery?.();
|
||||
}
|
||||
},
|
||||
[
|
||||
insertTableRows,
|
||||
tableId,
|
||||
displayErrorIfExists,
|
||||
closeCreateRowModal,
|
||||
refetchTableDataQuery,
|
||||
],
|
||||
);
|
||||
|
||||
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 response = await deleteTableRows({
|
||||
rows: [{ [pkColumn.name]: rowPkValue }],
|
||||
tableId: tableId,
|
||||
primaryKeyColumnName: pkColumn.name,
|
||||
});
|
||||
displayErrorIfExists(response.error);
|
||||
|
||||
refetchTableDataQuery?.();
|
||||
},
|
||||
[
|
||||
datasetData,
|
||||
closeCreateRowModal,
|
||||
deleteTableRows,
|
||||
tableId,
|
||||
displayErrorIfExists,
|
||||
refetchTableDataQuery,
|
||||
],
|
||||
);
|
||||
|
||||
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,37 @@
|
||||
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[];
|
||||
primaryKeyColumnName: string;
|
||||
};
|
||||
|
||||
export type TableUpdateRowsResponse = { updated: Record<string, RowValue>[] };
|
||||
|
||||
export type TableDeleteRowsRequest = {
|
||||
tableId: ConcreteTableId;
|
||||
rows: RowCellsWithPkValue[];
|
||||
primaryKeyColumnName: string;
|
||||
};
|
||||
|
||||
export type TableDeleteRowsResponse = { success: boolean };
|
||||
|
||||
export type UpdatedRowCellsHandlerParams = {
|
||||
updatedData: Record<DatasetColumn["name"], RowValue>;
|
||||
rowIndex: number;
|
||||
};
|
||||
@@ -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,143 @@
|
||||
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/TableNotificationsModals/TableNotificationsTrigger";
|
||||
import RunButtonWithTooltip from "metabase/query_builder/components/RunButtonWithTooltip";
|
||||
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,
|
||||
isLoading,
|
||||
refetch: refetchTableDataQuery,
|
||||
} = 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">
|
||||
<RunButtonWithTooltip
|
||||
iconSize={16}
|
||||
onlyIcon
|
||||
medium
|
||||
compact
|
||||
isRunning={isLoading}
|
||||
onRun={refetchTableDataQuery}
|
||||
/>
|
||||
<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";
|
||||
@@ -35,3 +35,4 @@ import "./resource_downloads";
|
||||
import "./user_provisioning";
|
||||
import "./clean_up";
|
||||
import "./troubleshooting";
|
||||
import "./data_editing";
|
||||
|
||||
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,8 @@ export type VisualizationSettings = {
|
||||
|
||||
// Table
|
||||
"table.columns"?: TableColumnOrderSetting[];
|
||||
"table.editableColumns"?: string[]; // list of column names
|
||||
|
||||
// Keys here can be modern (returned by `getColumnKey`) or legacy (`getLegacyColumnKey`).
|
||||
// Use `getColumnSettings` which checks for both keys.
|
||||
column_settings?: Record<string, ColumnSettings>;
|
||||
|
||||
@@ -117,6 +117,7 @@ export type BaseDashboardCard = DashboardCardLayoutAttrs & {
|
||||
entity_id: BaseEntityId;
|
||||
visualization_settings?: DashCardVisualizationSettings;
|
||||
justAdded?: boolean;
|
||||
isAdded?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
@@ -120,6 +120,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;
|
||||
}
|
||||
@@ -154,7 +163,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: "row/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,
|
||||
});
|
||||
|
||||
@@ -131,6 +131,7 @@ export const createMockTokenFeatures = (
|
||||
query_reference_validation: false,
|
||||
serialization: false,
|
||||
cache_preemptive: false,
|
||||
table_data_editing: 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 = "row/create" | "row/update" | "row/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,44 @@ 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;
|
||||
|
||||
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;
|
||||
|
||||
@@ -204,6 +204,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",
|
||||
@@ -226,6 +227,7 @@ export const tokenFeatures = [
|
||||
"collection_cleanup",
|
||||
"query_reference_validation",
|
||||
"cache_preemptive",
|
||||
"table_data_editing",
|
||||
] as const;
|
||||
|
||||
export type TokenFeature = (typeof tokenFeatures)[number];
|
||||
|
||||
@@ -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 'row created' event", () => {
|
||||
const tableNotification = getTableNotificationItem("row/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 'row updated' event", () => {
|
||||
const tableNotification = getTableNotificationItem("row/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 'row deleted' event", () => {
|
||||
const tableNotification = getTableNotificationItem("row/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("row/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;
|
||||
|
||||
@@ -5,7 +5,7 @@ import Toggle from "metabase/core/components/Toggle";
|
||||
import { getResponseErrorMessage } from "metabase/lib/errors";
|
||||
import { Box, Flex } from "metabase/ui";
|
||||
|
||||
import { Description, Error, Label } from "../ModelFeatureToggles";
|
||||
import { Description, Error, Label } from "../ModelFeatureToggles.styled";
|
||||
|
||||
export interface ModelActionsSectionProps {
|
||||
hasModelActionsEnabled: boolean;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Box, Flex } from "metabase/ui";
|
||||
import type Database from "metabase-lib/v1/metadata/Database";
|
||||
import { getModelCacheSchemaName } from "metabase-lib/v1/metadata/utils/models";
|
||||
|
||||
import { Description, Error, Label } from "../ModelFeatureToggles";
|
||||
import { Description, Error, Label } from "../ModelFeatureToggles.styled";
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
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 from "metabase-lib/v1/metadata/Database";
|
||||
import type { DatabaseData, DatabaseId } from "metabase-types/api";
|
||||
|
||||
import { DatabaseInfoSection } from "./DatabaseInfoSection";
|
||||
import {
|
||||
Description,
|
||||
Error,
|
||||
Label,
|
||||
} from "./DatabaseModelFeaturesSection/ModelFeatureToggles.styled";
|
||||
|
||||
export function DatabaseTableEditingSection({
|
||||
database,
|
||||
updateDatabase,
|
||||
}: {
|
||||
database: Database;
|
||||
updateDatabase: (
|
||||
database: { id: DatabaseId } & Partial<DatabaseData>,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const isEditingDatabase = !!database.id;
|
||||
const showTableEditingSection =
|
||||
isEditingDatabase &&
|
||||
database.supportsActions() &&
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { DatabaseConnectionInfoSection } from "../components/DatabaseConnectionI
|
||||
import { DatabaseDangerZoneSection } from "../components/DatabaseDangerZoneSection";
|
||||
import type { DatabaseEditErrorType } from "../components/DatabaseEditConnectionForm";
|
||||
import { DatabaseModelFeaturesSection } from "../components/DatabaseModelFeaturesSection";
|
||||
import { DatabaseTableEditingSection } from "../components/DatabaseTableEditingSection";
|
||||
import { ExistingDatabaseHeader } from "../components/ExistingDatabaseHeader";
|
||||
import { NewDatabasePermissionsModal } from "../components/NewDatabasePermissionsModal";
|
||||
import {
|
||||
@@ -167,6 +168,11 @@ function DatabaseEditAppInner({
|
||||
updateDatabase={updateDatabase}
|
||||
/>
|
||||
|
||||
<DatabaseTableEditingSection
|
||||
database={database}
|
||||
updateDatabase={updateDatabase}
|
||||
/>
|
||||
|
||||
<DatabaseDangerZoneSection
|
||||
isAdmin={isAdmin}
|
||||
database={database}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type {
|
||||
AlertNotification,
|
||||
CreateNotificationRequest,
|
||||
ListNotificationsRequest,
|
||||
Notification,
|
||||
NotificationId,
|
||||
TableNotification,
|
||||
UpdateNotificationRequest,
|
||||
} from "metabase-types/api/notification";
|
||||
|
||||
@@ -102,3 +106,35 @@ export const {
|
||||
useUnsubscribeFromNotificationMutation,
|
||||
useSendUnsavedNotificationMutation,
|
||||
} = 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,
|
||||
|
||||
@@ -4,6 +4,7 @@ import Tables from "metabase/entities/tables";
|
||||
import { connect } from "metabase/lib/redux";
|
||||
import { isSyncInProgress } from "metabase/lib/syncing";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { PLUGIN_DATA_EDITING } from "metabase/plugins";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import { getSetting } from "metabase/selectors/settings";
|
||||
import { SAVED_QUESTIONS_VIRTUAL_DB_ID } from "metabase-lib/v1/metadata/utils/saved-questions";
|
||||
@@ -35,7 +36,11 @@ const getSchemaName = (props) => {
|
||||
const getReloadInterval = (_state, _props, tables = []) =>
|
||||
tables.some((t) => isSyncInProgress(t)) ? RELOAD_INTERVAL : 0;
|
||||
|
||||
export const getTableUrl = (table, metadata) => {
|
||||
export const getTableUrl = ({ table, metadata, database }) => {
|
||||
if (database && PLUGIN_DATA_EDITING.isDatabaseTableEditingEnabled(database)) {
|
||||
return Urls.tableView(database.id, table.id);
|
||||
}
|
||||
|
||||
const metadataTable = metadata?.table(table.id);
|
||||
return ML_Urls.getUrl(metadataTable?.newQuestion(), { clean: false });
|
||||
};
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import { useDatabaseCrumb } from "./useDatabaseCrumb";
|
||||
|
||||
const propTypes = {
|
||||
database: PropTypes.object,
|
||||
tables: PropTypes.array.isRequired,
|
||||
getTableUrl: PropTypes.func.isRequired,
|
||||
metadata: PropTypes.object,
|
||||
@@ -33,7 +32,6 @@ const propTypes = {
|
||||
};
|
||||
|
||||
export const TableBrowser = ({
|
||||
database,
|
||||
tables,
|
||||
getTableUrl,
|
||||
metadata,
|
||||
@@ -43,6 +41,7 @@ export const TableBrowser = ({
|
||||
showSchemaInHeader = true,
|
||||
}) => {
|
||||
const databaseCrumb = useDatabaseCrumb(dbId);
|
||||
const { database } = databaseCrumb;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -61,12 +60,13 @@ export const TableBrowser = ({
|
||||
<TableCard hoverable={!isSyncInProgress(table)}>
|
||||
<TableLink
|
||||
to={
|
||||
!isSyncInProgress(table) ? getTableUrl(table, metadata) : ""
|
||||
!isSyncInProgress(table)
|
||||
? getTableUrl({ table, metadata, database })
|
||||
: ""
|
||||
}
|
||||
onClick={() => trackTableClick(table.id)}
|
||||
>
|
||||
<TableBrowserItem
|
||||
database={database}
|
||||
table={table}
|
||||
dbId={dbId}
|
||||
xraysEnabled={xraysEnabled}
|
||||
@@ -89,7 +89,7 @@ const itemPropTypes = {
|
||||
xraysEnabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
const TableBrowserItem = ({ database, table, dbId, xraysEnabled }) => {
|
||||
const TableBrowserItem = ({ table, dbId, xraysEnabled }) => {
|
||||
const isVirtual = isVirtualCardId(table.id);
|
||||
const isLoading = isSyncInProgress(table);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export const useDatabaseCrumb = (id: DatabaseId) => {
|
||||
if (id === SAVED_QUESTIONS_VIRTUAL_DB_ID) {
|
||||
return {
|
||||
title: t`Saved Questions`,
|
||||
database: null,
|
||||
to: Urls.browseDatabase({ id: SAVED_QUESTIONS_VIRTUAL_DB_ID }),
|
||||
};
|
||||
}
|
||||
@@ -34,12 +35,14 @@ export const useDatabaseCrumb = (id: DatabaseId) => {
|
||||
if (!database) {
|
||||
return {
|
||||
title: null,
|
||||
database: null,
|
||||
to: Urls.browseDatabase({ id }),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: database.name,
|
||||
database,
|
||||
to: Urls.browseDatabase(database),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
* - On older IEs it's set to `flex-basis: 0` which means that the initial main size of flex item is zero.
|
||||
* It is also notable that `flex-basis: 0%` doesn't work correctly on IE.
|
||||
*
|
||||
* As a solution, `flex-basis-auto` should always be used in conjunction with `flex-full` when it is
|
||||
* As a solution, `flexBasisAuto` should always be used in conjunction with `flex-full` when it is
|
||||
* a desired behavior that the element grows with its contents.
|
||||
*/
|
||||
.flexBasisAuto {
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
getPositionForNewDashCard,
|
||||
} from "metabase/lib/dashboard_grid";
|
||||
import { createThunkAction } from "metabase/lib/redux";
|
||||
import { loadMetadataForCard } from "metabase/questions/actions";
|
||||
import {
|
||||
loadMetadataForCard,
|
||||
loadMetadataForTable,
|
||||
} from "metabase/questions/actions";
|
||||
import { getDefaultSize } from "metabase/visualizations";
|
||||
import type {
|
||||
Card,
|
||||
@@ -16,7 +19,9 @@ import type {
|
||||
DashboardCard,
|
||||
DashboardId,
|
||||
DashboardTabId,
|
||||
TableId,
|
||||
VirtualCard,
|
||||
VirtualCardDisplay,
|
||||
} from "metabase-types/api";
|
||||
import type { Dispatch, GetState } from "metabase-types/store";
|
||||
|
||||
@@ -189,6 +194,33 @@ export const addMarkdownDashCardToDashboard =
|
||||
};
|
||||
dispatch(addDashCardToDashboard({ dashId, tabId, dashcardOverrides }));
|
||||
};
|
||||
|
||||
export type AddEditableTableDashCardToDashboardOpts = NewDashCardOpts & {
|
||||
tableId: TableId;
|
||||
};
|
||||
|
||||
export const addEditableTableDashCardToDashboard =
|
||||
({ dashId, tabId, tableId }: AddEditableTableDashCardToDashboardOpts) =>
|
||||
(dispatch: Dispatch) => {
|
||||
// this should work as a virtual card until dashboard is saved, then it becomes a normal card. Hence the typecast
|
||||
const card = createVirtualCard("table-editable" as VirtualCardDisplay);
|
||||
|
||||
dispatch(
|
||||
addDashCardToDashboard({
|
||||
dashId,
|
||||
tabId,
|
||||
dashcardOverrides: {
|
||||
card: card as VirtualCard,
|
||||
visualization_settings: {
|
||||
table_id: tableId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch(loadMetadataForTable(tableId));
|
||||
};
|
||||
|
||||
export const addIFrameDashCardToDashboard =
|
||||
({ dashId, tabId }: NewDashCardOpts) =>
|
||||
(dispatch: Dispatch) => {
|
||||
@@ -228,7 +260,7 @@ export const replaceCard =
|
||||
.getObject(getState(), { entityId: nextCardId })
|
||||
.card();
|
||||
|
||||
await dispatch(
|
||||
dispatch(
|
||||
setDashCardAttributes({
|
||||
id: dashcardId,
|
||||
attributes: {
|
||||
|
||||
@@ -552,6 +552,15 @@ export const clearCardData = createAction(
|
||||
(cardId, dashcardId) => ({ payload: { cardId, dashcardId } }),
|
||||
);
|
||||
|
||||
export const SET_EDITING_DASHCARD_DATA =
|
||||
"metabase/dashboard/SET_EDITING_DASHCARD_DATA";
|
||||
export const setEditingDashcardData = createAction(
|
||||
SET_EDITING_DASHCARD_DATA,
|
||||
(dashcardId, cardId, dataset) => ({
|
||||
payload: { dashcardId, cardId, dataset },
|
||||
}),
|
||||
);
|
||||
|
||||
function getDatasetQueryParams(datasetQuery: Partial<DatasetQuery> = {}) {
|
||||
const parameters =
|
||||
datasetQuery?.parameters
|
||||
|
||||
@@ -31,6 +31,16 @@ export const showClickBehaviorSidebar =
|
||||
}
|
||||
};
|
||||
|
||||
export const showConfigureEditableTableSidebar =
|
||||
(dashcardId: DashCardId) => (dispatch: Dispatch) => {
|
||||
dispatch(
|
||||
setSidebar({
|
||||
name: SIDEBAR_NAME.configureEditableTable,
|
||||
props: { dashcardId },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const toggleSidebar =
|
||||
(name: DashboardSidebarName) => (dispatch: Dispatch, getState: GetState) => {
|
||||
const currentSidebarName = getSidebar(getState()).name;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Sidebar } from "metabase/dashboard/components/Sidebar";
|
||||
import { Box } from "metabase/ui";
|
||||
import type { TableId } from "metabase-types/api";
|
||||
|
||||
import { TableList } from "./TablePicker/TableList";
|
||||
|
||||
interface AddTableSidebarProps {
|
||||
onSelect: (cardId: TableId) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AddTableSidebar(props: AddTableSidebarProps) {
|
||||
return (
|
||||
<Sidebar data-testid="add-table-sidebar">
|
||||
<Box p="md">
|
||||
<TableList onSelect={props.onSelect} />
|
||||
</Box>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { PointerSensor, useSensor } from "@dnd-kit/core";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import {
|
||||
type DragEndEvent,
|
||||
Sortable,
|
||||
SortableList,
|
||||
} from "metabase/core/components/Sortable";
|
||||
import { onUpdateDashCardVisualizationSettings } from "metabase/dashboard/actions";
|
||||
import { FIELD_SEMANTIC_TYPES_MAP } from "metabase/lib/core";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { ChartSettingActionIcon } from "metabase/visualizations/components/settings/ChartSettingActionIcon";
|
||||
import { ColumnItem } from "metabase/visualizations/components/settings/ColumnItem";
|
||||
import { mergeSettings } from "metabase/visualizations/lib/settings/typed-utils";
|
||||
import type {
|
||||
DashboardCard,
|
||||
Field,
|
||||
TableColumnOrderSetting,
|
||||
} from "metabase-types/api";
|
||||
|
||||
type ConfigureEditableTableColumnsProps = {
|
||||
dashcard: DashboardCard;
|
||||
};
|
||||
|
||||
export function ConfigureEditableTableColumns({
|
||||
dashcard,
|
||||
}: ConfigureEditableTableColumnsProps) {
|
||||
const dispatch = useDispatch();
|
||||
const pointerSensor = useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 15 },
|
||||
});
|
||||
|
||||
const items = useEditableTableColumnSettingItems(dashcard);
|
||||
const isDragDisabled = items.length < 1;
|
||||
|
||||
const handleSortEnd = useCallback(
|
||||
({ id, newIndex }: DragEndEvent) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === id);
|
||||
const newArray = arrayMove(items, oldIndex, newIndex);
|
||||
|
||||
dispatch(
|
||||
onUpdateDashCardVisualizationSettings(dashcard.id, {
|
||||
"table.columns": newArray.map((it) => ({
|
||||
name: it.name,
|
||||
enabled: it.enabled,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch, dashcard.id, items],
|
||||
);
|
||||
|
||||
const handleShowHide = useCallback(
|
||||
(name: string, enabled: boolean) => {
|
||||
dispatch(
|
||||
onUpdateDashCardVisualizationSettings(dashcard.id, {
|
||||
"table.columns": items.map((it) => ({
|
||||
name: it.name,
|
||||
enabled: it.name === name ? enabled : it.enabled,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dashcard.id, dispatch, items],
|
||||
);
|
||||
|
||||
const handleUpdateEditable = useCallback(
|
||||
(name: string, editable: boolean) => {
|
||||
dispatch(
|
||||
onUpdateDashCardVisualizationSettings(dashcard.id, {
|
||||
"table.editableColumns": items
|
||||
.filter(
|
||||
editable
|
||||
? (it) => it.editable || it.name === name
|
||||
: (it) => it.editable && it.name !== name,
|
||||
)
|
||||
.map((it) => it.name),
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dashcard.id, dispatch, items],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({
|
||||
item,
|
||||
id,
|
||||
}: {
|
||||
item: EditableTableColumnSettingItem;
|
||||
id: string | number;
|
||||
}) => (
|
||||
<Sortable
|
||||
id={id}
|
||||
key={`sortable-${id}`}
|
||||
disabled={isDragDisabled}
|
||||
draggingStyle={{ opacity: 0.5 }}
|
||||
>
|
||||
<ColumnItem
|
||||
title={item.title}
|
||||
onRemove={
|
||||
item.enabled ? () => handleShowHide(item.name, false) : undefined
|
||||
}
|
||||
onEnable={
|
||||
!item.enabled ? () => handleShowHide(item.name, true) : undefined
|
||||
}
|
||||
draggable={!isDragDisabled}
|
||||
icon={item.icon}
|
||||
additionalActions={
|
||||
<ChartSettingActionIcon
|
||||
icon={item.editable ? "pencil" : "line_style_solid"}
|
||||
onClick={() => handleUpdateEditable(item.name, !item.editable)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Sortable>
|
||||
),
|
||||
[isDragDisabled, handleShowHide, handleUpdateEditable],
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableList
|
||||
getId={(item) => item.id}
|
||||
items={items}
|
||||
renderItem={renderItem}
|
||||
onSortEnd={handleSortEnd}
|
||||
sensors={[pointerSensor]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type EditableTableColumnSettingItem = ReturnType<
|
||||
typeof useEditableTableColumnSettingItems
|
||||
>[number];
|
||||
|
||||
function useEditableTableColumnSettingItems(dashcard: DashboardCard) {
|
||||
return useMemo(() => {
|
||||
const fields = dashcard.card.result_metadata ?? [];
|
||||
const settings = mergeSettings(
|
||||
dashcard.card.visualization_settings,
|
||||
dashcard.visualization_settings,
|
||||
);
|
||||
|
||||
const columnDisplaySettings = settings["table.columns"] ?? [];
|
||||
const columnEditableSettings = settings["table.editableColumns"] ?? [];
|
||||
|
||||
const nameToDisplaySettingMap: Record<string, TableColumnOrderSetting> = {};
|
||||
const nameToFieldMap: Record<string, Field> = {};
|
||||
const nameOrder: string[] = [];
|
||||
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const field = fields[i];
|
||||
nameToFieldMap[field.name] = field;
|
||||
}
|
||||
|
||||
for (let i = 0; i < columnDisplaySettings.length; i++) {
|
||||
const setting = columnDisplaySettings[i];
|
||||
|
||||
nameToDisplaySettingMap[setting.name] = setting;
|
||||
|
||||
// If column is deleted and settings are preserved, we need to exclude it
|
||||
// from the list of items to be displayed in the sidebar
|
||||
if (nameToFieldMap[setting.name]) {
|
||||
nameOrder.push(setting.name);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const field = fields[i];
|
||||
|
||||
// If the field is not in the settings, we need to add it to the list of items
|
||||
if (!nameToDisplaySettingMap[field.name]) {
|
||||
nameOrder.push(field.name);
|
||||
}
|
||||
}
|
||||
|
||||
// By default all columns are editable if no settings are provided
|
||||
// If settings are provided, we preserve them even if a new column is added
|
||||
const editableColumnSet = new Set(
|
||||
columnEditableSettings.length > 0
|
||||
? columnEditableSettings
|
||||
: fields.map((it) => it.name),
|
||||
);
|
||||
|
||||
return nameOrder.map((name) => {
|
||||
const setting = nameToDisplaySettingMap[name];
|
||||
const field = nameToFieldMap[name];
|
||||
|
||||
return {
|
||||
id: field.name,
|
||||
name: field.name,
|
||||
title: field.display_name,
|
||||
enabled: setting?.enabled ?? true,
|
||||
editable: editableColumnSet.has(field.name),
|
||||
icon: field.semantic_type
|
||||
? FIELD_SEMANTIC_TYPES_MAP[field.semantic_type].icon
|
||||
: "string",
|
||||
};
|
||||
});
|
||||
}, [
|
||||
dashcard.card.result_metadata,
|
||||
dashcard.visualization_settings,
|
||||
dashcard.card.visualization_settings,
|
||||
]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user