Compare commits
104 Commits
python-sec
...
example-no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86d25e6123 | ||
|
|
44cd641234 | ||
|
|
c1116d59a4 | ||
|
|
e547e0ddd5 | ||
|
|
b9757115be | ||
|
|
d1e95f17cb | ||
|
|
c26108c8cd | ||
|
|
a031787ddf | ||
|
|
02d6eb0e59 | ||
|
|
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 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
|
||||
;;
|
||||
|
||||
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)))
|
||||
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",
|
||||
@@ -63,12 +63,14 @@ const SUM_OF_TOTAL_MONTH_EXCLUDE_MAY_AUG = {
|
||||
"2024-08-02",
|
||||
],
|
||||
"source-query": {
|
||||
"source-table": 5,
|
||||
aggregation: [["sum", ["field", 40, { "base-type": "type/Float" }]]],
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [
|
||||
["sum", ["field", ORDERS.TOTAL, { "base-type": "type/Float" }]],
|
||||
],
|
||||
breakout: [
|
||||
[
|
||||
"field",
|
||||
39,
|
||||
ORDERS.CREATED_AT,
|
||||
{ "base-type": "type/DateTime", "temporal-unit": "month" },
|
||||
],
|
||||
],
|
||||
@@ -953,7 +955,7 @@ describe("scenarios > visualizations > line/bar chart > tooltips", () => {
|
||||
breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }]],
|
||||
filter: [
|
||||
"between",
|
||||
["field", 39, { "base-type": "type/DateTime" }],
|
||||
["field", ORDERS.CREATED_AT, { "base-type": "type/DateTime" }],
|
||||
"2024-01-01",
|
||||
"2024-05-30",
|
||||
],
|
||||
@@ -971,7 +973,7 @@ describe("scenarios > visualizations > line/bar chart > tooltips", () => {
|
||||
breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "week" }]],
|
||||
filter: [
|
||||
"between",
|
||||
["field", 39, { "base-type": "type/DateTime" }],
|
||||
["field", ORDERS.CREATED_AT, { "base-type": "type/DateTime" }],
|
||||
"2024-03-01",
|
||||
"2024-03-31",
|
||||
],
|
||||
@@ -989,7 +991,7 @@ describe("scenarios > visualizations > line/bar chart > tooltips", () => {
|
||||
breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "day" }]],
|
||||
filter: [
|
||||
"between",
|
||||
["field", 39, { "base-type": "type/DateTime" }],
|
||||
["field", ORDERS.CREATED_AT, { "base-type": "type/DateTime" }],
|
||||
"2024-03-09",
|
||||
"2024-03-12",
|
||||
],
|
||||
|
||||
@@ -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,209 @@
|
||||
(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.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 %))))))))
|
||||
|
||||
(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)))]
|
||||
{: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}))))
|
||||
{:updated @updated-rows})))
|
||||
|
||||
(api.macros/defendpoint :delete "/table/:table-id"
|
||||
(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 (str (random-uuid))
|
||||
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,58 @@
|
||||
(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]
|
||||
(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)]
|
||||
;; TODO if we are inserting via webhook endpoint, we have no user id - but schema requires it.
|
||||
(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]))
|
||||
@@ -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 [table-id (api/check-404 (t2/select-one-fn :table_id :table_webhook_token :token token))
|
||||
rows (if (map? row-or-rows) [row-or-rows] row-or-rows)]
|
||||
(when (seq rows)
|
||||
(api/check-400 (every? seq rows))
|
||||
(data-editing/insert! table-id rows))
|
||||
{}))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/data-editing routes."
|
||||
(api.macros/ns-handler *ns*))
|
||||
@@ -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,17 @@
|
||||
(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.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 +24,424 @@
|
||||
|
||||
(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"))))))
|
||||
|
||||
(defn- toggle-data-editing-enabled! [on-or-off]
|
||||
(t2/update! :model/Database (mt/id) {:settings {:database-enable-table-editing (boolean on-or-off)}}))
|
||||
|
||||
(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)]
|
||||
(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
|
||||
(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
|
||||
(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)
|
||||
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 200
|
||||
{} 400
|
||||
[] 200
|
||||
[{}] 400))
|
||||
(testing "one row in array"
|
||||
(is (= 200 (status token [{:id 1, :v "a"}])))
|
||||
(is (= [[1 "a"]] (table-rows table-id))))
|
||||
(testing "multiple rows in array"
|
||||
(is (= 200 (status 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
|
||||
(toggle-data-editing-enabled! false)
|
||||
(is (= 400 (status token [{:id 4, :v "d"}])))
|
||||
(toggle-data-editing-enabled! true)
|
||||
(is (= 200 (status token [{:id 4, :v "d"}])))
|
||||
(finally
|
||||
(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)
|
||||
|
||||
@@ -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,216 @@
|
||||
(ns metabase-enterprise.data-editing.notification-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
|
||||
[metabase.events.notification :as events.notification]
|
||||
[metabase.notification.test-util :as notification.tu]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(use-fixtures :each (fn [thunk]
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(thunk))))
|
||||
|
||||
(defn all-handlers
|
||||
[http-channel-id]
|
||||
[{:channel_type :channel/email
|
||||
:recipients [{:type :notification-recipient/user
|
||||
:user_id (mt/user->id :crowberto)}]}
|
||||
{:channel_type :channel/slack
|
||||
:recipients [{:type :notification-recipient/raw-value
|
||||
:details {:value "#test-pulse"}}]}
|
||||
{:channel_type :channel/http
|
||||
:channel_id http-channel-id}])
|
||||
|
||||
(defn test-row-notification!
|
||||
[action request-fn channel-type->assert-fns]
|
||||
(data-editing.tu/with-temp-test-db!
|
||||
(mt/with-temp [:model/Channel chn {:type :channel/http}]
|
||||
(notification.tu/with-system-event-notification!
|
||||
[_notification {:notification-system-event {:event_name :event/action.success
|
||||
:action action
|
||||
:table_id (mt/id :categories)}
|
||||
:handlers (all-handlers (:id chn))}]
|
||||
(notification.tu/with-channel-fixtures [:channel/email :channel/slack]
|
||||
(let [channel-type->captured-message (notification.tu/with-captured-channel-send!
|
||||
(request-fn))]
|
||||
(doseq [[channel-type assert-fn] channel-type->assert-fns]
|
||||
(testing (format "channel-type = %s" channel-type)
|
||||
(assert-fn (get channel-type->captured-message channel-type))))))))))
|
||||
|
||||
(deftest create-row-notification-test
|
||||
(test-row-notification!
|
||||
: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
|
||||
(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
|
||||
(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
|
||||
(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,74 @@
|
||||
(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 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 @@
|
||||
(ns metabase-enterprise.data-editing.util)
|
||||
@@ -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 }); // TODO: consider using just "dbId" to avoid extra data request
|
||||
const { data: table, isLoading: tableIdLoading } = useGetTableQuery({
|
||||
id: tableId,
|
||||
});
|
||||
|
||||
const { data: datasetData, isLoading } = useGetTableDataQuery({
|
||||
tableId,
|
||||
});
|
||||
|
||||
const { cols: remappedCols } = useMemo(() => {
|
||||
return datasetData
|
||||
? extractRemappedColumns(datasetData.data)
|
||||
: { cols: undefined };
|
||||
}, [datasetData]);
|
||||
|
||||
const {
|
||||
isCreateRowModalOpen,
|
||||
expandedRowIndex,
|
||||
isInserting,
|
||||
closeCreateRowModal,
|
||||
tableFieldMetadataMap,
|
||||
|
||||
handleRowCreate,
|
||||
handleCellValueUpdate,
|
||||
handleExpandedRowDelete,
|
||||
handleModalOpenAndExpandedRow,
|
||||
} = useTableCRUD({ tableId, datasetData: datasetData?.data });
|
||||
|
||||
useMount(() => {
|
||||
dispatch(closeNavbar());
|
||||
});
|
||||
|
||||
if (!database || isLoading || tableIdLoading) {
|
||||
// TODO: show loader
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!datasetData || !tableFieldMetadataMap || !remappedCols) {
|
||||
// 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.data}
|
||||
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(datasetData)}
|
||||
</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={remappedCols}
|
||||
currentRowIndex={expandedRowIndex}
|
||||
currentRowData={
|
||||
expandedRowIndex !== undefined
|
||||
? datasetData.data.rows[expandedRowIndex]
|
||||
: undefined
|
||||
}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
isLoading={isInserting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
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 { useTableEditing } from "./use-table-editing";
|
||||
|
||||
type EditTableDataGridProps = {
|
||||
data: DatasetData;
|
||||
fieldMetadataMap: Record<FieldWithMetadata["name"], FieldWithMetadata>;
|
||||
onCellValueUpdate: (params: UpdatedRowCellsHandlerParams) => void;
|
||||
onRowExpandClick: (rowIndex: number) => void;
|
||||
};
|
||||
|
||||
export const EditTableDataGrid = ({
|
||||
data,
|
||||
fieldMetadataMap,
|
||||
onCellValueUpdate,
|
||||
onRowExpandClick,
|
||||
}: EditTableDataGridProps) => {
|
||||
const { cols, rows } = data;
|
||||
|
||||
const { editingCellId, onCellClickToEdit, onCellEditCancel } =
|
||||
useTableEditing();
|
||||
|
||||
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,
|
||||
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,
|
||||
});
|
||||
|
||||
const handleCellClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
{
|
||||
cellId,
|
||||
columnId,
|
||||
}: {
|
||||
cellId: string;
|
||||
columnId: string;
|
||||
},
|
||||
) => {
|
||||
const field = fieldMetadataMap?.[columnId];
|
||||
// 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],
|
||||
);
|
||||
|
||||
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,62 @@
|
||||
import cx from "classnames";
|
||||
|
||||
import type { ConcreteTableId, DatasetData } from "metabase-types/api";
|
||||
|
||||
import S from "./EditTableData.module.css";
|
||||
import { EditTableDataGrid } from "./EditTableDataGrid";
|
||||
import { EditingBaseRowModal } from "./modals/EditingBaseRowModal";
|
||||
import { useTableCRUD } from "./use-table-crud";
|
||||
|
||||
type EditTableDataWithUpdateProps = {
|
||||
tableId: ConcreteTableId;
|
||||
data: DatasetData;
|
||||
className?: string;
|
||||
refetchTableDataQuery: () => void;
|
||||
};
|
||||
|
||||
export const EditTableDataWithUpdate = ({
|
||||
tableId,
|
||||
data,
|
||||
className,
|
||||
refetchTableDataQuery,
|
||||
}: EditTableDataWithUpdateProps) => {
|
||||
const {
|
||||
isCreateRowModalOpen,
|
||||
expandedRowIndex,
|
||||
isInserting,
|
||||
closeCreateRowModal,
|
||||
tableFieldMetadataMap,
|
||||
|
||||
handleRowCreate,
|
||||
handleCellValueUpdate,
|
||||
handleExpandedRowDelete,
|
||||
handleModalOpenAndExpandedRow,
|
||||
} = useTableCRUD({ tableId, datasetData: data, refetchTableDataQuery });
|
||||
|
||||
return (
|
||||
<div className={cx(S.tableRoot, className)}>
|
||||
<EditTableDataGrid
|
||||
data={data}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
onCellValueUpdate={handleCellValueUpdate}
|
||||
onRowExpandClick={handleModalOpenAndExpandedRow}
|
||||
/>
|
||||
<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,176 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { skipToken, useGetFieldValuesQuery } from "metabase/api";
|
||||
import { getFieldOptions } from "metabase/querying/filters/components/FilterValuePicker/utils";
|
||||
import {
|
||||
Box,
|
||||
Combobox,
|
||||
Icon,
|
||||
Input,
|
||||
type SelectOption,
|
||||
TextInput,
|
||||
useCombobox,
|
||||
} from "metabase/ui";
|
||||
|
||||
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 { data: fieldData, isLoading } = useGetFieldValuesQuery(
|
||||
datasetColumn.id ?? skipToken,
|
||||
);
|
||||
|
||||
const [value, setValue] = useState(initialValue?.toString() ?? "");
|
||||
const [search, setSearch] = useState("");
|
||||
const combobox = useCombobox({
|
||||
defaultOpened: autoFocus,
|
||||
onDropdownClose: onCancel,
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
fieldData
|
||||
? getFieldOptions(fieldData.values).filter((item) =>
|
||||
getDropdownLabelText(item)
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase().trim()),
|
||||
)
|
||||
: [],
|
||||
[fieldData, getDropdownLabelText, search],
|
||||
);
|
||||
|
||||
const handleOptionSubmit = useCallback(
|
||||
(value: string) => {
|
||||
setValue(value);
|
||||
onSubmit(value);
|
||||
onChangeValue?.(value);
|
||||
combobox.toggleDropdown();
|
||||
},
|
||||
[onSubmit, setValue, onChangeValue, combobox],
|
||||
);
|
||||
|
||||
const optionValueSelectOptionMap = useMemo(
|
||||
() =>
|
||||
fieldData
|
||||
? getFieldOptions(fieldData.values).reduce(
|
||||
(map, item) => ({
|
||||
...map,
|
||||
[item.value]: item,
|
||||
}),
|
||||
{} as Record<string, SelectOption>,
|
||||
)
|
||||
: null,
|
||||
[fieldData],
|
||||
);
|
||||
|
||||
const inputLabel = useMemo(() => {
|
||||
if (isLoading || !optionValueSelectOptionMap) {
|
||||
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 optionValueSelectOptionMap) {
|
||||
return getSelectedLabelText(optionValueSelectOptionMap[value]);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
isLoading,
|
||||
value,
|
||||
optionValueSelectOptionMap,
|
||||
inputProps?.placeholder,
|
||||
getSelectedLabelText,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
position="bottom-start"
|
||||
onOptionSubmit={handleOptionSubmit}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<Input
|
||||
component="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" />}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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,36 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { SelectOption } from "metabase/ui";
|
||||
|
||||
import { EditingBodyCellBasicInput } from "./EditingBodyCellBasicInput";
|
||||
import { EditingBodyCellCategorySelect } from "./EditingBodyCellCategorySelect";
|
||||
import type { EditingBodyPrimitiveProps } from "./types";
|
||||
|
||||
export const EditingBodyCellFKSelect = (props: EditingBodyPrimitiveProps) => {
|
||||
const { field } = props;
|
||||
|
||||
const shouldDisplayValuesList = field?.has_field_values === "list";
|
||||
|
||||
const getDropdownLabelText = useCallback(
|
||||
(item: SelectOption) => `${item.label} [${item.value}]`,
|
||||
[],
|
||||
);
|
||||
|
||||
const getSelectedLabelText = useCallback(
|
||||
(item: SelectOption) => item.label,
|
||||
[],
|
||||
);
|
||||
|
||||
if (shouldDisplayValuesList) {
|
||||
return (
|
||||
<EditingBodyCellCategorySelect
|
||||
{...props}
|
||||
getDropdownLabelText={getDropdownLabelText}
|
||||
getSelectedLabelText={getSelectedLabelText}
|
||||
withCreateNew={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <EditingBodyCellBasicInput {...props} />;
|
||||
};
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import type {
|
||||
Notification,
|
||||
AlertNotification,
|
||||
NotificationCronSubscription,
|
||||
NotificationHandlerEmail,
|
||||
NotificationHandlerSlack,
|
||||
NotificationRecipientUser,
|
||||
NotificationSystemEventSubscription,
|
||||
TableNotification,
|
||||
} from "metabase-types/api";
|
||||
import { createMockUserInfo } from "metabase-types/api/mocks/user";
|
||||
|
||||
export const createMockNotification = (
|
||||
opts?: Partial<Notification>,
|
||||
): Notification => ({
|
||||
import { createMockTable } from "./table";
|
||||
|
||||
export const createMockAlertNotification = (
|
||||
opts?: Partial<AlertNotification>,
|
||||
): AlertNotification => ({
|
||||
payload_id: 7,
|
||||
payload: {
|
||||
id: 7,
|
||||
@@ -104,3 +108,37 @@ export const createMockNotificationCronSubscription = (
|
||||
ui_display_type: "cron/builder",
|
||||
...opts,
|
||||
});
|
||||
|
||||
export const createMockNotificationSystemEventSubscription = (
|
||||
opts?: Partial<NotificationSystemEventSubscription>,
|
||||
): NotificationSystemEventSubscription => ({
|
||||
id: 11,
|
||||
notification_id: 10,
|
||||
type: "notification-subscription/system-event",
|
||||
event_name: "event/data-editing-row-create",
|
||||
table_id: 42,
|
||||
table: createMockTable(),
|
||||
created_at: "2025-01-07T18:40:47.245205+03:00",
|
||||
updated_at: "2025-01-07T18:40:47.245205+03:00",
|
||||
ui_display_type: null,
|
||||
cron_schedule: null,
|
||||
...opts,
|
||||
});
|
||||
|
||||
export const createMockTableNotification = (
|
||||
opts?: Partial<TableNotification>,
|
||||
): TableNotification => ({
|
||||
id: 1,
|
||||
payload_id: null,
|
||||
payload_type: "notification/system-event",
|
||||
payload: null,
|
||||
creator: createMockUserInfo(),
|
||||
creator_id: 3,
|
||||
handlers: [createMockNotificationHandlerEmail()],
|
||||
subscriptions: [createMockNotificationSystemEventSubscription()],
|
||||
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,15 @@ export type NotificationCardSendCondition =
|
||||
| "goal_below"
|
||||
| "has_result";
|
||||
|
||||
export type SystemEvent =
|
||||
| "event/data-editing-row-create"
|
||||
| "event/data-editing-row-update"
|
||||
| "event/data-editing-row-delete";
|
||||
|
||||
type NotificationPayloadType =
|
||||
| "notification/card"
|
||||
| "notification/system-event";
|
||||
|
||||
//#region Payload union type
|
||||
type NotificationCardPayload = {
|
||||
payload_type: "notification/card";
|
||||
@@ -26,7 +36,11 @@ type NotificationCardPayload = {
|
||||
payload_id?: number;
|
||||
};
|
||||
|
||||
type NotificationPayload = NotificationCardPayload; // will be populated with more variants later on
|
||||
type NotificationSystemEventPayload = {
|
||||
payload_type: "notification/system-event";
|
||||
payload: null;
|
||||
payload_id: null;
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
@@ -60,12 +74,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;
|
||||
@@ -116,6 +142,31 @@ export type NotificationCronSubscription = {
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type NotificationSystemEventSubscriptionRequest = {
|
||||
type: "notification-subscription/system-event";
|
||||
event_name: SystemEvent;
|
||||
table_id: TableId;
|
||||
};
|
||||
|
||||
export type NotificationSystemEventSubscription = {
|
||||
type: "notification-subscription/system-event";
|
||||
|
||||
event_name: SystemEvent;
|
||||
|
||||
id?: number;
|
||||
notification_id?: number;
|
||||
table_id: TableId;
|
||||
table?: Table;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
ui_display_type?: null;
|
||||
cron_schedule?: null;
|
||||
};
|
||||
|
||||
export type NotificationSubscription =
|
||||
| NotificationCronSubscription
|
||||
| NotificationSystemEventSubscription;
|
||||
|
||||
//#endregion
|
||||
|
||||
export interface ListNotificationsRequest extends PaginationRequest {
|
||||
@@ -125,6 +176,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 +185,15 @@ export type CreateAlertNotificationRequest = NotificationCardPayload & {
|
||||
subscriptions: NotificationCronSubscription[];
|
||||
};
|
||||
|
||||
export type CreateNotificationRequest = CreateAlertNotificationRequest; // will be populated with more variants later on
|
||||
export type CreateTableNotificationRequest = NotificationSystemEventPayload & {
|
||||
handlers: NotificationHandler[];
|
||||
subscriptions: NotificationSystemEventSubscriptionRequest[];
|
||||
condition: Condition;
|
||||
};
|
||||
|
||||
export type CreateNotificationRequest =
|
||||
| CreateAlertNotificationRequest
|
||||
| CreateTableNotificationRequest;
|
||||
|
||||
export type UpdateAlertNotificationRequest = NotificationCardPayload & {
|
||||
id: NotificationId;
|
||||
@@ -141,18 +202,46 @@ 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[];
|
||||
subscriptions: NotificationSystemEventSubscription[];
|
||||
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 & {
|
||||
subscriptions: NotificationSystemEventSubscription[];
|
||||
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", // TODO [WRK]: make sure this doesn't break anything for regular use cases
|
||||
"bar",
|
||||
"line",
|
||||
"pie",
|
||||
|
||||
@@ -18,7 +18,9 @@ export type DashboardSidebarName =
|
||||
| "editParameter"
|
||||
| "settings"
|
||||
| "sharing"
|
||||
| "info";
|
||||
| "info"
|
||||
| "addEditableTable"
|
||||
| "configureEditableTable";
|
||||
|
||||
interface BaseSidebarState {
|
||||
name?: DashboardSidebarName;
|
||||
|
||||
@@ -4,6 +4,7 @@ const PREFIX = `/account/notifications`;
|
||||
|
||||
const TYPE_MAP = {
|
||||
"question-notification": "alert",
|
||||
"table-notification": "alert",
|
||||
pulse: "pulse",
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@ 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 Link from "metabase/core/components/Link/Link";
|
||||
import {
|
||||
canArchive,
|
||||
@@ -17,32 +20,32 @@ 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);
|
||||
|
||||
@@ -73,9 +76,10 @@ 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>
|
||||
)}
|
||||
{" · "}
|
||||
{<span>{formatCreatorMessage(item, user.id)}</span>}
|
||||
</Group>
|
||||
|
||||
@@ -1,26 +1,59 @@
|
||||
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 {
|
||||
QuestionNotificationListItem,
|
||||
TableNotificationListItem,
|
||||
} from "metabase/account/notifications/types";
|
||||
import type { AlertNotification, SystemEvent } from "metabase-types/api";
|
||||
import {
|
||||
createMockNotification,
|
||||
createMockAlertNotification,
|
||||
createMockNotificationCronSubscription,
|
||||
createMockNotificationHandlerEmail,
|
||||
createMockNotificationHandlerSlack,
|
||||
createMockNotificationRecipientUser,
|
||||
createMockNotificationSystemEventSubscription,
|
||||
createMockTable,
|
||||
createMockUser,
|
||||
} from "metabase-types/api/mocks";
|
||||
|
||||
import { NotificationCard } from "./NotificationCard";
|
||||
|
||||
const getQuestionAlertItem = (
|
||||
opts?: Partial<Notification>,
|
||||
opts?: Partial<AlertNotification>,
|
||||
): QuestionNotificationListItem => ({
|
||||
item: createMockNotification(opts),
|
||||
item: createMockAlertNotification(opts),
|
||||
type: "question-notification",
|
||||
});
|
||||
|
||||
const getTableNotificationItem = (
|
||||
eventName: SystemEvent,
|
||||
tableName = "Sample Table",
|
||||
): TableNotificationListItem => ({
|
||||
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: null,
|
||||
payload_id: null,
|
||||
subscriptions: [
|
||||
createMockNotificationSystemEventSubscription({
|
||||
event_name: eventName,
|
||||
table: createMockTable({
|
||||
display_name: tableName,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
condition: ["=", ["field", "id"], 1],
|
||||
},
|
||||
type: "table-notification",
|
||||
});
|
||||
|
||||
describe("NotificationCard", () => {
|
||||
it("should render a question alert", () => {
|
||||
const alert = getQuestionAlertItem();
|
||||
@@ -33,6 +66,7 @@ describe("NotificationCard", () => {
|
||||
isEditable
|
||||
onArchive={jest.fn()}
|
||||
onUnsubscribe={jest.fn()}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -56,6 +90,7 @@ describe("NotificationCard", () => {
|
||||
isEditable
|
||||
onArchive={jest.fn()}
|
||||
onUnsubscribe={jest.fn()}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -80,6 +115,7 @@ describe("NotificationCard", () => {
|
||||
isEditable
|
||||
onArchive={jest.fn()}
|
||||
onUnsubscribe={jest.fn()}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -103,6 +139,7 @@ describe("NotificationCard", () => {
|
||||
isEditable
|
||||
onArchive={jest.fn()}
|
||||
onUnsubscribe={jest.fn()}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -124,6 +161,7 @@ describe("NotificationCard", () => {
|
||||
isEditable
|
||||
onArchive={jest.fn()}
|
||||
onUnsubscribe={jest.fn()}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -158,6 +196,7 @@ describe("NotificationCard", () => {
|
||||
isEditable
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -166,6 +205,97 @@ describe("NotificationCard", () => {
|
||||
expect(onArchive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should render a table notification with 'row created' event", () => {
|
||||
const tableNotification = getTableNotificationItem(
|
||||
"event/data-editing-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(
|
||||
"event/data-editing-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(
|
||||
"event/data-editing-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(
|
||||
"event/data-editing-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 +326,7 @@ describe("NotificationCard", () => {
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
isEditable
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -217,6 +348,7 @@ describe("NotificationCard", () => {
|
||||
isEditable={false}
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -236,6 +368,7 @@ describe("NotificationCard", () => {
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
isEditable
|
||||
entityLink={"/"}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -269,6 +402,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 subscription = listItem.item.subscriptions[0];
|
||||
return (
|
||||
<NotificationCard
|
||||
key={`${listItem.type}-${listItem.item.id}`}
|
||||
listItem={listItem}
|
||||
user={user}
|
||||
isEditable={canManageSubscriptions}
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onArchive={onArchive}
|
||||
entityLink={Urls.tableView(
|
||||
subscription.table!.db_id,
|
||||
subscription.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) => {
|
||||
|
||||
@@ -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,57 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Sidebar } from "metabase/dashboard/components/Sidebar";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { ActionIcon, Box, Flex, Icon, Tabs } from "metabase/ui";
|
||||
|
||||
import { getSidebar } from "../selectors";
|
||||
|
||||
interface ConfigureEditableTableSidebarProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConfigureEditableTableSidebar({
|
||||
onClose,
|
||||
}: ConfigureEditableTableSidebarProps) {
|
||||
const dashcardId = useSelector(getSidebar).props.dashcardId;
|
||||
|
||||
return (
|
||||
<Sidebar data-testid="add-table-sidebar">
|
||||
<Tabs defaultValue="columns">
|
||||
<Tabs.List px="md" pt="sm">
|
||||
<Tabs.Tab value="columns">{t`Columns`}</Tabs.Tab>
|
||||
<Tabs.Tab value="filters">{t`Filters`}</Tabs.Tab>
|
||||
<Tabs.Tab value="actions">{t`Actions`}</Tabs.Tab>
|
||||
|
||||
<Flex flex="1" justify="flex-end" align="center">
|
||||
<ActionIcon onClick={onClose}>
|
||||
<Icon name="close" />
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
</Tabs.List>
|
||||
|
||||
<Box p="md">
|
||||
<Tabs.Panel value="columns">
|
||||
<div>DashcardID: {dashcardId}</div>
|
||||
<ConfigureEditableTableColumns />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="filters">
|
||||
<div>DashcardID: {dashcardId}</div>
|
||||
<ConfigureEditableTableFilters />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="actions">
|
||||
<div>Not implemented</div>
|
||||
</Tabs.Panel>
|
||||
</Box>
|
||||
</Tabs>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigureEditableTableColumns() {
|
||||
return <div>Columns Content</div>;
|
||||
}
|
||||
|
||||
function ConfigureEditableTableFilters() {
|
||||
return <div>Filters Content</div>;
|
||||
}
|
||||
@@ -74,6 +74,8 @@ function DashCardActionsPanelInner({
|
||||
supportPreviewing,
|
||||
supportsSeries,
|
||||
disableClickBehavior,
|
||||
disableReplaceCard,
|
||||
additionalDashcardActionButtons,
|
||||
} = getVisualizationRaw(series) ?? {};
|
||||
|
||||
const buttons = [];
|
||||
@@ -137,6 +139,17 @@ function DashCardActionsPanelInner({
|
||||
);
|
||||
}
|
||||
|
||||
if (additionalDashcardActionButtons) {
|
||||
additionalDashcardActionButtons.forEach((AdditionalButtonComponent) => {
|
||||
buttons.push(
|
||||
<AdditionalButtonComponent
|
||||
key={AdditionalButtonComponent.name}
|
||||
dashcard={dashcard}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (supportPreviewing && isPreviewing) {
|
||||
buttons.push(
|
||||
<DashCardActionButton
|
||||
@@ -179,7 +192,12 @@ function DashCardActionsPanelInner({
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLoading && dashcard && !isVirtualDashCard(dashcard)) {
|
||||
if (
|
||||
!isLoading &&
|
||||
dashcard &&
|
||||
!isVirtualDashCard(dashcard) &&
|
||||
!disableReplaceCard
|
||||
) {
|
||||
buttons.push(
|
||||
<DashCardActionButton
|
||||
key="replace-question"
|
||||
|
||||
@@ -28,9 +28,11 @@ import { SAVING_DOM_IMAGE_HIDDEN_CLASS } from "metabase/visualizations/lib/save-
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import { InternalQuery } from "metabase-lib/v1/queries/InternalQuery";
|
||||
import type {
|
||||
Card,
|
||||
DashCardId,
|
||||
DashboardId,
|
||||
Dataset,
|
||||
VirtualCard,
|
||||
VisualizationSettings,
|
||||
} from "metabase-types/api";
|
||||
|
||||
@@ -163,6 +165,7 @@ interface ShouldRenderDashcardMenuProps {
|
||||
isPublicOrEmbedded?: boolean;
|
||||
isEditing: boolean;
|
||||
downloadsEnabled: boolean;
|
||||
card?: Card | VirtualCard;
|
||||
}
|
||||
|
||||
DashCardMenu.shouldRender = ({
|
||||
@@ -172,6 +175,7 @@ DashCardMenu.shouldRender = ({
|
||||
isPublicOrEmbedded,
|
||||
isEditing,
|
||||
downloadsEnabled,
|
||||
card,
|
||||
}: ShouldRenderDashcardMenuProps) => {
|
||||
// Do not remove this check until we completely remove the old code related to Audit V1!
|
||||
// MLv2 doesn't handle `internal` queries used for Audit V1.
|
||||
@@ -179,9 +183,15 @@ DashCardMenu.shouldRender = ({
|
||||
question.datasetQuery(),
|
||||
);
|
||||
|
||||
// TODO [WRK]: not the best solution
|
||||
if (card?.display === "table-editable") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPublicOrEmbedded) {
|
||||
return downloadsEnabled && !!result?.data && !result?.error;
|
||||
}
|
||||
|
||||
return (
|
||||
!isInternalQuery &&
|
||||
!isEditing &&
|
||||
|
||||
@@ -163,6 +163,7 @@ export function DashCardVisualization({
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ClickBehaviorSidebarOverlay
|
||||
dashcard={dashcard}
|
||||
@@ -214,6 +215,7 @@ export function DashCardVisualization({
|
||||
isPublicOrEmbedded,
|
||||
isEditing,
|
||||
downloadsEnabled,
|
||||
card: dashcard.card,
|
||||
});
|
||||
|
||||
if (!shouldShowDashCardMenu) {
|
||||
@@ -245,6 +247,7 @@ export function DashCardVisualization({
|
||||
dashcard.dashboard_id,
|
||||
dashboard.id,
|
||||
downloadsEnabled,
|
||||
dashcard.card,
|
||||
]);
|
||||
|
||||
const { getExtraDataForClick } = useClickBehaviorData({
|
||||
|
||||
@@ -49,6 +49,10 @@ const DASHBOARD_EXPECTED_DATA_MAP: Record<
|
||||
icon: "filter",
|
||||
tooltip: "Add a filter or parameter",
|
||||
},
|
||||
[DASHBOARD_ACTION.ADD_EDITABLE_TABLE]: {
|
||||
icon: "table",
|
||||
tooltip: "Add editable table",
|
||||
},
|
||||
[DASHBOARD_ACTION.ADD_ACTION_ELEMENT]: {
|
||||
icon: "click",
|
||||
tooltip: "Add action button",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ExtraEditButtonsMenu } from "../../ExtraEditButtonsMenu";
|
||||
import { RefreshWidget } from "../../RefreshWidget";
|
||||
import {
|
||||
AddActionElementButton,
|
||||
AddEditableTableButton,
|
||||
AddFilterParameterButton,
|
||||
AddHeadingOrTextButton,
|
||||
AddQuestionButton,
|
||||
@@ -29,6 +30,7 @@ export const DASHBOARD_ACTION = {
|
||||
ADD_SECTION: "ADD_SECTION",
|
||||
ADD_FILTER_PARAMETER: "ADD_FILTER_PARAMETER",
|
||||
ADD_ACTION_ELEMENT: "ADD_ACTION_ELEMENT",
|
||||
ADD_EDITABLE_TABLE: "ADD_EDITABLE_TABLE",
|
||||
EXTRA_EDIT_BUTTONS_MENU: "EXTRA_EDIT_BUTTONS_MENU",
|
||||
COPY_ANALYTICS_DASHBOARD: "COPY_ANALYTICS_DASHBOARD",
|
||||
EDIT_DASHBOARD: "EDIT_DASHBOARD",
|
||||
@@ -68,6 +70,10 @@ export const dashboardActionButtons: Record<
|
||||
component: AddFilterParameterButton,
|
||||
enabled: ({ isEditing }) => isEditing,
|
||||
},
|
||||
[DASHBOARD_ACTION.ADD_EDITABLE_TABLE]: {
|
||||
component: AddEditableTableButton,
|
||||
enabled: ({ isEditing }) => isEditing,
|
||||
},
|
||||
[DASHBOARD_ACTION.ADD_ACTION_ELEMENT]: {
|
||||
component: AddActionElementButton,
|
||||
enabled: ({ isEditing, canEdit, hasModelActionsEnabled }) =>
|
||||
|
||||
@@ -13,6 +13,7 @@ export const DASHBOARD_EDITING_ACTIONS = [
|
||||
DASHBOARD_ACTION.ADD_LINK_CARD,
|
||||
DASHBOARD_ACTION.ADD_SECTION,
|
||||
DASHBOARD_ACTION.ADD_FILTER_PARAMETER,
|
||||
DASHBOARD_ACTION.ADD_EDITABLE_TABLE,
|
||||
DASHBOARD_ACTION.DASHBOARD_HEADER_ACTION_DIVIDER,
|
||||
DASHBOARD_ACTION.ADD_ACTION_ELEMENT,
|
||||
DASHBOARD_ACTION.EXTRA_EDIT_BUTTONS_MENU,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { ToolbarButton } from "metabase/components/ToolbarButton";
|
||||
import { toggleSidebar } from "metabase/dashboard/actions";
|
||||
import { SIDEBAR_NAME } from "metabase/dashboard/constants";
|
||||
import { getSidebar } from "metabase/dashboard/selectors";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
|
||||
export const AddEditableTableButton = () => {
|
||||
const dispatch = useDispatch();
|
||||
const sidebar = useSelector(getSidebar);
|
||||
|
||||
const title = t`Add editable table`;
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
tooltipLabel={title}
|
||||
icon="table"
|
||||
isActive={sidebar.name === SIDEBAR_NAME.addEditableTable}
|
||||
onClick={() => dispatch(toggleSidebar(SIDEBAR_NAME.addEditableTable))}
|
||||
aria-label={title}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./AddActionElementButton";
|
||||
export * from "./AddEditableTableButton";
|
||||
export * from "./AddFilterParameterButton";
|
||||
export * from "./AddHeadingOrTextButton";
|
||||
export * from "./AddQuestionButton";
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useCallback } from "react";
|
||||
import _ from "underscore";
|
||||
|
||||
import { addEditableTableDashCardToDashboard } from "metabase/dashboard/actions";
|
||||
import { useSetDashboardAttributeHandler } from "metabase/dashboard/components/Dashboard/use-set-dashboard-attribute";
|
||||
import { SIDEBAR_NAME } from "metabase/dashboard/constants";
|
||||
import {
|
||||
getEditingParameter,
|
||||
getParameters,
|
||||
} from "metabase/dashboard/selectors";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import DashboardSubscriptionsSidebar from "metabase/notifications/DashboardSubscriptionsSidebar";
|
||||
import { ParameterSidebar } from "metabase/parameters/components/ParameterSidebar";
|
||||
import { hasMapping } from "metabase/parameters/utils/dashboards";
|
||||
@@ -20,6 +21,7 @@ import type {
|
||||
DashboardTabId,
|
||||
Dashboard as IDashboard,
|
||||
ParameterId,
|
||||
TableId,
|
||||
TemporalUnit,
|
||||
ValuesQueryType,
|
||||
ValuesSourceConfig,
|
||||
@@ -30,7 +32,9 @@ import type { SelectedTabId, State } from "metabase-types/store";
|
||||
|
||||
import { ActionSidebarConnected } from "./ActionSidebar";
|
||||
import { AddCardSidebar } from "./AddCardSidebar";
|
||||
import { AddTableSidebar } from "./AddTableSidebar";
|
||||
import { ClickBehaviorSidebar } from "./ClickBehaviorSidebar/ClickBehaviorSidebar";
|
||||
import { ConfigureEditableTableSidebar } from "./ConfigureEditableTableSidebar";
|
||||
import { DashboardInfoSidebar } from "./DashboardInfoSidebar";
|
||||
import { DashboardSettingsSidebar } from "./DashboardSettingsSidebar";
|
||||
|
||||
@@ -118,6 +122,7 @@ export function DashboardSidebars({
|
||||
}: DashboardSidebarsProps) {
|
||||
const parameters = useSelector(getParameters);
|
||||
const editingParameter = useSelector(getEditingParameter);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleAddCard = useCallback(
|
||||
(cardId: CardId) => {
|
||||
@@ -130,6 +135,19 @@ export function DashboardSidebars({
|
||||
[addCardToDashboard, dashboard.id, selectedTabId],
|
||||
);
|
||||
|
||||
const handleAddEditableTable = useCallback(
|
||||
(tableId: TableId) => {
|
||||
dispatch(
|
||||
addEditableTableDashCardToDashboard({
|
||||
dashId: dashboard.id,
|
||||
tabId: selectedTabId,
|
||||
tableId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dashboard.id, dispatch, selectedTabId],
|
||||
);
|
||||
|
||||
const setDashboardAttribute = useSetDashboardAttributeHandler();
|
||||
|
||||
if (isFullscreen) {
|
||||
@@ -230,6 +248,18 @@ export function DashboardSidebars({
|
||||
onClose={closeSidebar}
|
||||
/>
|
||||
);
|
||||
|
||||
case SIDEBAR_NAME.addEditableTable:
|
||||
return (
|
||||
<AddTableSidebar
|
||||
onSelect={handleAddEditableTable}
|
||||
onClose={closeSidebar}
|
||||
/>
|
||||
);
|
||||
|
||||
case SIDEBAR_NAME.configureEditableTable:
|
||||
return <ConfigureEditableTableSidebar onClose={closeSidebar} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user