Compare commits

...

66 Commits

Author SHA1 Message Date
Ngoc Khuat
9467afbbf4 keep refactoring 2025-04-01 11:44:42 +07:00
Ngoc Khuat
cfa263c26b refactored 2025-04-01 11:35:22 +07:00
Ngoc Khuat
a45f69b3a3 can properly handle nested reverse relationship 2025-04-01 11:26:53 +07:00
Chris Truter
a92fca3ef5 Allow search to return all field values (#55997) 2025-03-31 14:49:16 +02:00
Konstantin Darutkin
4e37127051 fix: merge conflicts 2025-03-31 11:42:27 +01:00
Ngoc Khuat
f70ca33c0a fix condition conflicts 2025-03-31 16:25:01 +07:00
Konstantin Darutkin
f47d072729 fix: editable select value and dropdown labels (#55907)
* fix: editable select value and dropdown labels

* fix: missing func

* Apply suggestions from code review

Co-authored-by: Denis Berezin <denis.berezin@metabase.com>

---------

Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
2025-03-28 15:17:06 +00:00
Konstantin Darutkin
7e138cf216 refactor: use input button for editing category (#55906) 2025-03-28 14:02:16 +00:00
Konstantin Darutkin
e9de85bd37 fix: api types for field metadata (#55900)
* fix: api types for field metadata

* fix: review issues

* fix: review issues
2025-03-28 13:24:10 +00:00
Konstantin Darutkin
3d489581dd fix(enterprise): inline editing table input styles (#55820)
* fix(enterprise): inline editing table input styles

* fix: review fixes
2025-03-28 12:53:09 +00:00
Ngoc Khuat
ce87540743 [Notification] Customizable template (#55799) 2025-03-28 18:29:21 +07:00
Ngoc Khuat
eaf1ad0085 fix migration checksum 2025-03-28 17:27:36 +07:00
Denis Berezin
506c665eb6 Editable tables polishing 2025-03-28 00:44:15 +03:00
Konstantin Darutkin
e71e4d0ada feat(enterprise): Add a confirmation message on Delete operations (#55821) 2025-03-27 19:01:40 +00:00
Denis Berezin
a02d08fcfc Fix FK fields empty dropdown (#55851) 2025-03-27 21:55:04 +03:00
Dan Stone
e2fd4fc37e Manage coercion_strategy translations from JSON inputs to database values (#55773)
Implements a set of coercions back to database types, when the frontend provides zoned date time strings (ISO8601).

If an integer field representing UNIX seconds is presented to the frontend as a ISO8601 datetime via coercion_strategy

The frontend will not currently know that database representation of the value (a int representing seconds in this instance). Therefore data editing needs to accept ISO8601 datetime strings as inputs, as the frontend will present a date picker based on the information it has.

For lossy translations (such as ISO8601->Date or ISO8601->Time) much is left undefined, and in some situations users may lose data or find surprises on edit. A working audit log should protect against somewhat $consequences, given how easy it is to make a mistake anyway.

One definition that might work is that time/date changes keep the corresponding exclusive part of the previous row, so if you say vary the time on 2025-03-27T13:42:03Z to 14:00:00, then we take keep the date as 2025-03-27 for that row. In other words, coercion behaviour might become additionally dependent on the existing record state. A followup PR might be in order to acheive this.
2025-03-27 17:48:22 +00:00
Stas Gavrylov
4262ff5a3d WRK-189: Add table notifications UI (#55700)
* WRK-189: Add table notifications UI

* WRK-189: Add delete/unsubscribe confirmation modals. Refactor shared code

* WRK-189: Add display of table notifications list in user profile. Fix lint errors
2025-03-27 18:46:41 +02:00
Dan Stone
76d7d80ecb Fix ci internal tools (#55838)
* Fix migrations file

* Fix postgres kondo

* Fix modules config

* Fix unused requires

* Test util kondo

* Fix MySQL index rollback
2025-03-27 16:44:24 +00:00
Ngoc Khuat
95c442b943 fix lint migration about deleteCascade by adding a proper migration for adding constraint 2025-03-27 20:30:51 +07:00
Ngoc Khuat
e285c32157 [Notification] Update api to work with system events (#55646) 2025-03-27 18:32:48 +07:00
Konstantin Darutkin
68927160c8 fix: inline editing FK field style (#55813) 2025-03-27 11:12:19 +00:00
Chris Truter
01222ff5e1 Merge branch 'master' into internal-tools 2025-03-27 10:19:31 +02:00
Denis Berezin
43c64db6dc Editable tables in dashboards (#55372)
* Support editable tables dashcards with filter support

* Test fix

* Temp

* Add table-editable visualization

* Add table selection + other fixes

* Fixes after merge

* fix e2e

* Fixes after merge

* Fix edit issues and scroll

* Fix issues with edit after merge, fix modal remappings

---------

Co-authored-by: Chris Truter <chris@metabase.com>
2025-03-27 01:02:10 +03:00
Konstantin Darutkin
a9169ea54e feat(enterprise): Handle nulls/defaults/generated columns when (#55675)
* feat(enterprise): Handle nulls/defaults/generated columns when creating/editing

* fix: review fixes

* feat(enterprise): add formik for creation modal (#55694)
2025-03-26 16:07:34 +00:00
Konstantin Darutkin
a5c30576c3 feat(enterprise): update editable table values without reload (#55755) 2025-03-26 13:34:09 +00:00
Chris Truter
b2aa25a18e Merge branch 'master' into internal-tools 2025-03-26 13:24:53 +02:00
Alexander Solovyov
5ed7432f77 improve actions code a little bit (#55749) 2025-03-26 10:23:56 +02:00
Ngoc Khuat
4ee43ee057 Merge branch 'master' into internal-tools 2025-03-26 13:17:41 +07:00
Ngoc Khuat
66e30a69c7 [Notification] tables notification working e2e (#55444) 2025-03-26 12:41:22 +07:00
Dan Stone
a4d8cc425e Return :updated rows when modify rows with PUT (#55707)
Manually tested the UI, does not seem to break anything. 

Policy stuff is ignored for now
2025-03-25 17:57:07 +00:00
Dan Stone
4bee28d643 Fix clickhouse metadata test (#55710) 2025-03-25 17:27:45 +00:00
Chris Truter
58ac9f8dc0 Nix stale comment 2025-03-25 17:57:10 +02:00
Chris Truter
ba47dca45f Preserve existing 'editable' on dashboard save (#55708) 2025-03-25 17:56:09 +02:00
Chris Truter
741d492778 Missed tests 2025-03-25 12:25:17 +02:00
Chris Truter
4073da9974 Merge branch 'master' into internal-tools 2025-03-25 11:07:44 +02:00
Konstantin Darutkin
4bdbeccdd9 fix(enterprise): row deletion attempt #2 (#55644) 2025-03-24 16:13:08 +00:00
Chris Truter
2fff1e2c4a Use POST for delete, so we can receive a body (#55642) 2025-03-24 18:04:42 +02:00
Chris Truter
defba4bc0e Add basic malli schema to data editing APIs (#55640)
* Add basic malli schema to data editing APIs
* Track gitignore changes on master
2025-03-24 17:59:23 +02:00
Konstantin Darutkin
4cdbafc70f Revert "fix(enterprise): delete row query (#55616)" (#55639)
This reverts commit d04b760b32.
2025-03-24 15:54:34 +00:00
Konstantin Darutkin
5fb6bcb4d5 feat(enterprise): non-editable PKs for inline table editing (#55623)
* feat(enterprise): non-editable PKs for inline table editing

* fix: review fixes
2025-03-24 15:12:13 +00:00
Konstantin Darutkin
d04b760b32 fix(enterprise): delete row query (#55616) 2025-03-24 14:24:11 +00:00
Dan Stone
b257e020f6 Add create table endpoint (#55418)
* Export actions/check-data-editing-enabled-for-database!

* Add API for creating tables

Adds a new endpoint: POST ee/data-editing/database/{db-id}/table
- shares policy with data editing (admin only, data editing enabled)
- has a fixed type set (upload types) to limit security exposure for now
- does not permit changing nullable/default/generated yet
2025-03-22 10:21:36 +00:00
Konstantin Darutkin
9f2e1cb040 feature(enterprise): add creation and editing modals for table editing (#55461)
* feature(enterprise): add creation and editing modals for table editing

* fix: review fixes

* feat: add backend integration

* fix: comment out delete button in header
2025-03-21 15:25:54 +00:00
Dan Stone
e5023e9e11 Fix sync dropping new columns on the floor after describe (#55447) 2025-03-21 15:14:57 +00:00
Dan Stone
b817729e1d Fix migration collision (#55421) 2025-03-19 15:57:48 +00:00
Konstantin Darutkin
759da5a5c8 fix(enterprise): datetime editing formatting (#55408) 2025-03-19 13:37:41 +00:00
Denis Berezin
398bacf6ed fix: hide edit mode button from non-admins 2025-03-19 16:33:22 +03:00
Konstantin Darutkin
b7cb067b6c feat(enterprise): add date and datetime table editing (#55402)
* feat(enterprise): add date and datetime table editing

* fix: minor improvement

* fix: minor improvement
2025-03-19 13:23:40 +00:00
Ngoc Khuat
cccf5a8348 [Notification] Conditional expression (#55314)
* condition syntax PoC

* remove the migration

* wire up notification with condition check (#55389)
2025-03-19 21:56:04 +09:00
Denis Berezin
b4b84e778a Merge branch 'master' into internal-tools 2025-03-19 14:57:26 +03:00
Dan Stone
06c9675131 Sync nullable, generated, default attributes for fields (#55113)
* Sync nullable, generated, default database flags

* Fix database-default for apache druid

* Add postgres test for new column attributes

* Add h2 test for new column attributes

* Fix serde baseline

* Use more random test table names
2025-03-19 11:27:15 +00:00
Konstantin Darutkin
e67139aa3f fix: visual improvements for table editing (#55259) 2025-03-19 11:21:44 +00:00
Chris Truter
7696b5e16c Editable table components in dashboards (#55345) 2025-03-19 00:07:08 +02:00
Denis Berezin
177cc26508 Add FK handling and editing (#55303) 2025-03-18 22:45:07 +03:00
Chris Truter
b158037771 Merge branch 'master' into internal-tools 2025-03-18 11:17:27 +02:00
Denis Berezin
5c162716e5 Switch between table view and edit mode (#55133) 2025-03-18 00:31:20 +03:00
Denis Berezin
b52a3b6d34 Merge branch 'master' into internal-tools 2025-03-17 23:19:10 +03:00
Denis Berezin
57bbf5b224 Add db setting to enable table editing (#54990) 2025-03-14 22:28:49 +03:00
Dan Stone
e235783fc4 Add data editing configuration checks with a flag (#55104)
* Support intended configuration checks

e.g
- database-enable-editing is required
- database-enable-actions is not required
- superuser is required

* Fix long line in docstring

* Factor out init helper

* Less lossy asserts
2025-03-14 14:24:00 +02:00
Chris Truter
3c954431f9 Merge branch 'master' into internal-tools 2025-03-14 11:05:52 +02:00
Chris Truter
5e406269a4 Naive detection and deletion of dependant records (#55076)
Walk the foreign keys to get counts, grouped by table, and a delete-the-whole-shebang method.

Doesn't limit the worst case work as much as we'd like, and the glue for working with drivers needs to be written as well. Deferring that glue until we have the necessary metadata in appdb.

There are lots of low hanging optimizations, but scaling isn't a concern while we're in scrappy mode.
2025-03-13 17:40:10 +02:00
Konstantin Darutkin
a68c2f475e feat: customize edit view data grid (#54787) 2025-03-13 09:40:46 +02:00
Denis Berezin
b26d24c90f Fix grid markup to show missing rows 2025-03-13 09:40:46 +02:00
Konstantin Darutkin
3beb60de0b feat(enterprise): add category dropdown editing (#54921) 2025-03-13 09:40:46 +02:00
Chris Truter
430d3ab213 FE for Table Editing v1 (#54289)
* API for reading tables

* Strip fields, add table

* Table editing view mode (#54218)

* Add basic component in ee

* Add table view with header

* Enable feature flag

* Code review fixes, remove token check

* Add basic e2e test for route

* Fix CI

* Boilerplate for new EE feature

* Basic CRUD URLs

* Spoke to Katya, fine to require actions being enabled

* Missing require

* Track the table-crud-apis branch

* Add simple table cell edit UI (#54679)

* Temp

* Add full code to edit a value

* Add proper feature toggle name

* Single click edit, use new api, add cell formatting

* Updated api usage

* Add feature toggle check

* Temp

* Add isolated EditingCell component

* Review fix - simplify state

* Add keyboard events

* Fixes after merge with master

* Fix be tests

* Fix fe e2e tests

* Clean-up

---------

Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
2025-03-13 09:40:46 +02:00
Denis Berezin
59dd7e7dc3 Table editing view mode (#54218)
* Add basic component in ee

* Add table view with header

* Enable feature flag

* Code review fixes, remove token check

* Add basic e2e test for route

* Fix CI
2025-03-13 09:40:46 +02:00
275 changed files with 8244 additions and 716 deletions

View File

@@ -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,7 @@
enterprise/data-editing
{:api #{}
:uses #{api actions util}}
:uses #{api actions driver events lib query-processor upload util}}
enterprise/task
{:api #{metabase-enterprise.task.truncate-audit-tables}

View 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,
},
});
}

View File

@@ -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";

View 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();
}

View File

@@ -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();
});

View File

@@ -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}`);

View File

@@ -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]))

View File

@@ -1,38 +1,205 @@
(ns metabase-enterprise.data-editing.api
(:require
[clojure.data :refer [diff]]
[medley.core :as m]
[metabase-enterprise.data-editing.coerce :as data-editing.coerce]
[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 :as events]
[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.i18n :as i18n]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(defn- perform-bulk-action! [action-kw table-id rows]
(api/check-superuser)
(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}))
:arg rows}
:policy :data-editing))
(doseq [topic [:event/data-editing-row-create
:event/data-editing-row-update
:event/data-editing-row-delete]]
(defmethod events.notification/notification-filter-for-topic topic
[_topic event-info]
(assert (:table_id event-info) "Event info must contain :table_id")
[:= :table_id (:table_id event-info)]))
(defn- qp-result->row-map
[{:keys [rows cols]}]
;; rows from the request are keywordized
(let [col-names (map (comp keyword :name) cols)]
(map #(zipmap col-names %) rows)))
(defn- table-id->pk
[table-id]
;; TODO: support composite PKs
(let [pks (api/check-404 (t2/select :model/Field :table_id table-id :semantic_type :type/PK))]
(api/check-500 (= 1 (count pks)))
(first pks)))
(defn- get-row-pk
[pk-field row]
(get row (keyword (:name pk-field))))
(defn- query-db-rows
[table-id pk-field rows]
(let [{:keys [db_id]} (api/check-404 (t2/select-one :model/Table table-id))]
(assert pk-field "Table must have a primary key")
(assert (every? (partial get-row-pk pk-field) rows) "All rows must have the primary key")
(when-let [pk-values (seq (map (partial get-row-pk pk-field) rows))]
(qp.store/with-metadata-provider db_id
(let [mp (qp.store/metadata-provider)
query (-> (lib/query mp (lib.metadata/table mp table-id))
(lib/filter (apply lib/in (lib.metadata/field mp (:id pk-field)) pk-values))
qp/userland-query-with-default-constraints)]
(->> (qp/process-query query)
:data
qp-result->row-map
(m/index-by #(get-row-pk pk-field %))))))))
(defn- 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))))
(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]]]]
(let [rows (apply-coercions table-id rows)
res (perform-bulk-action! :bulk/create table-id rows)]
(doseq [row (:created-rows res)]
(events/publish-event! :event/data-editing-row-create
{:table_id table-id
:created_row row
:actor_id api/*current-user-id*}))
(let [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]]]]
(if (empty? rows)
{:updated []}
(let [rows (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 (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))
[_ changes _] (diff row-before row)]
(vswap! updated-rows conj after-row)
(when (pos-int? result)
(events/publish-event! :event/data-editing-row-update
{:table_id table-id
:after after-row
:before row-before
:update changes
:actor_id api/*current-user-id*}))))
{: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]]]]
(let [pk-field (table-id->pk table-id)
id->db-rows (query-db-rows table-id pk-field rows)
res (perform-bulk-action! :bulk/delete table-id rows)]
(doseq [row rows]
(events/publish-event! :event/data-editing-row-delete
{:table_id table-id
:deleted_row (get id->db-rows (get-row-pk pk-field row))
:actor_id api/*current-user-id*}))
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))))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/data-editing routes."

View File

@@ -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})

View File

@@ -0,0 +1,7 @@
(ns metabase-enterprise.data-editing.events)
(derive ::event :metabase/event)
(derive :event/data-editing-row-create ::event)
(derive :event/data-editing-row-update ::event)
(derive :event/data-editing-row-delete ::event)

View File

@@ -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)))))

View File

@@ -0,0 +1,3 @@
(ns metabase-enterprise.data-editing.init
(:require
[metabase-enterprise.data-editing.events]))

View File

@@ -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}

View File

@@ -1,13 +1,16 @@
(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]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defn- table-rows [table-id]
(mt/rows
(qp/process-query
@@ -20,72 +23,283 @@
(deftest feature-flag-required-test
(mt/with-premium-features #{}
(let [url (table-url 1)]
(let [url (data-editing.tu/table-url 1)]
(mt/assert-has-premium-feature-error "Editing Table Data" (mt/user-http-request :crowberto :post 402 url))
(mt/assert-has-premium-feature-error "Editing Table Data" (mt/user-http-request :crowberto :put 402 url))
(mt/assert-has-premium-feature-error "Editing Table Data" (mt/user-http-request :crowberto :delete 402 url)))))
(mt/assert-has-premium-feature-error "Editing Table Data" (mt/user-http-request :crowberto :post 402 (str url "/delete"))))))
(deftest table-operations-test
(mt/with-premium-features #{:table-data-editing}
(mt/with-empty-h2-app-db
(let [db (t2/select-one :model/Database (mt/id))
driver :h2
table-name (str "temp_table_" (u/lower-case-en (random-uuid)))]
(with-open [table-ref (data-editing.tu/open-test-table!)]
(let [table-id @table-ref
url (data-editing.tu/table-url table-id)]
(t2/update! :model/Database (mt/id) {:settings {:database-enable-table-editing 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
(t2/update! :model/Database (mt/id) {:settings {:database-enable-table-editing 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 %)))))))

View File

@@ -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}]))))

View File

@@ -0,0 +1,164 @@
(ns metabase-enterprise.data-editing.notification-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
[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!
[event-name 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 {:subscriptions [{:event_name event-name
: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!
:event/data-editing-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 {:event_info {:actor {:common_name "Crowberto Corv"
:email "crowberto@metabase.com"
:first_name "Crowberto"
:last_name "Corv"}
:actor_id (mt/user->id :crowberto),
:created_row {"ID" (mt/malli=? :int) "NAME" "New Category"}
:table {:name "CATEGORIES"}
:table_id (mt/id :categories)}
:event_name :event/data-editing-row-create,
:type "system_event"}}
req)))}))
(deftest update-row-notification-test
(test-row-notification!
:event/data-editing-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• 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 {:event_info {:actor {:common_name "Crowberto Corv"
:email "crowberto@metabase.com"
:first_name "Crowberto"
:last_name "Corv"}
:actor_id (mt/user->id :crowberto),
:after {:ID 1 :NAME "Updated Category"}
:before {:ID 1 :NAME (mt/malli=? :string)}
:update {:NAME "Updated Category"}
:table {:name "CATEGORIES"}
:table_id (mt/id :categories)}
:event_name :event/data-editing-row-update,
:type "system_event"}}
req)))}))
(deftest delete-row-notification-test
(test-row-notification!
:event/data-editing-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 {:event_info {:actor {:common_name "Crowberto Corv"
:email "crowberto@metabase.com"
:first_name "Crowberto"
:last_name "Corv"}
:actor_id (mt/user->id :crowberto),
:deleted_row {:ID 1 :NAME "African"}
:table {:name "CATEGORIES"}
:table_id (mt/id :categories)}
:event_name :event/data-editing-row-delete,
:type "system_event"}}
req)))}))

View File

@@ -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))

View File

@@ -0,0 +1 @@
(ns metabase-enterprise.data-editing.util)

View File

@@ -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";

View File

@@ -0,0 +1,146 @@
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;

View File

@@ -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
);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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}
/>
</>
);
};

View File

@@ -0,0 +1,149 @@
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 { extractRemappedColumns } from "metabase/visualizations";
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 } = useMemo(() => extractRemappedColumns(data), [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}
/>
);
};

View File

@@ -0,0 +1,38 @@
import { t } from "ttag";
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>
</Group>
</Flex>
);
};

View File

@@ -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>
);
};

View File

@@ -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);
}
}

View File

@@ -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}
/>
);
};

View File

@@ -0,0 +1 @@
export { EditTableDataContainer } from "./EditTableDataContainer";

View File

@@ -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}
/>
);
};

View File

@@ -0,0 +1,178 @@
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>
);
};

View File

@@ -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 }}
/>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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} />;
};

View File

@@ -0,0 +1,5 @@
export { EditingBodyCellBasicInput } from "./EditingBodyCellBasicInput";
export { EditingBodyCellCategorySelect } from "./EditingBodyCellCategorySelect";
export { EditingBodyCellFKSelect } from "./EditingBodyCellFKSelect";
export { EditingBodyCellDatetime } from "./EditingBodyCellDatetime";
export { EditingBodyCellConditional } from "./EditingBodyCellConditional";

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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 }}
/>
);
}

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -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 { TableNotificationsTriggerButton } from "metabase/notifications/modals/TableNotificationsModals/TableNotificationsTriggerButton";
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}
/>
<TableNotificationsTriggerButton 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>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -0,0 +1 @@
export { BrowseTableData } from "./BrowseTableData";

View File

@@ -35,3 +35,4 @@ import "./resource_downloads";
import "./user_provisioning";
import "./clean_up";
import "./troubleshooting";
import "./data_editing";

View 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)}`;
}

View File

@@ -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;

View File

@@ -117,6 +117,7 @@ export type BaseDashboardCard = DashboardCardLayoutAttrs & {
entity_id: BaseEntityId;
visualization_settings?: DashCardVisualizationSettings;
justAdded?: boolean;
isAdded?: boolean;
created_at: string;
updated_at: string;
};

View File

@@ -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;
}

View File

@@ -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,
});

View File

@@ -131,6 +131,7 @@ export const createMockTokenFeatures = (
query_reference_validation: false,
serialization: false,
cache_preemptive: false,
table_data_editing: false,
...opts,
});

View File

@@ -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
@@ -116,6 +130,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 +164,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 +173,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 +190,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;

View File

@@ -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];

View File

@@ -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;
};

View File

@@ -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",

View File

@@ -18,7 +18,8 @@ export type DashboardSidebarName =
| "editParameter"
| "settings"
| "sharing"
| "info";
| "info"
| "addEditableTable";
interface BaseSidebarState {
name?: DashboardSidebarName;

View File

@@ -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>

View File

@@ -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={"/"}
/>,
);

View File

@@ -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>
);

View File

@@ -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?`

View File

@@ -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) =>

View File

@@ -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";

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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";
};

View File

@@ -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,

View File

@@ -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 });
};

View File

@@ -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);

View File

@@ -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),
};
};

View File

@@ -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 {

View File

@@ -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) => {

View File

@@ -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>
);
}

View File

@@ -74,6 +74,7 @@ function DashCardActionsPanelInner({
supportPreviewing,
supportsSeries,
disableClickBehavior,
disableReplaceCard,
} = getVisualizationRaw(series) ?? {};
const buttons = [];
@@ -179,7 +180,12 @@ function DashCardActionsPanelInner({
}
}
if (!isLoading && dashcard && !isVirtualDashCard(dashcard)) {
if (
!isLoading &&
dashcard &&
!isVirtualDashCard(dashcard) &&
!disableReplaceCard
) {
buttons.push(
<DashCardActionButton
key="replace-question"

View File

@@ -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 QueryDownloadWidgetOpts {
isPublicOrEmbedded?: boolean;
isEditing: boolean;
downloadsEnabled: boolean;
card?: Card | VirtualCard;
}
DashCardMenu.shouldRender = ({
@@ -172,6 +175,7 @@ DashCardMenu.shouldRender = ({
isPublicOrEmbedded,
isEditing,
downloadsEnabled,
card,
}: QueryDownloadWidgetOpts) => {
// 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 &&

View File

@@ -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({

View File

@@ -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",

View File

@@ -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 }) =>

View File

@@ -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,

View File

@@ -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}
/>
);
};

View File

@@ -1,4 +1,5 @@
export * from "./AddActionElementButton";
export * from "./AddEditableTableButton";
export * from "./AddFilterParameterButton";
export * from "./AddHeadingOrTextButton";
export * from "./AddQuestionButton";

View File

@@ -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,6 +32,7 @@ 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 { DashboardInfoSidebar } from "./DashboardInfoSidebar";
import { DashboardSettingsSidebar } from "./DashboardSettingsSidebar";
@@ -118,6 +121,7 @@ export function DashboardSidebars({
}: DashboardSidebarsProps) {
const parameters = useSelector(getParameters);
const editingParameter = useSelector(getEditingParameter);
const dispatch = useDispatch();
const handleAddCard = useCallback(
(cardId: CardId) => {
@@ -130,6 +134,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 +247,14 @@ export function DashboardSidebars({
onClose={closeSidebar}
/>
);
case SIDEBAR_NAME.addEditableTable:
return (
<AddTableSidebar
onSelect={handleAddEditableTable}
onClose={closeSidebar}
/>
);
default:
return null;
}

View File

@@ -0,0 +1,34 @@
import { t } from "ttag";
import { useListTablesQuery } from "metabase/api";
import SelectList from "metabase/components/SelectList/SelectList";
import type { TableId } from "metabase-types/api";
type TableListProps = {
onSelect: (cardId: TableId) => void;
};
export const TableList = ({ onSelect }: TableListProps) => {
const { data: tables } = useListTablesQuery();
if (!tables) {
return <div>{t`Nothing found`}</div>;
}
return (
<SelectList>
{tables.map(item => (
<SelectList.Item
key={item.id}
id={item.id}
name={item.display_name}
icon={{
name: "table",
size: 16,
}}
onSelect={onSelect}
/>
))}
</SelectList>
);
};

View File

@@ -16,6 +16,7 @@ export const SIDEBAR_NAME: Record<DashboardSidebarName, DashboardSidebarName> =
sharing: "sharing",
settings: "settings",
info: "info",
addEditableTable: "addEditableTable",
};
export const INITIAL_DASHBOARD_STATE: DashboardState = {

View File

@@ -93,7 +93,8 @@ export function isQuestionDashCard(
"card_id" in dashcard &&
"card" in dashcard &&
!isVirtualDashCard(dashcard) &&
!isActionDashCard(dashcard)
!isActionDashCard(dashcard) &&
!isEditableTableDashCard(dashcard)
);
}
@@ -109,6 +110,12 @@ export function isVirtualDashCard(
return _.isObject(dashcard?.visualization_settings?.virtual_card);
}
export function isEditableTableDashCard(dashcard: BaseDashboardCard): boolean {
return (
"table_id" in dashcard.card && dashcard.card.display === "table-editable"
);
}
export function getVirtualCardType(dashcard: BaseDashboardCard) {
return dashcard?.visualization_settings?.virtual_card?.display;
}

View File

@@ -43,6 +43,7 @@
}
.headerCell {
height: 100%;
position: relative;
}

View File

@@ -253,11 +253,14 @@ export const CombinedFeatures: Story = () => {
const handleBodyCellClick = useCallback(
(
_: React.MouseEvent<HTMLDivElement>,
rowIndex: number,
columnId: string,
cellProps: {
rowIndex: number;
columnId: string;
cellId: string;
},
) => {
// eslint-disable-next-line no-console
console.log(`Clicked cell at row ${rowIndex}, column ${columnId}`);
console.log(`Clicked cell`, cellProps);
},
[],
);

View File

@@ -23,6 +23,7 @@ import { DataGridThemeProvider } from "metabase/data-grid/hooks/use-table-theme"
import type { DataGridInstance, DataGridTheme } from "metabase/data-grid/types";
import { useForceUpdate } from "metabase/hooks/use-force-update";
import { getScrollBarSize } from "metabase/lib/dom";
import { Box } from "metabase/ui";
import { Footer } from "../Footer/Footer";
@@ -215,7 +216,12 @@ export const DataGrid = function DataGrid<TData>({
};
const headerContent = isPinned ? (
headerCell
<Box
className={cx(S.headerCell, classNames?.headerCell)}
style={styles?.headerCell}
>
{headerCell}
</Box>
) : (
<SortableHeader
className={cx(S.headerCell, classNames?.headerCell)}
@@ -331,7 +337,11 @@ export const DataGrid = function DataGrid<TData>({
data-column-id={cell.column.id}
className={cx(S.bodyCell, classNames?.bodyCell)}
onClick={(e) =>
onBodyCellClick?.(e, cell.row.index, cell.column.id)
onBodyCellClick?.(e, {
rowIndex: cell.row.index,
columnId: cell.column.id,
cellId: cell.id,
})
}
onMouseDown={(e) =>
selection.handlers.handleCellMouseDown(e, cell)

View File

@@ -44,8 +44,11 @@ interface TestDataGridProps {
) => void;
onBodyCellClick?: (
event: MouseEvent<HTMLDivElement>,
rowIndex: number,
columnId: string,
cellProps: {
rowIndex: number;
columnId: string;
cellId: string;
},
) => void;
onAddColumnClick?: () => void;
onColumnResize?: (columnSizing: ColumnSizingState) => void;
@@ -187,8 +190,11 @@ describe("DataGrid", () => {
expect(onBodyCellClick).toHaveBeenCalledTimes(1);
expect(onBodyCellClick).toHaveBeenCalledWith(
expect.anything(), // event
0, // row index
"id", // column ID
{
rowIndex: 0,
columnId: "id",
cellId: "0_id",
},
);
});

View File

@@ -13,12 +13,14 @@ export interface RowIdCellProps {
value?: React.ReactNode;
backgroundColor?: string;
canExpand?: boolean;
onRowExpandClick?: () => void;
}
export const RowIdCell = memo(function RowIdCell({
value,
backgroundColor,
canExpand = true,
onRowExpandClick,
}: RowIdCellProps) {
const hasValue = value != null;
@@ -38,6 +40,7 @@ export const RowIdCell = memo(function RowIdCell({
{value}
</span>
) : null}
{canExpand && (
<Button
data-testid="detail-shortcut"
@@ -46,6 +49,7 @@ export const RowIdCell = memo(function RowIdCell({
className={cx(DataGridS.rowHoverVisible, S.expandButton)}
size="compact-md"
leftSection={<Icon name="expand" size={14} />}
onClick={onRowExpandClick}
/>
)}
</BaseCell>

View File

@@ -65,6 +65,9 @@ export interface ColumnOptions<TRow extends RowData, TValue = unknown> {
/** Custom cell render template */
cell?: ColumnDefTemplate<CellContext<TRow, TValue>>;
/** Custom cell render template for cells in editing state */
editingCell?: (props: CellContext<TRow, TValue>) => React.JSX.Element;
/** Custom header render template */
header?: ColumnDefTemplate<HeaderContext<TRow, TValue>>;
@@ -100,6 +103,8 @@ export interface ColumnOptions<TRow extends RowData, TValue = unknown> {
/** Function to format cell values for display */
formatter?: CellFormatter<TValue>;
getIsCellEditing?: (cellId: string) => boolean;
}
/**
@@ -111,6 +116,9 @@ export interface RowIdColumnOptions {
/** Function to determine background color for the ID cells */
getBackgroundColor?: (rowIndex: number) => string;
/** Handler for variant="expandButton" */
onRowExpandClick?: (rowIndex: number) => void;
}
export interface DataGridTheme {
@@ -195,6 +203,8 @@ export type BodyCellVariant = "text" | "pill";
export type HeaderCellVariant = "light" | "outline";
export type RowIdVariant = "indexExpand" | "expandButton" | "index";
export type DataGridCellId = string;
export type CellFormatter<TValue> = (
value: TValue,
rowIndex: number,
@@ -250,8 +260,11 @@ export interface DataGridInstance<TData> {
) => void;
onBodyCellClick?: (
event: React.MouseEvent<HTMLDivElement>,
rowIndex: number,
columnId: string,
cellProps: {
rowIndex: number;
columnId: string;
cellId: string;
},
) => void;
onAddColumnClick?: React.MouseEventHandler<HTMLButtonElement>;
onWheel?: React.UIEventHandler<HTMLDivElement>;

View File

@@ -14,7 +14,7 @@ import type {
ExpandedColumnsState,
} from "metabase/data-grid/types";
const getDefaultCellTemplate = <TRow, TValue>(
export const getDefaultCellTemplate = <TRow, TValue>(
{
id,
align,
@@ -24,18 +24,25 @@ const getDefaultCellTemplate = <TRow, TValue>(
wrap,
getCellClassName,
getCellStyle,
getIsCellEditing,
editingCell: EditingCellComponent,
}: ColumnOptions<TRow, TValue>,
isTruncated: boolean,
onExpand: (columnName: string, content: React.ReactNode) => void,
) => {
return function Cell({
getValue,
row,
isSelected,
}: CellContext<TRow, TValue> & { isSelected?: boolean }) {
return function Cell(
cellContext: CellContext<TRow, TValue> & { isSelected?: boolean },
) {
const { cell, getValue, row, isSelected } = cellContext;
const value = getValue();
const backgroundColor = getBackgroundColor?.(value, row?.index);
const isEditing = getIsCellEditing?.(cell.id);
if (isEditing && EditingCellComponent) {
return <EditingCellComponent {...cellContext} />;
}
return (
<BodyCell
isSelected={isSelected}

Some files were not shown because too many files have changed in this diff Show More