Compare commits
7 Commits
python-run
...
action-val
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
909d0bcc1c | ||
|
|
56021cd416 | ||
|
|
ece9d3752a | ||
|
|
51ca0bdd86 | ||
|
|
daffebebd0 | ||
|
|
40d30364b0 | ||
|
|
171903cd20 |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
507
e2e/test/scenarios/table-editing/table-editing.cy.spec.ts
Normal file
507
e2e/test/scenarios/table-editing/table-editing.cy.spec.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
const { ORDERS_ID, PRODUCTS_ID } = SAMPLE_DATABASE;
|
||||
|
||||
describe("scenarios > table-editing", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
|
||||
H.activateToken("bleeding-edge");
|
||||
setTableEditingEnabledForDB(SAMPLE_DB_ID);
|
||||
|
||||
cy.intercept("GET", "/api/database").as("getDatabases");
|
||||
cy.intercept("GET", "/api/table/*").as("getTable");
|
||||
});
|
||||
|
||||
it("should show edit icon on table browser", () => {
|
||||
openTableBrowser();
|
||||
getTableEditIcon("People").should("be.visible");
|
||||
});
|
||||
|
||||
it("should not show edit icon on table browser if user is not admin", () => {
|
||||
cy.signInAsNormalUser();
|
||||
|
||||
openTableBrowser();
|
||||
getTableEditIcon("People").should("not.exist");
|
||||
});
|
||||
|
||||
it("should allow to open table data edit mode", () => {
|
||||
openTableBrowser();
|
||||
openTableEdit("People");
|
||||
|
||||
cy.wait("@getTable");
|
||||
|
||||
cy.findByTestId("edit-table-data-root")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("People").should("be.visible");
|
||||
cy.findByTestId("head-crumbs-container")
|
||||
.findByText("Edit")
|
||||
.should("be.visible");
|
||||
|
||||
cy.findByTestId("table-root").should("be.visible");
|
||||
});
|
||||
|
||||
cy.findByTestId("head-crumbs-container").findByText("People").click();
|
||||
|
||||
cy.findByTestId("query-builder-root").should("be.visible");
|
||||
});
|
||||
|
||||
describe("db setting is disabled", () => {
|
||||
it("should not allow to open table data view by default", () => {
|
||||
setTableEditingEnabledForDB(SAMPLE_DB_ID, false);
|
||||
openTableBrowser();
|
||||
getTableEditIcon("People").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-admin user", () => {
|
||||
beforeEach(() => {
|
||||
cy.signInAsNormalUser();
|
||||
});
|
||||
|
||||
it("should not allow to open table data view", () => {
|
||||
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/edit`);
|
||||
cy.findByTestId("edit-table-data-restricted").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("table edit mode", () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept("GET", `/api/table/${ORDERS_ID}/query_metadata`).as(
|
||||
"getOrdersTable",
|
||||
);
|
||||
|
||||
cy.intercept("POST", "api/dataset").as("getTableDataQuery");
|
||||
cy.intercept("POST", "api/ee/action-v2/execute-bulk").as(
|
||||
"updateTableData",
|
||||
);
|
||||
|
||||
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/edit`);
|
||||
|
||||
cy.wait("@getOrdersTable");
|
||||
});
|
||||
|
||||
it("should allow to filter table data", () => {
|
||||
cy.findByTestId("edit-table-data-root").findByText("Filter").click();
|
||||
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Discount").click();
|
||||
cy.findByText("Between").click();
|
||||
});
|
||||
|
||||
H.menu().findByText("Not empty").click();
|
||||
H.popover().findByText("Apply filter").click();
|
||||
|
||||
cy.wait("@getTableDataQuery");
|
||||
|
||||
cy.findByTestId("filters-visibility-control").should("have.text", "1");
|
||||
|
||||
// check that filter persists after page refresh
|
||||
cy.reload();
|
||||
cy.wait("@getTableDataQuery");
|
||||
|
||||
cy.findByTestId("filters-visibility-control").should("have.text", "1");
|
||||
cy.findByTestId("filters-visibility-control").click();
|
||||
|
||||
cy.findByTestId("qb-filters-panel")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.icon("close").click();
|
||||
});
|
||||
|
||||
cy.wait("@getTableDataQuery");
|
||||
cy.findByTestId("filters-visibility-control").should("not.exist");
|
||||
});
|
||||
|
||||
it("should allow to sort table data", () => {
|
||||
cy.findByTestId("table-header").within(() => {
|
||||
cy.findByText("Quantity").click();
|
||||
|
||||
cy.findAllByTestId("header-sort-indicator").should("have.length", 1);
|
||||
|
||||
cy.findByText("Quantity")
|
||||
.closest("[role=button]")
|
||||
.findByLabelText("chevronup icon")
|
||||
.should("be.visible");
|
||||
|
||||
cy.findByText("Quantity").click();
|
||||
|
||||
cy.findByText("Quantity")
|
||||
.closest("[role=button]")
|
||||
.findByLabelText("chevrondown icon")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
// check that sorting persist after page refresh
|
||||
cy.reload();
|
||||
cy.wait("@getTableDataQuery");
|
||||
|
||||
cy.findByTestId("table-header").within(() => {
|
||||
cy.findByText("Quantity")
|
||||
.closest("[role=button]")
|
||||
.findByLabelText("chevrondown icon")
|
||||
.should("be.visible");
|
||||
|
||||
cy.findByText("Quantity").click();
|
||||
|
||||
cy.findAllByTestId("header-sort-indicator").should("have.length", 0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow to view row details", () => {
|
||||
openEditRowModal(2);
|
||||
H.modal().within(() => {
|
||||
cy.get("@rowId").then((rowId) => {
|
||||
cy.findAllByRole("textbox").first().should("have.value", rowId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow to edit a row using a modal", () => {
|
||||
openEditRowModal(2);
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByTestId("Quantity-field-input")
|
||||
.type("{backspace}{backspace}{backspace}123")
|
||||
.blur();
|
||||
|
||||
cy.findByTestId("update-row-save-button").click();
|
||||
});
|
||||
|
||||
cy.wait("@updateTableData").then(({ response }) => {
|
||||
expect(response?.body.outputs[0].op).to.equal("updated");
|
||||
expect(response?.body.outputs[0].row.QUANTITY).to.equal(123);
|
||||
});
|
||||
|
||||
H.modal().should("not.exist");
|
||||
|
||||
H.undoToast().findByText("Successfully updated").should("be.visible");
|
||||
});
|
||||
|
||||
describe("inline cell editing", () => {
|
||||
// we use randomized values here to allow multiple runs in one session (since non-changed values are not updated)
|
||||
const cases = [
|
||||
{
|
||||
table: "Products",
|
||||
dataType: "string",
|
||||
column: "EAN",
|
||||
value: Math.floor(Math.random() * 100000)
|
||||
.toString()
|
||||
.padStart(13, "0"),
|
||||
},
|
||||
{
|
||||
table: "Orders",
|
||||
dataType: "number",
|
||||
column: "TAX",
|
||||
value: Math.floor(Math.random() * 1000),
|
||||
},
|
||||
];
|
||||
|
||||
cases.forEach(({ dataType, column, value, table }) => {
|
||||
it(`should allow to edit a cell with type ${dataType}`, () => {
|
||||
if (table === "Products") {
|
||||
cy.visit(
|
||||
`/browse/databases/${SAMPLE_DB_ID}/tables/${PRODUCTS_ID}/edit`,
|
||||
);
|
||||
}
|
||||
|
||||
// Locate the table and the specific cell to edit
|
||||
cy.findByTestId("table-root")
|
||||
.findAllByRole("row")
|
||||
.eq(1) // Select the second row (index 1)
|
||||
.within(() => {
|
||||
cy.get(`[data-column-id='${column}']`).as("targetCell").click({
|
||||
scrollBehavior: false,
|
||||
}); // Activate inline editing
|
||||
});
|
||||
|
||||
// Edit the cell value
|
||||
cy.get("@targetCell")
|
||||
.find("input") // Assuming the cell becomes an input field
|
||||
.type(`{selectAll}{backspace}${value}`, {
|
||||
scrollBehavior: false,
|
||||
}) // Enter the new value
|
||||
.blur(); // Trigger the save action by blurring the input
|
||||
|
||||
cy.wait("@updateTableData").then(({ response }) => {
|
||||
expect(response?.body.outputs[0].op).to.equal("updated");
|
||||
expect(response?.body.outputs[0].row[column]).to.equal(value);
|
||||
});
|
||||
|
||||
H.undoToast().findByText("Successfully updated").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow to edit a cell with datetime type", () => {
|
||||
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/edit`);
|
||||
|
||||
// Locate the table and the specific cell to edit
|
||||
cy.findByTestId("table-root")
|
||||
.findAllByRole("row")
|
||||
.eq(1) // Select the second row (index 1)
|
||||
.within(() => {
|
||||
cy.get("[data-column-id='CREATED_AT']").as("targetCell").click({
|
||||
scrollBehavior: false,
|
||||
}); // Activate inline editing
|
||||
});
|
||||
|
||||
const day = Math.floor(Math.random() * 10) + 10; // 10-20
|
||||
const hour = Math.floor(Math.random() * 12);
|
||||
const minute = Math.floor(Math.random() * 60);
|
||||
|
||||
H.popover().within(() => {
|
||||
cy.findByRole("button", { name: `${day} May 2024` }).click();
|
||||
cy.findAllByRole("spinbutton").eq(0).type(hour.toString());
|
||||
cy.findAllByRole("spinbutton").eq(1).type(minute.toString());
|
||||
// It's safe to click the last button because we're in the popover
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
cy.findAllByRole("button").last().click();
|
||||
});
|
||||
|
||||
cy.wait("@updateTableData").then(({ response }) => {
|
||||
const targetDate = dayjs(
|
||||
new Date(2024, 4, day, hour, minute, 0),
|
||||
).format("YYYY-MM-DDTHH:mm:ss");
|
||||
const savedDate = response?.body.outputs[0].row.CREATED_AT;
|
||||
|
||||
expect(response?.body.outputs[0].op).to.equal("updated");
|
||||
// Omit timezone offset
|
||||
expect(savedDate.startsWith(targetDate)).to.be.true;
|
||||
});
|
||||
|
||||
H.undoToast().findByText("Successfully updated").should("be.visible");
|
||||
});
|
||||
|
||||
it("should allow to edit a cell with select type", () => {
|
||||
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/edit`);
|
||||
|
||||
cy.findByTestId("table-root")
|
||||
.findAllByRole("row")
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get("[data-column-id='USER_ID']").as("targetCell").click({
|
||||
scrollBehavior: false,
|
||||
});
|
||||
});
|
||||
|
||||
H.popover().within(() => {
|
||||
const randomIndex = Math.floor(2 + Math.random() * 18);
|
||||
|
||||
cy.findAllByRole("option")
|
||||
.should("have.length", 20)
|
||||
.eq(randomIndex)
|
||||
.click();
|
||||
|
||||
cy.wait("@updateTableData").then(({ response }) => {
|
||||
expect(response?.body.outputs[0].op).to.equal("updated");
|
||||
});
|
||||
});
|
||||
|
||||
H.undoToast().findByText("Successfully updated").should("be.visible");
|
||||
});
|
||||
|
||||
it("should not allow to edit PK cells", () => {
|
||||
cy.visit(
|
||||
`/browse/databases/${SAMPLE_DB_ID}/tables/${PRODUCTS_ID}/edit`,
|
||||
);
|
||||
|
||||
cy.findByTestId("table-root")
|
||||
.findAllByRole("row")
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get("[data-column-id='ID']")
|
||||
.as("targetCell")
|
||||
.click({
|
||||
scrollBehavior: false,
|
||||
})
|
||||
.find("input")
|
||||
.should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle errors", () => {
|
||||
cy.findByTestId("table-root")
|
||||
.findAllByRole("row")
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get("[data-column-id='TAX']")
|
||||
.as("targetCell")
|
||||
.click({
|
||||
scrollBehavior: false,
|
||||
})
|
||||
.find("input")
|
||||
.type("{selectAll}{backspace}aaa", {
|
||||
scrollBehavior: false,
|
||||
}) // Enter the new value
|
||||
.blur(); // Trigger the save action by blurring the input
|
||||
});
|
||||
|
||||
H.undoToast()
|
||||
.findByText("Couldn't save table changes")
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("create / delete row", () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept("GET", `/api/table/${ORDERS_ID}/query_metadata`).as(
|
||||
"getOrdersTable",
|
||||
);
|
||||
|
||||
cy.intercept("POST", "api/dataset").as("getTableDataQuery");
|
||||
cy.intercept("POST", "api/ee/action-v2/execute-bulk").as("executeBulk");
|
||||
|
||||
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/edit`);
|
||||
|
||||
cy.wait("@getOrdersTable");
|
||||
});
|
||||
|
||||
it("should allow to create and delete a row", () => {
|
||||
cy.findByTestId("new-record-button").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Create a new record").should("be.visible");
|
||||
cy.findByTestId("Tax-field-input").type("50");
|
||||
cy.findByTestId("Total-field-input").type("100");
|
||||
cy.findByTestId("Discount-field-input").type("10");
|
||||
cy.findByTestId("create-row-form-submit-button").click();
|
||||
});
|
||||
|
||||
cy.wait("@executeBulk").then(({ response, request }) => {
|
||||
expect(request.body.action).to.equal("data-grid.row/create");
|
||||
expect(response?.body.outputs[0].op).to.equal("created");
|
||||
expect(response?.body.outputs[0].row.TAX).to.equal(50);
|
||||
expect(response?.body.outputs[0].row.TOTAL).to.equal(100);
|
||||
expect(response?.body.outputs[0].row.DISCOUNT).to.equal(10);
|
||||
});
|
||||
|
||||
H.undoToast().within(() => {
|
||||
cy.findByText("Record successfully created").should("be.visible");
|
||||
cy.findByLabelText("close icon").click();
|
||||
});
|
||||
|
||||
// We want to check if the new row is added as the last row to the table
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
cy.findByTestId("table-scroll-container")
|
||||
.scrollTo("bottom")
|
||||
.findAllByRole("row")
|
||||
.last()
|
||||
.should("have.attr", "data-dataset-index", "2000")
|
||||
.within(() => {
|
||||
cy.get('[data-column-id="TAX"]').should("have.text", "50");
|
||||
cy.get('[data-column-id="TOTAL"]').should("have.text", "100");
|
||||
cy.get('[data-column-id="DISCOUNT"]').should("have.text", "10");
|
||||
|
||||
cy.findByTestId("detail-shortcut").click({
|
||||
scrollBehavior: false,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
H.modal()
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.findByText("Edit record").should("be.visible");
|
||||
cy.findByTestId("delete-row-icon").click();
|
||||
});
|
||||
|
||||
H.modal()
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.findByText("Delete this record?").should("be.visible");
|
||||
cy.findByText("Delete record").click();
|
||||
});
|
||||
|
||||
cy.wait("@executeBulk").then(({ response, request }) => {
|
||||
expect(request.body.action).to.equal("data-grid.row/delete");
|
||||
expect(response?.body.outputs[0].op).to.equal("deleted");
|
||||
});
|
||||
|
||||
H.undoToast().findByText("Successfully deleted").should("be.visible");
|
||||
});
|
||||
|
||||
it("should allow to delete multiple rows (bulk)", () => {
|
||||
cy.findByTestId("delete-records-bulk-button").should("be.disabled");
|
||||
|
||||
cy.findAllByTestId("row-select-checkbox").eq(15).click();
|
||||
cy.findAllByTestId("row-select-checkbox").eq(16).click();
|
||||
|
||||
cy.findByTestId("delete-records-bulk-button")
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Delete 2 records?").should("be.visible");
|
||||
cy.findByRole("button", { name: "Delete 2 records" }).click();
|
||||
});
|
||||
|
||||
cy.wait("@executeBulk").then(({ response, request }) => {
|
||||
expect(request.body.action).to.equal("data-grid.row/delete");
|
||||
expect(response?.body.outputs[0].op).to.equal("deleted");
|
||||
});
|
||||
|
||||
H.undoToast().findByText("Successfully deleted").should("be.visible");
|
||||
|
||||
cy.findByTestId("delete-records-bulk-button").should("be.disabled");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setTableEditingEnabledForDB(dbId: number, enabled = true) {
|
||||
return cy.request("PUT", `/api/database/${dbId}`, {
|
||||
settings: {
|
||||
"database-enable-table-editing": enabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openTableBrowser(databaseName: string = "Sample Database") {
|
||||
cy.visit("/browse/databases");
|
||||
cy.wait("@getDatabases");
|
||||
cy.findByTestId("database-browser").findByText(databaseName).click();
|
||||
}
|
||||
|
||||
function getTableEditIcon(tableName: string) {
|
||||
return cy
|
||||
.findByTestId("browse-schemas")
|
||||
.contains(tableName)
|
||||
.realHover()
|
||||
.findByTestId("edit-table-icon");
|
||||
}
|
||||
|
||||
function openTableEdit(tableName: string) {
|
||||
getTableEditIcon(tableName).click();
|
||||
}
|
||||
|
||||
function openEditRowModal(rowIndex: number) {
|
||||
cy.findByTestId("table-root")
|
||||
.findAllByRole("row")
|
||||
.should("have.length.gte", 10)
|
||||
.eq(rowIndex)
|
||||
.within(() => {
|
||||
cy.findAllByTestId("cell-data")
|
||||
.first()
|
||||
.invoke("text")
|
||||
.then((text) => {
|
||||
cy.wrap(text).as("rowId");
|
||||
});
|
||||
|
||||
cy.findAllByTestId("cell-data").first().realHover({
|
||||
scrollBehavior: false,
|
||||
});
|
||||
|
||||
cy.findByTestId("detail-shortcut").click({
|
||||
scrollBehavior: false,
|
||||
});
|
||||
});
|
||||
|
||||
H.modal().findByText("Edit record").should("be.visible");
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
(:require
|
||||
[metabase-enterprise.action-v2.data-editing :as data-editing]
|
||||
[metabase-enterprise.action-v2.models.undo :as undo]
|
||||
[metabase-enterprise.action-v2.validation :as validation]
|
||||
[metabase.actions.args :as actions.args]
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.driver.util :as driver.u]
|
||||
@@ -69,8 +70,21 @@
|
||||
[op pretty-row] (map vector (map :op diffs) pretty-rows)]
|
||||
{:op op, :table-id table-id, :row pretty-row}))
|
||||
|
||||
#_(let [table-id->inputs (group-by :table-id inputs)
|
||||
errors (u/for-map [[table-id inputs] table-id->inputs
|
||||
:let [error (validation/batch-validate table-id (map :row inputs))]
|
||||
:when error]
|
||||
[table-id error])]
|
||||
errors)
|
||||
|
||||
(defn- coerce-inputs [inputs]
|
||||
(let [input->coerced (u/for-map [[table-id inputs] (group-by :table-id inputs)
|
||||
(def inputs inputs)
|
||||
(let [table-id->inputs (group-by :table-id inputs)
|
||||
errors (u/for-map [[table-id inputs] table-id->inputs]
|
||||
[table-id (validation/batch-validate table-id (map :row inputs))])
|
||||
#__ #_(when errors
|
||||
(throw (ex-info "Failed validation" {:errors errors})))
|
||||
input->coerced (u/for-map [[table-id inputs] table-id->inputs
|
||||
:let [coerced (data-editing/apply-coercions table-id (map :row inputs))]
|
||||
input+row (map vector inputs coerced)]
|
||||
input+row)]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
(ns metabase-enterprise.action-v2.validation
|
||||
(:require
|
||||
[metabase-enterprise.action-v2.actions]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defmulti validation-fn
|
||||
(fn [ttype] ttype))
|
||||
|
||||
(defmethod validation-fn :default
|
||||
[_ttype]
|
||||
(constantly true))
|
||||
|
||||
(defmethod validation-fn :type/Number
|
||||
[_ttype]
|
||||
int?)
|
||||
|
||||
(validation-fn :type/Number)
|
||||
|
||||
(defmethod validation-fn :type/Text
|
||||
[_ttype]
|
||||
string?)
|
||||
|
||||
(defmethod validation-fn :type/Temporal
|
||||
[_ttype]
|
||||
;; TODO
|
||||
(constantly true))
|
||||
|
||||
(defmethod validation-fn :type/Boolean
|
||||
[_ttype]
|
||||
boolean?)
|
||||
|
||||
(defmethod validation-fn :type/Collection
|
||||
[_ttype]
|
||||
;; TODO
|
||||
(constantly true))
|
||||
|
||||
(defmethod validation-fn :Coercion/UNIXNanoSeconds->DateTime
|
||||
[_ttype]
|
||||
;; TODO: we need dateobject check
|
||||
(constantly true))
|
||||
|
||||
(defn validate
|
||||
"Validate a value given af field"
|
||||
[value field]
|
||||
(or ((validation-fn #p (:base_type field)) value)
|
||||
(and (nil? value) (not (:database_required field)))))
|
||||
|
||||
(defn- single-validate
|
||||
[row field-name->fields]
|
||||
(not-empty (reduce (fn [errors [column value]]
|
||||
(if (and (get field-name->fields column)
|
||||
(validate value (get field-name->fields column)))
|
||||
errors
|
||||
(into errors {column (format "Invalid value")})))
|
||||
{}
|
||||
row)))
|
||||
(defn batch-validate
|
||||
"Validate rows of a given table"
|
||||
[table-id-or-fields rows]
|
||||
(let [fields #p (if (int? table-id-or-fields)
|
||||
(t2/select-fn->fn :name identity [:model/Field :name :database_required :base_type] :table_id table-id-or-fields)
|
||||
(u/index-by :name table-id-or-fields))]
|
||||
(not-empty (reduce (fn [errors row]
|
||||
(if-let [error (single-validate row fields)]
|
||||
(conj errors error)
|
||||
errors)) [] rows))))
|
||||
@@ -39,3 +39,4 @@ import "./user_provisioning";
|
||||
import "./clean_up";
|
||||
import "./metabot";
|
||||
import "./database_replication";
|
||||
import "./table-editing";
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
Description,
|
||||
Error,
|
||||
Label,
|
||||
} from "metabase/admin/databases/components/DatabaseFeatureComponents";
|
||||
import { DatabaseInfoSection } from "metabase/admin/databases/components/DatabaseInfoSection";
|
||||
import { hasFeature } from "metabase/admin/databases/utils";
|
||||
import Toggle from "metabase/common/components/Toggle";
|
||||
import { getResponseErrorMessage } from "metabase/lib/errors";
|
||||
import { Box, Flex } from "metabase/ui";
|
||||
import type { Database, DatabaseData, DatabaseId } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
DATABASE_TABLE_EDITING_SETTING,
|
||||
isDatabaseTableEditingEnabled,
|
||||
} from "../settings";
|
||||
|
||||
export function AdminDatabaseTableEditingSection({
|
||||
database,
|
||||
updateDatabase,
|
||||
}: {
|
||||
database: Database;
|
||||
updateDatabase: (
|
||||
database: { id: DatabaseId } & Partial<DatabaseData>,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const showTableEditingSection =
|
||||
!!database.id && hasFeature(database, "actions/data-editing");
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleToggle = async (enabled: boolean) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
await updateDatabase({
|
||||
id: database.id,
|
||||
settings: { [DATABASE_TABLE_EDITING_SETTING]: 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={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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { renderWithProviders, screen } from "__support__/ui";
|
||||
import type { Database } from "metabase-types/api";
|
||||
import { createMockDatabase } from "metabase-types/api/mocks";
|
||||
|
||||
import { AdminDatabaseTableEditingSection } from "./AdminDatabaseTableEditingSection";
|
||||
|
||||
const setup = (database: Partial<Database>) => {
|
||||
const mockDatabase = createMockDatabase(database);
|
||||
|
||||
return renderWithProviders(
|
||||
<AdminDatabaseTableEditingSection
|
||||
database={mockDatabase}
|
||||
updateDatabase={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("AdminDatabaseTableEditingSection", () => {
|
||||
it("should render for supported engines", () => {
|
||||
setup({
|
||||
features: ["actions/data-editing"],
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId("database-table-editing-section"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render for unsupported engines", () => {
|
||||
setup({});
|
||||
expect(
|
||||
screen.queryByTestId("database-table-editing-section"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { EnterpriseApi } from "metabase-enterprise/api/api";
|
||||
|
||||
import {
|
||||
type DescribeActionFormRequest,
|
||||
type DescribeActionFormResponse,
|
||||
TableActionId,
|
||||
type TableDeleteRowsRequest,
|
||||
type TableDeleteRowsResponse,
|
||||
type TableInsertRowsRequest,
|
||||
type TableInsertRowsResponse,
|
||||
type TableUpdateRowsRequest,
|
||||
type TableUpdateRowsResponse,
|
||||
} from "./types";
|
||||
|
||||
export const tableEditingApi = EnterpriseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
insertTableRows: builder.mutation<
|
||||
TableInsertRowsResponse,
|
||||
TableInsertRowsRequest
|
||||
>({
|
||||
query: ({ rows, scope }) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/action-v2/execute-bulk`,
|
||||
body: { inputs: rows, scope, action: TableActionId.CreateRow },
|
||||
}),
|
||||
}),
|
||||
updateTableRows: builder.mutation<
|
||||
TableUpdateRowsResponse,
|
||||
TableUpdateRowsRequest
|
||||
>({
|
||||
query: ({ inputs, params, scope }) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/action-v2/execute-bulk`,
|
||||
body: { inputs, params, scope, action: TableActionId.UpdateRow },
|
||||
}),
|
||||
}),
|
||||
deleteTableRows: builder.mutation<
|
||||
TableDeleteRowsResponse,
|
||||
TableDeleteRowsRequest
|
||||
>({
|
||||
query: ({ inputs, scope, params }) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/action-v2/execute-bulk`,
|
||||
body: {
|
||||
inputs,
|
||||
scope,
|
||||
action: TableActionId.DeleteRow,
|
||||
...(params && { params }),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
describeActionForm: builder.mutation<
|
||||
DescribeActionFormResponse,
|
||||
DescribeActionFormRequest
|
||||
>({
|
||||
query: (body) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/action-v2/execute-form`,
|
||||
body,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useInsertTableRowsMutation,
|
||||
useUpdateTableRowsMutation,
|
||||
useDeleteTableRowsMutation,
|
||||
useDescribeActionFormMutation,
|
||||
} = tableEditingApi;
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { TableId } from "metabase-types/api";
|
||||
import type { DatasetColumn, RowValue } from "metabase-types/api/dataset";
|
||||
|
||||
export enum TableActionId {
|
||||
CreateRow = "data-grid.row/create",
|
||||
UpdateRow = "data-grid.row/update",
|
||||
DeleteRow = "data-grid.row/delete",
|
||||
Undo = "data-editing/undo",
|
||||
Redo = "data-editing/redo",
|
||||
}
|
||||
|
||||
export type RowCellsWithPkValue = Record<DatasetColumn["name"], RowValue>;
|
||||
|
||||
export type TableEditingActionScope = { "table-id": TableId };
|
||||
|
||||
type ExecuteOutput<Op extends "created" | "updated" | "deleted"> = {
|
||||
op: Op;
|
||||
row: RowCellsWithPkValue;
|
||||
"table-id": TableId;
|
||||
};
|
||||
|
||||
export type TableInsertRowsRequest = {
|
||||
rows: RowCellsWithPkValue[];
|
||||
scope?: TableEditingActionScope;
|
||||
};
|
||||
|
||||
export type TableInsertRowsResponse = {
|
||||
outputs: ExecuteOutput<"created">[];
|
||||
};
|
||||
|
||||
export type TableUpdateRowsRequest = {
|
||||
inputs: RowCellsWithPkValue[];
|
||||
params: RowCellsWithPkValue;
|
||||
scope?: TableEditingActionScope;
|
||||
};
|
||||
|
||||
export type TableUpdateRowsResponse = {
|
||||
outputs: ExecuteOutput<"updated">[];
|
||||
};
|
||||
|
||||
export type TableDeleteRowsRequest = {
|
||||
inputs: RowCellsWithPkValue[];
|
||||
scope?: TableEditingActionScope;
|
||||
params?: { "delete-children"?: boolean };
|
||||
};
|
||||
|
||||
export type TableDeleteRowsResponse = {
|
||||
outputs: ExecuteOutput<"deleted">[];
|
||||
};
|
||||
|
||||
export enum TableActionFormInputType {
|
||||
Text = "text",
|
||||
Textarea = "textarea",
|
||||
Date = "date",
|
||||
DateTime = "datetime",
|
||||
Dropdown = "dropdown",
|
||||
}
|
||||
|
||||
export type TableActionFormParameter = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
input_type: TableActionFormInputType;
|
||||
semantic_type?: string;
|
||||
optional?: boolean;
|
||||
nullable?: boolean;
|
||||
readonly?: boolean;
|
||||
field_id?: number;
|
||||
human_readable_field_id?: number;
|
||||
database_default?: string;
|
||||
value?: RowValue;
|
||||
};
|
||||
|
||||
export type DescribeActionFormRequest = {
|
||||
action: TableActionId;
|
||||
scope: TableEditingActionScope;
|
||||
input?: RowCellsWithPkValue;
|
||||
};
|
||||
|
||||
export type DescribeActionFormResponse = {
|
||||
title: string;
|
||||
parameters: TableActionFormParameter[];
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { HeadBreadcrumbs } from "metabase/query_builder/components/view/ViewHeader/components";
|
||||
import type { DataSourcePart } from "metabase/query_builder/components/view/ViewHeader/components/QuestionDataSource/utils";
|
||||
import { ViewHeading } from "metabase/query_builder/components/view/ViewSection";
|
||||
import type { Database, Table } from "metabase-types/api";
|
||||
|
||||
type TableBreadcrumbsProps = {
|
||||
database: Database;
|
||||
table: Table;
|
||||
showEditBreadcrumb?: boolean;
|
||||
};
|
||||
|
||||
export const TableBreadcrumbs = ({
|
||||
database,
|
||||
table,
|
||||
showEditBreadcrumb = false,
|
||||
}: TableBreadcrumbsProps) => {
|
||||
const parts = useMemo(() => {
|
||||
const parts: DataSourcePart[] = [
|
||||
{
|
||||
icon: "database" as const,
|
||||
model: "database" as const,
|
||||
name: database.name,
|
||||
href: Urls.browseDatabase(database),
|
||||
},
|
||||
{
|
||||
icon: "table" as const,
|
||||
model: "table" as const,
|
||||
name: table.display_name,
|
||||
href: Urls.tableRowsQuery(database.id, table.id),
|
||||
},
|
||||
];
|
||||
|
||||
if (showEditBreadcrumb) {
|
||||
parts.push({
|
||||
name: t`Edit`,
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
}, [database, table, showEditBreadcrumb]);
|
||||
|
||||
return (
|
||||
<ViewHeading color="medium">
|
||||
<HeadBreadcrumbs parts={parts} variant="head" />
|
||||
</ViewHeading>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { useGetDatabaseQuery, useGetTableQuery } from "metabase/api";
|
||||
import { Group, Skeleton, rem } from "metabase/ui";
|
||||
|
||||
import { TableBreadcrumbs } from "./TableBreadcrumbs";
|
||||
|
||||
type TableHeaderProps = PropsWithChildren<{
|
||||
databaseId: number;
|
||||
tableId: number;
|
||||
showEditBreadcrumb?: boolean;
|
||||
}>;
|
||||
|
||||
export function TableHeader({
|
||||
databaseId,
|
||||
tableId,
|
||||
children,
|
||||
showEditBreadcrumb = false,
|
||||
}: TableHeaderProps) {
|
||||
const { data: table } = useGetTableQuery({ id: tableId });
|
||||
const { data: database } = useGetDatabaseQuery({ id: databaseId });
|
||||
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p="0.5rem 1rem 0.5rem 2rem"
|
||||
mih="4rem"
|
||||
bg="var(--mb-color-background)"
|
||||
>
|
||||
{database && table ? (
|
||||
<TableBreadcrumbs
|
||||
database={database}
|
||||
table={table}
|
||||
showEditBreadcrumb={showEditBreadcrumb}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton height={rem(24)} width={rem(300)} />
|
||||
)}
|
||||
|
||||
<Group gap="md">{children}</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { b64hash_to_utf8, utf8_to_b64url } from "metabase/lib/encoding";
|
||||
import { isPK } from "metabase-lib/v1/types/utils/isa";
|
||||
import type {
|
||||
DatasetColumn,
|
||||
Filter,
|
||||
OrderBy,
|
||||
RowValue,
|
||||
RowValues,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import type { RowCellsWithPkValue } from "../api/types";
|
||||
|
||||
export const serializeMbqlParam = (filterMbql: Array<any>): string => {
|
||||
return utf8_to_b64url(JSON.stringify(filterMbql));
|
||||
};
|
||||
|
||||
export const deserializeTableFilter = (filterParam: string): Filter | null => {
|
||||
const maybeFilter = JSON.parse(b64hash_to_utf8(filterParam));
|
||||
|
||||
// simple and hacky way to test if param is a valid filter
|
||||
return Array.isArray(maybeFilter) && typeof maybeFilter[0] === "string"
|
||||
? (maybeFilter as Filter)
|
||||
: null;
|
||||
};
|
||||
|
||||
export const deserializeTableSorting = (
|
||||
sortingParam: string,
|
||||
): Array<OrderBy> | null => {
|
||||
const maybeParam = JSON.parse(b64hash_to_utf8(sortingParam));
|
||||
|
||||
// simple and hacky way to test if param is valid
|
||||
return Array.isArray(maybeParam) &&
|
||||
Array.isArray(maybeParam[0]) &&
|
||||
typeof maybeParam[0][0] === "string"
|
||||
? (maybeParam as Array<OrderBy>)
|
||||
: null;
|
||||
};
|
||||
|
||||
export const getPkColumns = (
|
||||
columns: DatasetColumn[],
|
||||
): { indexes: number[]; names: string[] } => {
|
||||
const pkColumnIndexes: number[] = [];
|
||||
const pkColumnNames: string[] = [];
|
||||
|
||||
columns.forEach((col, index) => {
|
||||
if (isPK(col)) {
|
||||
pkColumnIndexes.push(index);
|
||||
pkColumnNames.push(col.name);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
indexes: pkColumnIndexes,
|
||||
names: pkColumnNames,
|
||||
};
|
||||
};
|
||||
|
||||
const getPkValuesKeyString = (pkValues: RowValue[]) => pkValues.join("-");
|
||||
|
||||
export const getRowUniqueKeyByPkIndexes = (
|
||||
pkColumnIndexes: number[],
|
||||
rowData: RowValues,
|
||||
): string => {
|
||||
const resultValues = pkColumnIndexes.map((pkIndex) => rowData[pkIndex]);
|
||||
|
||||
return getPkValuesKeyString(resultValues);
|
||||
};
|
||||
|
||||
export const getRowObjectPkUniqueKeyByColumnNames = (
|
||||
pkColumnNames: string[],
|
||||
rowObject: RowCellsWithPkValue,
|
||||
): string => {
|
||||
const pkValues: RowValue[] = pkColumnNames.map(
|
||||
(colName) => rowObject[colName],
|
||||
);
|
||||
|
||||
return getPkValuesKeyString(pkValues);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PLUGIN_TABLE_EDITING } from "metabase/plugins";
|
||||
import { hasPremiumFeature } from "metabase-enterprise/settings";
|
||||
|
||||
import { AdminDatabaseTableEditingSection } from "./admin/AdminDatabaseTableEditingSection";
|
||||
import { getRoutes } from "./routes";
|
||||
import { isDatabaseTableEditingEnabled } from "./settings";
|
||||
import { getTableEditUrl } from "./urls";
|
||||
|
||||
if (hasPremiumFeature("table_data_editing")) {
|
||||
PLUGIN_TABLE_EDITING.isEnabled = () => true;
|
||||
PLUGIN_TABLE_EDITING.isDatabaseTableEditingEnabled =
|
||||
isDatabaseTableEditingEnabled;
|
||||
PLUGIN_TABLE_EDITING.getRoutes = getRoutes;
|
||||
PLUGIN_TABLE_EDITING.getTableEditUrl = getTableEditUrl;
|
||||
PLUGIN_TABLE_EDITING.AdminDatabaseTableEditingSection =
|
||||
AdminDatabaseTableEditingSection;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { TableActionFormParameter } from "../api/types";
|
||||
import { TableActionFormInputType } from "../api/types";
|
||||
|
||||
import {
|
||||
TableActionInputDate,
|
||||
type TableActionInputDateProps,
|
||||
} from "./TableActionInputDate";
|
||||
import {
|
||||
TableActionInputDateTime,
|
||||
type TableActionInputDateTimeProps,
|
||||
} from "./TableActionInputDateTime";
|
||||
import {
|
||||
TableActionInputSearchableSelect,
|
||||
type TableActionInputSearchableSelectProps,
|
||||
} from "./TableActionInputSearchableSelect";
|
||||
import {
|
||||
TableActionInputText,
|
||||
type TableActionInputTextProps,
|
||||
} from "./TableActionInputText";
|
||||
import {
|
||||
TableActionInputTextarea,
|
||||
type TableActionInputTextareaProps,
|
||||
} from "./TableActionInputTextarea";
|
||||
|
||||
type TableActionInputProps =
|
||||
| TableActionInputDateProps
|
||||
| TableActionInputDateTimeProps
|
||||
| TableActionInputTextProps
|
||||
| TableActionInputTextareaProps
|
||||
| TableActionInputSearchableSelectProps;
|
||||
|
||||
export type ParameterActionInputProps = TableActionInputProps & {
|
||||
parameter: TableActionFormParameter;
|
||||
};
|
||||
|
||||
export function ParameterActionInput(props: ParameterActionInputProps) {
|
||||
const { parameter, ...rest } = props;
|
||||
|
||||
switch (parameter.input_type) {
|
||||
case TableActionFormInputType.Date:
|
||||
return <TableActionInputDate {...rest} />;
|
||||
case TableActionFormInputType.DateTime:
|
||||
return <TableActionInputDateTime {...rest} />;
|
||||
case TableActionFormInputType.Textarea:
|
||||
return <TableActionInputTextarea {...rest} />;
|
||||
case TableActionFormInputType.Dropdown:
|
||||
if (parameter.field_id) {
|
||||
return (
|
||||
<TableActionInputSearchableSelect
|
||||
{...rest}
|
||||
fieldId={parameter.field_id}
|
||||
searchFieldId={parameter.human_readable_field_id}
|
||||
isNullable={parameter.nullable}
|
||||
withCreateNew={!parameter.human_readable_field_id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <TableActionInputText {...rest} />;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type KeyboardEvent, useCallback, useState } from "react";
|
||||
|
||||
import { DEFAULT_DATE_STYLE } from "metabase/lib/formatting/datetime-utils";
|
||||
import { DateInput } from "metabase/ui";
|
||||
|
||||
import type { TableActionInputSharedProps } from "./types";
|
||||
import { useDateValueWithoutTimezone } from "./use-date-value-without-timezone";
|
||||
|
||||
export type TableActionInputDateProps = TableActionInputSharedProps & {
|
||||
dateStyle?: string;
|
||||
classNames?: {
|
||||
wrapper?: string;
|
||||
dateTextInputElement?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const TableActionInputDate = ({
|
||||
dateStyle = DEFAULT_DATE_STYLE,
|
||||
autoFocus,
|
||||
inputProps,
|
||||
initialValue,
|
||||
classNames,
|
||||
onChange,
|
||||
onBlur,
|
||||
onEscape,
|
||||
onEnter,
|
||||
}: TableActionInputDateProps) => {
|
||||
const { localDate: initialValueWithoutTimezone, restoreTimezone } =
|
||||
useDateValueWithoutTimezone(initialValue ?? null);
|
||||
|
||||
const [value, setValue] = useState(initialValueWithoutTimezone);
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string | null) => {
|
||||
setValue(value);
|
||||
onChange?.(restoreTimezone(value));
|
||||
},
|
||||
[onChange, restoreTimezone],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
}, [setFocused]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
onBlur?.(restoreTimezone(value));
|
||||
}, [value, onBlur, restoreTimezone]);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onEscape?.(restoreTimezone(value));
|
||||
} else if (event.key === "Enter") {
|
||||
onEnter?.(restoreTimezone(value));
|
||||
}
|
||||
},
|
||||
[value, onEscape, onEnter, restoreTimezone],
|
||||
);
|
||||
|
||||
return (
|
||||
<DateInput
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
valueFormat={dateStyle}
|
||||
classNames={{
|
||||
wrapper: classNames?.wrapper,
|
||||
input: classNames?.dateTextInputElement,
|
||||
}}
|
||||
// Keeps popover mounted when focused to improve time editing UX
|
||||
popoverProps={{ opened: isFocused }}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyUp={handleKeyUp}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { type KeyboardEvent, useCallback, useState } from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_DATE_STYLE,
|
||||
DEFAULT_TIME_STYLE,
|
||||
} from "metabase/lib/formatting/datetime-utils";
|
||||
import { DateTimePicker } from "metabase/ui";
|
||||
|
||||
import type { TableActionInputSharedProps } from "./types";
|
||||
import { useDateValueWithoutTimezone } from "./use-date-value-without-timezone";
|
||||
|
||||
export type TableActionInputDateTimeProps = TableActionInputSharedProps & {
|
||||
dateTimeStyle?: string;
|
||||
classNames?: {
|
||||
wrapper?: string;
|
||||
dateTextInputElement?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_DATETIME_STYLE = `${DEFAULT_DATE_STYLE}, ${DEFAULT_TIME_STYLE}`;
|
||||
|
||||
export const TableActionInputDateTime = ({
|
||||
dateTimeStyle = DEFAULT_DATETIME_STYLE,
|
||||
autoFocus,
|
||||
inputProps,
|
||||
initialValue,
|
||||
classNames,
|
||||
onChange,
|
||||
onEscape,
|
||||
onEnter,
|
||||
onBlur,
|
||||
}: TableActionInputDateTimeProps) => {
|
||||
const { localDate: initialValueWithoutTimezone, restoreTimezone } =
|
||||
useDateValueWithoutTimezone(initialValue ?? null);
|
||||
|
||||
const [value, setValue] = useState(initialValueWithoutTimezone ?? null);
|
||||
const [dropdownOpened, dropdownHandlers] = useDisclosure(autoFocus);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string | null) => {
|
||||
setValue(value);
|
||||
onChange?.(restoreTimezone(value));
|
||||
},
|
||||
[onChange, restoreTimezone],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
onBlur?.(restoreTimezone(value));
|
||||
dropdownHandlers.close();
|
||||
}, [value, onBlur, dropdownHandlers, restoreTimezone]);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
onEnter?.(restoreTimezone(value));
|
||||
dropdownHandlers.close();
|
||||
}
|
||||
},
|
||||
[value, onEnter, dropdownHandlers, restoreTimezone],
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
onEscape?.(restoreTimezone(value));
|
||||
dropdownHandlers.close();
|
||||
}, [value, onEscape, dropdownHandlers, restoreTimezone]);
|
||||
|
||||
return (
|
||||
<DateTimePicker
|
||||
value={value}
|
||||
valueFormat={dateTimeStyle}
|
||||
classNames={{
|
||||
wrapper: classNames?.wrapper,
|
||||
input: classNames?.dateTextInputElement,
|
||||
}}
|
||||
onClick={dropdownHandlers.open}
|
||||
submitButtonProps={{
|
||||
variant: "light",
|
||||
onClick: handleSubmit,
|
||||
}}
|
||||
popoverProps={{
|
||||
opened: dropdownOpened,
|
||||
onDismiss: handleDismiss,
|
||||
styles: {
|
||||
dropdown: {
|
||||
"--popover-padding": "var(--mantine-spacing-md)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
timePickerProps={{ format: "12h", onKeyUp: handleKeyUp }}
|
||||
onChange={handleChange}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Combobox,
|
||||
Icon,
|
||||
Input,
|
||||
Loader,
|
||||
Text,
|
||||
TextInput,
|
||||
useCombobox,
|
||||
} from "metabase/ui";
|
||||
|
||||
import type { TableActionInputSharedProps } from "./types";
|
||||
import { useActionInputSearchableOptions } from "./use-action-input-searchable-options";
|
||||
|
||||
export type TableActionInputSearchableSelectProps =
|
||||
TableActionInputSharedProps & {
|
||||
fieldId: number;
|
||||
searchFieldId?: number;
|
||||
withCreateNew?: boolean;
|
||||
classNames?: {
|
||||
wrapper?: string;
|
||||
selectTextInputElement?: string;
|
||||
selectLabel?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const TableActionInputSearchableSelect = ({
|
||||
autoFocus,
|
||||
inputProps,
|
||||
initialValue,
|
||||
classNames,
|
||||
isNullable,
|
||||
fieldId,
|
||||
searchFieldId,
|
||||
withCreateNew,
|
||||
onBlur,
|
||||
onChange,
|
||||
onEscape,
|
||||
}: TableActionInputSearchableSelectProps) => {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(initialValue?.toString() ?? "");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const focusSearchInput = useCallback(() => {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
const combobox = useCombobox({
|
||||
defaultOpened: autoFocus,
|
||||
onDropdownOpen: focusSearchInput,
|
||||
});
|
||||
|
||||
const { options, isLoading, isFetching } = useActionInputSearchableOptions({
|
||||
initialValue,
|
||||
search,
|
||||
fieldId: fieldId,
|
||||
searchFieldId: searchFieldId,
|
||||
skipSearchQuery: !combobox.dropdownOpened,
|
||||
});
|
||||
|
||||
const handleOptionSubmit = useCallback(
|
||||
(value: string | null) => {
|
||||
setValue(value ?? "");
|
||||
onChange?.(value);
|
||||
onBlur?.(value);
|
||||
combobox.closeDropdown();
|
||||
},
|
||||
[setValue, onChange, onBlur, combobox],
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
onEscape?.(value);
|
||||
}, [onEscape, value]);
|
||||
|
||||
const inputLabel = useMemo(() => {
|
||||
if (value) {
|
||||
// Display remapped label if `searchFieldId` is provided
|
||||
if (searchFieldId) {
|
||||
const option = options.find((item) => item.value === value);
|
||||
if (option) {
|
||||
return (
|
||||
<Text truncate="end" className={classNames?.selectLabel}>
|
||||
{option.label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Display the raw value if `searchFieldId` is not provided (e.g. for FKs)
|
||||
else {
|
||||
return (
|
||||
<Text truncate="end" className={classNames?.selectLabel}>
|
||||
{value}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!value && inputProps?.placeholder) {
|
||||
return (
|
||||
<Input.Placeholder c="text-light" className={classNames?.selectLabel}>
|
||||
{inputProps.placeholder}
|
||||
</Input.Placeholder>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Input.Placeholder c="text-light" className={classNames?.selectLabel}>
|
||||
{t`Loading...`}
|
||||
</Input.Placeholder>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to the raw value instead of a label
|
||||
if (value) {
|
||||
return (
|
||||
<Text truncate="end" className={classNames?.selectLabel}>
|
||||
{value}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
isLoading,
|
||||
value,
|
||||
inputProps?.placeholder,
|
||||
options,
|
||||
searchFieldId,
|
||||
classNames?.selectLabel,
|
||||
]);
|
||||
|
||||
const shouldDisplayClearButton =
|
||||
isNullable && !!value && !inputProps?.disabled;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
position="bottom-start"
|
||||
onOptionSubmit={handleOptionSubmit}
|
||||
onDismiss={handleDismiss}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<Input
|
||||
component="button"
|
||||
type="button"
|
||||
role="combobox"
|
||||
pointer
|
||||
onClick={() => combobox.openDropdown()}
|
||||
classNames={{
|
||||
wrapper: classNames?.wrapper,
|
||||
input: classNames?.selectTextInputElement,
|
||||
}}
|
||||
style={{ overflow: "hidden" }} // for label truncation
|
||||
rightSectionPointerEvents="all"
|
||||
rightSection={
|
||||
shouldDisplayClearButton && (
|
||||
<Icon
|
||||
name="close"
|
||||
color="var(--mb-color-text-light)"
|
||||
onClick={() => handleOptionSubmit(null)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...inputProps}
|
||||
>
|
||||
{inputLabel}
|
||||
</Input>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown mah="none" miw={250}>
|
||||
<Box p="0.5rem" pb="0" bg="white" pos="sticky" top={0}>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
placeholder={t`Search the list`}
|
||||
leftSection={<Icon name="search" />}
|
||||
rightSection={isFetching ? <Loader size="xs" /> : undefined}
|
||||
// Auto focus works when dropdown is initially rendered as opened (e.g. inline editing)
|
||||
// whereas `onDropdownOpen` is called only when dropdown is opened by clicking the button (e.g. modal editing)
|
||||
autoFocus
|
||||
ref={searchInputRef}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Combobox.Options p="0.5rem">
|
||||
{options.length > 0 ? (
|
||||
options.map((item) => (
|
||||
<Combobox.Option
|
||||
selected={value === item.value}
|
||||
value={item.value}
|
||||
key={item.value}
|
||||
>
|
||||
{item.label}
|
||||
{searchFieldId ? ` [${item.value}]` : ""}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : isLoading ? (
|
||||
<Combobox.Empty>{t`Loading values...`}</Combobox.Empty>
|
||||
) : withCreateNew ? (
|
||||
<Combobox.Option value={search}>
|
||||
{t`Add option:`} <strong>{search}</strong>
|
||||
</Combobox.Option>
|
||||
) : (
|
||||
<Combobox.Empty>{t`Nothing found`}</Combobox.Empty>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Input } from "metabase/ui";
|
||||
|
||||
import type { TableActionInputSharedProps } from "./types";
|
||||
|
||||
export type TableActionInputTextProps = TableActionInputSharedProps & {
|
||||
classNames?: {
|
||||
wrapper?: string;
|
||||
textInputElement?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const TableActionInputText = ({
|
||||
autoFocus,
|
||||
inputProps,
|
||||
initialValue,
|
||||
classNames,
|
||||
onEscape,
|
||||
onEnter,
|
||||
onBlur,
|
||||
onChange,
|
||||
}: TableActionInputTextProps) => {
|
||||
return (
|
||||
<Input
|
||||
defaultValue={(initialValue ?? "").toString()}
|
||||
autoFocus={autoFocus}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
onEscape?.(event.currentTarget.value);
|
||||
} else if (event.key === "Enter") {
|
||||
onEnter?.(event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => {
|
||||
onChange?.(event.currentTarget.value);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event.currentTarget.value);
|
||||
}}
|
||||
classNames={{
|
||||
wrapper: classNames?.wrapper,
|
||||
input: classNames?.textInputElement,
|
||||
}}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Textarea } from "metabase/ui";
|
||||
|
||||
import type { TableActionInputSharedProps } from "./types";
|
||||
|
||||
const DEFAULT_MAX_ROWS = 8;
|
||||
const DEFAULT_MIN_ROWS = 1;
|
||||
|
||||
export type TableActionInputTextareaProps = TableActionInputSharedProps & {
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
classNames?: {
|
||||
wrapper?: string;
|
||||
textInputElement?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const TableActionInputTextarea = ({
|
||||
minRows = DEFAULT_MIN_ROWS,
|
||||
maxRows = DEFAULT_MAX_ROWS,
|
||||
autoFocus,
|
||||
inputProps,
|
||||
initialValue,
|
||||
classNames,
|
||||
onBlur,
|
||||
onEscape,
|
||||
onChange,
|
||||
}: TableActionInputTextareaProps) => {
|
||||
return (
|
||||
<Textarea
|
||||
defaultValue={(initialValue ?? "").toString()}
|
||||
autoFocus={autoFocus}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
onEscape?.(event.currentTarget.value);
|
||||
}
|
||||
// We skip handling Enter here because it might break the
|
||||
// default behavior of the Textarea and we don't want to
|
||||
// override it.
|
||||
}}
|
||||
onChange={(event) => {
|
||||
onChange?.(event.currentTarget.value);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event.currentTarget.value);
|
||||
}}
|
||||
classNames={{
|
||||
wrapper: classNames?.wrapper,
|
||||
input: classNames?.textInputElement,
|
||||
}}
|
||||
{...inputProps}
|
||||
// Match the line height for single line text (should look like a regular input)
|
||||
styles={{ input: { lineHeight: "165%" } }}
|
||||
maxRows={maxRows}
|
||||
minRows={minRows}
|
||||
autosize
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { MantineSize, TextInputProps } from "metabase/ui";
|
||||
|
||||
export type TableActionInputSharedProps = {
|
||||
autoFocus?: boolean;
|
||||
initialValue?: string;
|
||||
inputProps?: InputProps;
|
||||
isNullable?: boolean;
|
||||
onChange?: (value: string | null) => unknown;
|
||||
onBlur?: (value: string | null) => unknown;
|
||||
onEscape?: (value: string | null) => unknown;
|
||||
onEnter?: (value: string | null) => unknown;
|
||||
onSelect?: (value: string | null) => unknown;
|
||||
};
|
||||
|
||||
type InputProps = {
|
||||
size?: MantineSize;
|
||||
variant?: TextInputProps["variant"];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
error?: TextInputProps["error"];
|
||||
rightSection?: TextInputProps["rightSection"];
|
||||
rightSectionPointerEvents?: TextInputProps["rightSectionPointerEvents"];
|
||||
"data-testid"?: string;
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
useGetRemappedFieldValueQuery,
|
||||
useSearchFieldValuesQuery,
|
||||
} from "metabase/api/field";
|
||||
import { useDebouncedValue } from "metabase/common/hooks/use-debounced-value";
|
||||
import { getFieldOptions } from "metabase/querying/filters/components/FilterValuePicker/utils";
|
||||
import type { FieldValue, RowValue } from "metabase-types/api";
|
||||
|
||||
const SEARCH_LIMIT_DEFAULT = 20;
|
||||
const SEARCH_DEBOUNCE = 500;
|
||||
|
||||
type UseActionInputSearchableOptionsProps = {
|
||||
initialValue?: RowValue;
|
||||
search: string;
|
||||
fieldId: number;
|
||||
searchFieldId?: number;
|
||||
limit?: number;
|
||||
skipSearchQuery?: boolean;
|
||||
};
|
||||
|
||||
export function useActionInputSearchableOptions({
|
||||
initialValue,
|
||||
search,
|
||||
fieldId,
|
||||
searchFieldId = fieldId,
|
||||
limit = SEARCH_LIMIT_DEFAULT,
|
||||
skipSearchQuery = false,
|
||||
}: UseActionInputSearchableOptionsProps) {
|
||||
const debouncedSearch = useDebouncedValue(search, SEARCH_DEBOUNCE);
|
||||
const [searchValueToFetch, setSearchValueToFetch] = useState(debouncedSearch);
|
||||
|
||||
// Values that are fetched without search value (all values with some limit)
|
||||
const [initialFieldValues, setInitialFieldValues] = useState<
|
||||
FieldValue[] | undefined
|
||||
>(undefined);
|
||||
|
||||
// Local search is performed if there are less than `limit` initial values
|
||||
const shouldPerformLocalSearch =
|
||||
initialFieldValues && initialFieldValues.length < limit;
|
||||
|
||||
// Update the search value to fetch on every debounced search value change
|
||||
// if the local search is disabled
|
||||
useEffect(() => {
|
||||
if (!shouldPerformLocalSearch) {
|
||||
setSearchValueToFetch(debouncedSearch);
|
||||
}
|
||||
}, [debouncedSearch, shouldPerformLocalSearch]);
|
||||
|
||||
const {
|
||||
data: fieldValues,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useSearchFieldValuesQuery(
|
||||
{
|
||||
fieldId: fieldId,
|
||||
searchFieldId: searchFieldId,
|
||||
// empty string is invalid from the API perspective
|
||||
value: searchValueToFetch === "" ? undefined : searchValueToFetch,
|
||||
limit: SEARCH_LIMIT_DEFAULT,
|
||||
},
|
||||
{ skip: skipSearchQuery },
|
||||
);
|
||||
|
||||
const { data: initialFieldValue } = useGetRemappedFieldValueQuery(
|
||||
{
|
||||
fieldId,
|
||||
remappedFieldId: searchFieldId,
|
||||
value: initialValue?.toString() ?? "",
|
||||
},
|
||||
{ skip: !initialValue || fieldId === searchFieldId },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialFieldValues && fieldValues) {
|
||||
setInitialFieldValues(fieldValues);
|
||||
}
|
||||
}, [initialFieldValues, fieldValues]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (fieldValues) {
|
||||
const mergedFieldValues = fieldValues.slice();
|
||||
if (initialFieldValue) {
|
||||
const hasInitialValueInFieldValues = !!mergedFieldValues.find(
|
||||
([value]) => value === initialFieldValue[0],
|
||||
);
|
||||
|
||||
if (!hasInitialValueInFieldValues) {
|
||||
mergedFieldValues.push(initialFieldValue);
|
||||
}
|
||||
}
|
||||
|
||||
const options = getFieldOptions(mergedFieldValues);
|
||||
|
||||
if (shouldPerformLocalSearch) {
|
||||
const searchValue = search.toLowerCase().trim();
|
||||
return options.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(searchValue) ||
|
||||
item.value.toLowerCase().includes(searchValue),
|
||||
);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
if (initialFieldValue) {
|
||||
return getFieldOptions([initialFieldValue]);
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [shouldPerformLocalSearch, fieldValues, initialFieldValue, search]);
|
||||
|
||||
return {
|
||||
options,
|
||||
isLoading,
|
||||
isFetching:
|
||||
(isFetching || search !== debouncedSearch) && !shouldPerformLocalSearch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
export const useDateValueWithoutTimezone = (
|
||||
value: string | null,
|
||||
valueFormat: string = "YYYY-MM-DD HH:mm:ss",
|
||||
) => {
|
||||
const { timezoneOffsetString, localDate } = useMemo(() => {
|
||||
// If no value, we use browser timezone
|
||||
const timezoneOffsetString = dayjs
|
||||
.parseZone(value ?? new Date())
|
||||
.format("Z");
|
||||
|
||||
return {
|
||||
timezoneOffsetString,
|
||||
localDate: value ? dayjs.parseZone(value).format(valueFormat) : null,
|
||||
};
|
||||
}, [value, valueFormat]);
|
||||
|
||||
const restoreTimezone = useCallback(
|
||||
(value: string | null) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dayjs(value).format(valueFormat) + timezoneOffsetString;
|
||||
},
|
||||
[valueFormat, timezoneOffsetString],
|
||||
);
|
||||
|
||||
return { localDate, timezoneOffsetString, restoreTimezone };
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Route } from "react-router";
|
||||
|
||||
import { EditTableDataContainer } from "./table-edit/EditTableDataContainer";
|
||||
|
||||
export function getRoutes() {
|
||||
return (
|
||||
<Route
|
||||
path="databases/:dbId/tables/:tableId/edit(/:objectId)"
|
||||
component={EditTableDataContainer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Database } from "metabase-types/api";
|
||||
|
||||
export const DATABASE_TABLE_EDITING_SETTING = "database-enable-table-editing";
|
||||
|
||||
export function isDatabaseTableEditingEnabled(database: Database) {
|
||||
return database.settings?.[DATABASE_TABLE_EDITING_SETTING] ?? false;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.container {
|
||||
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);
|
||||
padding: 0.5rem 1.5rem;
|
||||
padding-left: 0.5rem;
|
||||
height: 2.5rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import type { Location } from "history";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { msgid, ngettext, t } from "ttag";
|
||||
|
||||
import { useGetDatabaseQuery } from "metabase/api";
|
||||
import { GenericError } from "metabase/common/components/ErrorPages";
|
||||
import { useCloseNavbarOnMount } from "metabase/common/hooks/use-close-navbar-on-mount";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { getUserIsAdmin } from "metabase/selectors/user";
|
||||
import { Box, Flex, Stack, Text } from "metabase/ui";
|
||||
import { extractRemappedColumns } from "metabase/visualizations";
|
||||
|
||||
import type { TableEditingActionScope } from "../api/types";
|
||||
import { TableHeader } from "../common/TableHeader";
|
||||
import { getRowCountMessage } from "../common/getRowCountMessage";
|
||||
import { isDatabaseTableEditingEnabled } from "../settings";
|
||||
|
||||
import S from "./EditTableDataContainer.module.css";
|
||||
import { EditTableDataHeader } from "./EditTableDataHeader";
|
||||
import { EditTableDataOverlay } from "./EditTableDataOverlay";
|
||||
import { EditTableDataGrid } from "./data-grid/EditTableDataGrid";
|
||||
import { CreateRowActionFormModal } from "./modals/CreateRowActionFormModal";
|
||||
import { DeleteBulkRowConfirmationModal } from "./modals/DeleteBulkRowConfirmationModal";
|
||||
import { UpdateRowActionFormModal } from "./modals/UpdateRowActionFormModal";
|
||||
import { useEditTableData } from "./use-edit-table-data";
|
||||
import { useEditTableLoadingOverlay } from "./use-edit-table-loading-overlay";
|
||||
import { useTableCreateRow } from "./use-table-create-row";
|
||||
import { useTableCRUD } from "./use-table-crud";
|
||||
import {
|
||||
getRowInputAndParamsFromRow,
|
||||
useTableExpandedUpdateRow,
|
||||
} from "./use-table-expanded-update-row";
|
||||
import { useEditingTableRowSelection } from "./use-table-row-selection";
|
||||
import { useTableEditingStateAdHocQueryUpdateStrategy } from "./use-table-state-adhoc-query-update-strategy";
|
||||
|
||||
type EditTableDataContainerProps = {
|
||||
params: {
|
||||
dbId: string;
|
||||
tableId: string;
|
||||
objectId?: string;
|
||||
};
|
||||
location: Location<{ filter?: string }>;
|
||||
};
|
||||
|
||||
export const EditTableDataContainer = ({
|
||||
params: { dbId: dbIdParam, tableId: tableIdParam },
|
||||
location,
|
||||
}: EditTableDataContainerProps) => {
|
||||
useCloseNavbarOnMount();
|
||||
|
||||
const databaseId = parseInt(dbIdParam, 10);
|
||||
const tableId = parseInt(tableIdParam, 10);
|
||||
|
||||
const { data: database } = useGetDatabaseQuery({ id: databaseId });
|
||||
const {
|
||||
data: rawDataset,
|
||||
isLoading,
|
||||
isFetching,
|
||||
tableQuestion,
|
||||
tableQuery,
|
||||
getColumnSortDirection,
|
||||
handleTableQuestionChange,
|
||||
handleChangeColumnSort,
|
||||
} = useEditTableData({
|
||||
tableId,
|
||||
databaseId,
|
||||
location,
|
||||
});
|
||||
|
||||
const datasetData = useMemo(() => {
|
||||
return rawDataset?.data
|
||||
? extractRemappedColumns(rawDataset.data)
|
||||
: undefined;
|
||||
}, [rawDataset]);
|
||||
|
||||
const stateUpdateStrategy =
|
||||
useTableEditingStateAdHocQueryUpdateStrategy(tableQuery);
|
||||
|
||||
const scope = useMemo<TableEditingActionScope>(
|
||||
() => ({ "table-id": tableId }),
|
||||
[tableId],
|
||||
);
|
||||
|
||||
const {
|
||||
isInserting,
|
||||
isUpdating,
|
||||
isDeleting,
|
||||
handleRowCreate,
|
||||
handleRowUpdate,
|
||||
handleRowDelete,
|
||||
handleRowDeleteBulk,
|
||||
} = useTableCRUD({
|
||||
scope,
|
||||
datasetData,
|
||||
stateUpdateStrategy,
|
||||
});
|
||||
|
||||
const loadingOverlayProps = useEditTableLoadingOverlay({
|
||||
isDatasetLoading: isLoading,
|
||||
isDatasetFetching: isFetching,
|
||||
});
|
||||
|
||||
const {
|
||||
isCreateRowModalOpen,
|
||||
openCreateRowModal,
|
||||
closeCreateRowModal,
|
||||
formDescription: createFromDescription,
|
||||
} = useTableCreateRow({ scope });
|
||||
|
||||
const {
|
||||
expandedRow,
|
||||
handleExpandRow,
|
||||
handleExpandedRowUpdate,
|
||||
handleExpandedRowDelete,
|
||||
closeExpandedRow,
|
||||
formDescription: updateFormDescription,
|
||||
} = useTableExpandedUpdateRow({
|
||||
scope,
|
||||
datasetData,
|
||||
handleRowUpdate,
|
||||
handleRowDelete,
|
||||
});
|
||||
|
||||
const [deleteBulkRequested, deleteBulkModalController] = useDisclosure(false);
|
||||
const { rowSelection, selectedRowIndices, setRowSelection } =
|
||||
useEditingTableRowSelection();
|
||||
|
||||
const handleDeleteBulkSubmit = useCallback(async () => {
|
||||
if (!datasetData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { cols, rows } = datasetData;
|
||||
const inputs = selectedRowIndices.map(
|
||||
(index) => getRowInputAndParamsFromRow(cols, rows[index]).input,
|
||||
);
|
||||
|
||||
const success = await handleRowDeleteBulk(inputs);
|
||||
if (success) {
|
||||
setRowSelection({});
|
||||
deleteBulkModalController.close();
|
||||
}
|
||||
}, [
|
||||
datasetData,
|
||||
setRowSelection,
|
||||
selectedRowIndices,
|
||||
handleRowDeleteBulk,
|
||||
deleteBulkModalController,
|
||||
]);
|
||||
|
||||
const isAdmin = useSelector(getUserIsAdmin);
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<EditTablePermissionError
|
||||
databaseId={databaseId}
|
||||
tableId={tableId}
|
||||
title={t`You are not allowed to edit this table`}
|
||||
message={t`Only admin users are authorized to edit tables`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (database && !isDatabaseTableEditingEnabled(database)) {
|
||||
return (
|
||||
<EditTablePermissionError
|
||||
databaseId={databaseId}
|
||||
tableId={tableId}
|
||||
title={t`Table editing is not enabled for this database`}
|
||||
message={t`Please ask your admin to enable table editing`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0} data-testid="edit-table-data-root" className={S.container}>
|
||||
<EditTableDataHeader
|
||||
databaseId={databaseId}
|
||||
tableId={tableId}
|
||||
question={tableQuestion}
|
||||
onQuestionChange={handleTableQuestionChange}
|
||||
onCreate={openCreateRowModal}
|
||||
onRequestDeleteBulk={deleteBulkModalController.open}
|
||||
canDeleteBulk={selectedRowIndices.length > 0}
|
||||
/>
|
||||
<Box pos="relative" className={S.gridWrapper}>
|
||||
<EditTableDataOverlay {...loadingOverlayProps} />
|
||||
{datasetData && (
|
||||
<EditTableDataGrid
|
||||
handleRowUpdate={handleRowUpdate}
|
||||
updateFormDescription={updateFormDescription}
|
||||
data={datasetData}
|
||||
getColumnSortDirection={getColumnSortDirection}
|
||||
onColumnSort={handleChangeColumnSort}
|
||||
onRowExpandClick={handleExpandRow}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{rawDataset && (
|
||||
<Flex className={S.gridFooter}>
|
||||
<Text fw="bold" size="md" c="inherit" component="span">
|
||||
{selectedRowIndices.length > 0 &&
|
||||
ngettext(
|
||||
msgid`Selected ${selectedRowIndices.length} row`,
|
||||
`Selected ${selectedRowIndices.length} rows`,
|
||||
selectedRowIndices.length,
|
||||
)}
|
||||
</Text>
|
||||
<Text fw="bold" size="md" c="inherit" component="span">
|
||||
{getRowCountMessage(rawDataset)}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<CreateRowActionFormModal
|
||||
opened={isCreateRowModalOpen}
|
||||
description={createFromDescription}
|
||||
onClose={closeCreateRowModal}
|
||||
onSubmit={handleRowCreate}
|
||||
isLoading={isInserting}
|
||||
/>
|
||||
|
||||
<UpdateRowActionFormModal
|
||||
opened={!!expandedRow}
|
||||
initialValues={expandedRow?.params}
|
||||
description={updateFormDescription}
|
||||
onClose={closeExpandedRow}
|
||||
onSubmit={handleExpandedRowUpdate}
|
||||
onDelete={handleExpandedRowDelete}
|
||||
isLoading={isUpdating}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
<DeleteBulkRowConfirmationModal
|
||||
opened={deleteBulkRequested}
|
||||
rowCount={selectedRowIndices.length}
|
||||
onConfirm={handleDeleteBulkSubmit}
|
||||
isLoading={isDeleting}
|
||||
onClose={deleteBulkModalController.close}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type EditTablePermissionErrorProps = {
|
||||
databaseId: number;
|
||||
tableId: number;
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function EditTablePermissionError({
|
||||
databaseId,
|
||||
tableId,
|
||||
title,
|
||||
message,
|
||||
}: EditTablePermissionErrorProps) {
|
||||
return (
|
||||
<Stack
|
||||
gap={0}
|
||||
data-testid="edit-table-data-restricted"
|
||||
className={S.container}
|
||||
>
|
||||
<TableHeader
|
||||
databaseId={databaseId}
|
||||
tableId={tableId}
|
||||
showEditBreadcrumb
|
||||
/>
|
||||
<GenericError title={title} message={message} details={undefined} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/* copied from metabase/query_builder/components/view/ViewHeader/ViewTitleHeader.module.css */
|
||||
|
||||
.FilterButton {
|
||||
&[data-variant="default"] {
|
||||
transition: background 300ms linear;
|
||||
|
||||
&:hover {
|
||||
color: var(--mb-color-filter);
|
||||
background-color: color-mix(in srgb, var(--mb-color-filter) 10%, white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.FilterButtonAttachment {
|
||||
&[data-variant="default"] {
|
||||
padding: 0.5rem;
|
||||
transition: background 300ms linear;
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(in srgb, var(--mb-color-filter) 10%, white);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-expanded="true"] {
|
||||
.FilterCountChip {
|
||||
color: var(--mb-color-text-selected);
|
||||
background-color: var(--mb-color-filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.FilterCountChip {
|
||||
font-size: 0.6875rem;
|
||||
border-radius: 10px;
|
||||
line-height: 1rem;
|
||||
padding-inline: 0.5rem;
|
||||
color: var(--mb-color-text-primary);
|
||||
background-color: color-mix(in srgb, var(--mb-color-filter) 15%, white);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import cx from "classnames";
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { getFilterItems } from "metabase/querying/filters/components/FilterPanel/utils";
|
||||
import { MultiStageFilterPicker } from "metabase/querying/filters/components/FilterPicker/MultiStageFilterPicker";
|
||||
import { Button, Icon, Popover, Tooltip } from "metabase/ui";
|
||||
import type * as Lib from "metabase-lib";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
|
||||
import ViewTitleHeaderS from "./EditTableDataFilterButton.module.css";
|
||||
|
||||
interface EditTableDataFilterButtonProps {
|
||||
className?: string;
|
||||
question: Question;
|
||||
isExpanded: boolean;
|
||||
onExpand: () => void;
|
||||
onCollapse: () => void;
|
||||
onQuestionChange: (newQuestion: Question) => void;
|
||||
}
|
||||
|
||||
// copied from metabase/query_builder/components/view/ViewHeader/components/FilterHeaderButton.tsx
|
||||
export function EditTableDataFilterButton({
|
||||
className,
|
||||
question,
|
||||
isExpanded,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onQuestionChange,
|
||||
}: EditTableDataFilterButtonProps) {
|
||||
const [isOpened, { close, toggle }] = useDisclosure();
|
||||
const query = question.query();
|
||||
const items = useMemo(() => (query ? getFilterItems(query) : []), [query]);
|
||||
const hasFilters = items.length > 0;
|
||||
const label = isExpanded ? t`Hide filters` : t`Show filters`;
|
||||
|
||||
const handleQueryChange = (newQuery: Lib.Query) => {
|
||||
const newQuestion = question.setQuery(newQuery);
|
||||
onQuestionChange(newQuestion);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button.Group>
|
||||
<Popover opened={isOpened} position="bottom-start" onDismiss={close}>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
className={cx(className, ViewTitleHeaderS.FilterButton)}
|
||||
leftSection={<Icon name={hasFilters ? "filter_plus" : "filter"} />}
|
||||
onClick={toggle}
|
||||
data-testid="table-edit-filter-header"
|
||||
>
|
||||
{t`Filter`}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<MultiStageFilterPicker
|
||||
query={query}
|
||||
canAppendStage={false}
|
||||
onChange={handleQueryChange}
|
||||
onClose={close}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
{hasFilters && (
|
||||
<Tooltip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className={ViewTitleHeaderS.FilterButtonAttachment}
|
||||
onClick={isExpanded ? onCollapse : onExpand}
|
||||
data-testid="filters-visibility-control"
|
||||
data-expanded={isExpanded}
|
||||
style={{ borderLeft: "none" }} // mantine puts a double border between buttons in groups
|
||||
>
|
||||
<div className={ViewTitleHeaderS.FilterCountChip}>
|
||||
{items?.length}
|
||||
</div>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button.Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { QuestionFiltersHeader } from "metabase/query_builder/components/view/ViewHeader/components";
|
||||
import { getFilterItems } from "metabase/querying/filters/components/FilterPanel/utils";
|
||||
import { Button, Icon, Stack } from "metabase/ui";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
|
||||
import { TableHeader } from "../common/TableHeader";
|
||||
|
||||
import { EditTableDataFilterButton } from "./EditTableDataFilterButton";
|
||||
|
||||
interface EditTableDataHeaderProps {
|
||||
databaseId: number;
|
||||
tableId: number;
|
||||
question?: Question;
|
||||
onQuestionChange: (newQuestion: Question) => void;
|
||||
onCreate: () => void;
|
||||
onRequestDeleteBulk: () => void;
|
||||
canDeleteBulk: boolean;
|
||||
}
|
||||
|
||||
export const EditTableDataHeader = ({
|
||||
databaseId,
|
||||
tableId,
|
||||
question,
|
||||
onQuestionChange,
|
||||
onCreate,
|
||||
onRequestDeleteBulk,
|
||||
canDeleteBulk,
|
||||
}: EditTableDataHeaderProps) => {
|
||||
const hasFilters = useMemo(
|
||||
() =>
|
||||
question?.query() ? getFilterItems(question.query()).length > 0 : false,
|
||||
[question],
|
||||
);
|
||||
|
||||
const [
|
||||
areFiltersExpanded,
|
||||
{ open: onExpandFilters, close: onCollapseFilters },
|
||||
] = useDisclosure(hasFilters);
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<TableHeader databaseId={databaseId} tableId={tableId} showEditBreadcrumb>
|
||||
{question && (
|
||||
<EditTableDataFilterButton
|
||||
question={question}
|
||||
isExpanded={areFiltersExpanded}
|
||||
onExpand={onExpandFilters}
|
||||
onCollapse={onCollapseFilters}
|
||||
onQuestionChange={onQuestionChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
leftSection={<Icon name="add" />}
|
||||
variant="filled"
|
||||
onClick={onCreate}
|
||||
data-testid="new-record-button"
|
||||
>{t`New record`}</Button>
|
||||
|
||||
<Button
|
||||
leftSection={<Icon name="trash" />}
|
||||
variant="filled"
|
||||
color="error"
|
||||
onClick={onRequestDeleteBulk}
|
||||
disabled={!canDeleteBulk}
|
||||
data-testid="delete-records-bulk-button"
|
||||
>{t`Delete`}</Button>
|
||||
</TableHeader>
|
||||
{question && (
|
||||
<QuestionFiltersHeader
|
||||
expanded={areFiltersExpanded}
|
||||
question={question}
|
||||
updateQuestion={onQuestionChange}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
/* +1 for DataGrid `headerContainer` (which has z-index: 2) */
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
color: var(--mb-color-brand);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--mb-color-bg-white) 75%,
|
||||
transparent
|
||||
);
|
||||
transition:
|
||||
opacity 0.5s,
|
||||
background-color 0.5s;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--mb-color-brand);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Flex, Loader, Text } from "metabase/ui";
|
||||
|
||||
import S from "./EditTableDataOverlay.module.css";
|
||||
|
||||
type EditTableDataOverlayProps = {
|
||||
message?: string;
|
||||
show?: boolean;
|
||||
};
|
||||
|
||||
export function EditTableDataOverlay({
|
||||
show = true,
|
||||
message,
|
||||
}: EditTableDataOverlayProps) {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex className={S.overlay}>
|
||||
<Loader size="lg" color="currentColor" />
|
||||
{message && <Text className={S.message}>{message}</Text>}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** DataGrid overrides */
|
||||
|
||||
.tableRoot {
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
&:hover .tableBodyCell {
|
||||
background-color: var(--mb-base-color-blue-10);
|
||||
}
|
||||
|
||||
&:hover .tableHeaderCell {
|
||||
/* Draggable hover highlight for `SortableHeader` */
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&[data-row-selected="true"] .tableBodyCell {
|
||||
background-color: var(--mb-base-color-blue-10);
|
||||
}
|
||||
}
|
||||
|
||||
.tableHeaderContainer {
|
||||
--cell-bg-color: var(--mb-base-color-orion-5);
|
||||
--cell-hover-bg-color: var(--mb-base-color-orion-5);
|
||||
|
||||
background-color: var(--mb-base-color-orion-5);
|
||||
|
||||
& .tableRow {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.tableHeaderCell {
|
||||
border-right: 1px solid var(--mb-color-border);
|
||||
|
||||
&[data-column-id="__MB_ROW_SELECT"] {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
&[data-column-id="__MB_ROW_SELECT"] {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.headerCellContainer {
|
||||
background-color: var(--cell-bg-color);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.sortIndicator {
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { OnChangeFn, RowSelectionState } from "@tanstack/react-table";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import {
|
||||
DataGrid,
|
||||
type RowIdColumnOptions,
|
||||
useDataGridInstance,
|
||||
} from "metabase/data-grid";
|
||||
import { ROW_ID_COLUMN_ID } from "metabase/data-grid/constants";
|
||||
import type { DatasetColumn, DatasetData } from "metabase-types/api";
|
||||
|
||||
import type { DescribeActionFormResponse } from "../../api/types";
|
||||
import type { TableDataGetColumnSortDirection } from "../use-edit-table-data";
|
||||
import type { TableRowUpdateHandler } from "../use-table-crud";
|
||||
|
||||
import S from "./EditTableDataGrid.module.css";
|
||||
import { useEditDataDridColumnOptions } from "./use-edit-datagrid-column-options";
|
||||
import {
|
||||
ROW_SELECT_COLUMN_ID,
|
||||
useTableColumnRowSelect,
|
||||
} from "./use-table-column-row-select";
|
||||
import { useTableEditingCell } from "./use-table-editing-cell";
|
||||
|
||||
type EditTableDataGridProps = {
|
||||
data: DatasetData;
|
||||
updateFormDescription?: DescribeActionFormResponse;
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
|
||||
handleRowUpdate: TableRowUpdateHandler;
|
||||
onRowExpandClick: (rowIndex: number) => void;
|
||||
getColumnSortDirection?: TableDataGetColumnSortDirection;
|
||||
onColumnSort?: (field: DatasetColumn) => void;
|
||||
};
|
||||
|
||||
export const EditTableDataGrid = ({
|
||||
data,
|
||||
updateFormDescription,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
onRowExpandClick,
|
||||
getColumnSortDirection,
|
||||
onColumnSort,
|
||||
handleRowUpdate,
|
||||
}: EditTableDataGridProps) => {
|
||||
const { cols, rows } = data;
|
||||
|
||||
const {
|
||||
editingCell,
|
||||
getCellState,
|
||||
handleSelectEditingCell,
|
||||
handleCancelEditing,
|
||||
handleUpdateEditingCell,
|
||||
} = useTableEditingCell({
|
||||
data,
|
||||
handleRowUpdate,
|
||||
});
|
||||
|
||||
const columnsOptions = useEditDataDridColumnOptions({
|
||||
cols,
|
||||
editingCell,
|
||||
updateFormDescription,
|
||||
getColumnSortDirection,
|
||||
onColumnSort,
|
||||
onCancelEditing: handleCancelEditing,
|
||||
onValueUpdated: handleUpdateEditingCell,
|
||||
getCellState,
|
||||
});
|
||||
|
||||
const columnRowSelectOptions = useTableColumnRowSelect(true);
|
||||
|
||||
const rowIdColumnOptions: RowIdColumnOptions = useMemo(
|
||||
() => ({
|
||||
variant: "expandButton",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleBodyCellClick = useCallback(
|
||||
(
|
||||
_event: React.MouseEvent<HTMLDivElement>,
|
||||
rowIndex: number,
|
||||
columnId: string,
|
||||
) => {
|
||||
if (columnId === ROW_ID_COLUMN_ID) {
|
||||
onRowExpandClick(rowIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
editingCell?.rowIndex === rowIndex &&
|
||||
editingCell?.columnId === columnId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parameter = updateFormDescription?.parameters.find(
|
||||
({ id }) => id === columnId,
|
||||
);
|
||||
if (parameter?.readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleSelectEditingCell(rowIndex, columnId);
|
||||
},
|
||||
[
|
||||
onRowExpandClick,
|
||||
handleSelectEditingCell,
|
||||
editingCell,
|
||||
updateFormDescription,
|
||||
],
|
||||
);
|
||||
|
||||
const columnSizingMap = useMemo(() => {
|
||||
return {
|
||||
[ROW_SELECT_COLUMN_ID]: 35,
|
||||
[ROW_ID_COLUMN_ID]: 35,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const columnOrder = useMemo(() => cols.map(({ name }) => name), [cols]);
|
||||
|
||||
const tableProps = useDataGridInstance({
|
||||
data: rows,
|
||||
rowId: rowIdColumnOptions,
|
||||
columnsOptions,
|
||||
columnOrder,
|
||||
columnSizingMap,
|
||||
columnPinning: { left: [ROW_SELECT_COLUMN_ID, ROW_ID_COLUMN_ID] },
|
||||
enableRowSelection: true,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
columnRowSelectOptions,
|
||||
});
|
||||
|
||||
const stylingProps = useMemo(
|
||||
() => ({
|
||||
classNames: {
|
||||
headerContainer: S.tableHeaderContainer,
|
||||
headerCell: S.tableHeaderCell,
|
||||
bodyContainer: S.tableBodyContainer,
|
||||
bodyCell: S.tableBodyCell,
|
||||
row: S.tableRow,
|
||||
root: S.tableRoot,
|
||||
},
|
||||
|
||||
// Overrides theme constants and default bg
|
||||
styles: {
|
||||
bodyCell: {
|
||||
backgroundColor: undefined,
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
cell: {
|
||||
backgroundColor: "",
|
||||
},
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
onBodyCellClick={handleBodyCellClick}
|
||||
{...tableProps}
|
||||
{...stylingProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
.inlineEditingTextInput {
|
||||
transition: none;
|
||||
outline: 2px solid var(--mb-base-color-blue-50);
|
||||
outline-offset: -2px;
|
||||
border-color: transparent;
|
||||
box-sizing: border-box;
|
||||
font-weight: 700;
|
||||
background-color: var(--mb-base-color-white);
|
||||
border-radius: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
/* Should be in sync with DEFAULT_FONT_SIZE */
|
||||
font-size: 12.5px;
|
||||
|
||||
/* Properly alignment with BaseCell, to avoid text flickering (BaseCell has only border-bottom 1px) */
|
||||
border-top-width: 0;
|
||||
|
||||
/* Keep outline consistent for inline editing, since focused and non focused state is semantically the same */
|
||||
&:focus {
|
||||
outline: 2px solid var(--mb-base-color-blue-50);
|
||||
}
|
||||
}
|
||||
|
||||
.selectLabel {
|
||||
/* Should be in sync with DEFAULT_FONT_SIZE */
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.readonlyCell {
|
||||
color: var(--mb-color-text-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.errorCell {
|
||||
background-color: var(--mb-base-color-dubloon-5);
|
||||
outline: 2px solid var(--mb-base-color-dubloon-30);
|
||||
outline-offset: -2px;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--mb-base-color-dubloon-5);
|
||||
}
|
||||
}
|
||||
|
||||
.updatingCell {
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
var(--mb-color-bg-light) 8%,
|
||||
var(--mb-color-bg-white) 18%,
|
||||
var(--mb-color-bg-light) 33%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: 1s shine linear infinite;
|
||||
}
|
||||
|
||||
@keyframes shine {
|
||||
to {
|
||||
background-position-x: -200%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { CellContext } from "@tanstack/react-table";
|
||||
|
||||
import type { TableActionFormParameter } from "metabase-enterprise/table-editing/api/types";
|
||||
import { ParameterActionInput } from "metabase-enterprise/table-editing/inputs/ParameterActionInput";
|
||||
import type { RowValue, RowValues } from "metabase-types/api";
|
||||
|
||||
import S from "./TableEditingCell.module.css";
|
||||
|
||||
type Props = {
|
||||
parameterDescription: TableActionFormParameter;
|
||||
onValueUpdated: (value: string | null) => void;
|
||||
onCancelEditing: () => void;
|
||||
};
|
||||
|
||||
export function getEditingCellTemplate({
|
||||
parameterDescription,
|
||||
onValueUpdated,
|
||||
onCancelEditing,
|
||||
}: Props) {
|
||||
return function EditingCell(props: CellContext<RowValues, RowValue>) {
|
||||
return (
|
||||
<ParameterActionInput
|
||||
parameter={parameterDescription}
|
||||
autoFocus
|
||||
initialValue={props.getValue()?.toString()}
|
||||
inputProps={{
|
||||
variant: "unstyled",
|
||||
size: "sm",
|
||||
}}
|
||||
classNames={{
|
||||
textInputElement: S.inlineEditingTextInput,
|
||||
selectTextInputElement: S.inlineEditingTextInput,
|
||||
dateTextInputElement: S.inlineEditingTextInput,
|
||||
selectLabel: S.selectLabel,
|
||||
}}
|
||||
onEscape={onCancelEditing}
|
||||
onEnter={onValueUpdated}
|
||||
onBlur={onValueUpdated}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Ellipsified } from "metabase/common/components/Ellipsified";
|
||||
import { Box, Icon } from "metabase/ui";
|
||||
import type { OrderByDirection } from "metabase-lib";
|
||||
import type { DatasetColumn } from "metabase-types/api";
|
||||
|
||||
import S from "./EditTableDataGrid.module.css";
|
||||
|
||||
type Props = {
|
||||
sortDirection?: OrderByDirection;
|
||||
column: DatasetColumn;
|
||||
onColumnSort?: (column: DatasetColumn) => void;
|
||||
};
|
||||
|
||||
export function getTableEditingHeaderTemplate({
|
||||
sortDirection,
|
||||
column,
|
||||
onColumnSort,
|
||||
}: Props) {
|
||||
return function TableEditingHeader() {
|
||||
return (
|
||||
<Box
|
||||
className={S.headerCellContainer}
|
||||
onClick={() => {
|
||||
onColumnSort?.(column);
|
||||
}}
|
||||
>
|
||||
<Ellipsified>{column.display_name}</Ellipsified>
|
||||
{sortDirection != null ? (
|
||||
<Icon
|
||||
className={S.sortIndicator}
|
||||
data-testid="header-sort-indicator"
|
||||
name={sortDirection === "asc" ? "chevronup" : "chevrondown"}
|
||||
size={10}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export enum EditDataGridCellState {
|
||||
Updating,
|
||||
Error,
|
||||
}
|
||||
|
||||
export function useEditDataGridCellState() {
|
||||
const [cellState, _setCellState] = useState<
|
||||
Record<string, EditDataGridCellState | undefined>
|
||||
>({});
|
||||
|
||||
const getCellState = useCallback(
|
||||
(columnId: string, rowIndex: number) => {
|
||||
return cellState[getCellKey(columnId, rowIndex)];
|
||||
},
|
||||
[cellState],
|
||||
);
|
||||
|
||||
const setCellState = useCallback(
|
||||
(columnId: string, rowIndex: number, state: EditDataGridCellState) => {
|
||||
_setCellState((prev) => ({
|
||||
...prev,
|
||||
[getCellKey(columnId, rowIndex)]: state,
|
||||
}));
|
||||
},
|
||||
[_setCellState],
|
||||
);
|
||||
|
||||
const resetCellState = useCallback(
|
||||
(columnId: string, rowIndex: number) => {
|
||||
_setCellState((prev) => ({
|
||||
...prev,
|
||||
[getCellKey(columnId, rowIndex)]: undefined,
|
||||
}));
|
||||
},
|
||||
[_setCellState],
|
||||
);
|
||||
|
||||
return {
|
||||
cellState,
|
||||
getCellState,
|
||||
setCellState,
|
||||
resetCellState,
|
||||
};
|
||||
}
|
||||
|
||||
function getCellKey(columnId: string, rowIndex: number) {
|
||||
return `${columnId}_${rowIndex}`;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import cx from "classnames";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import type { ColumnOptions } from "metabase/data-grid";
|
||||
import { formatValue } from "metabase/lib/formatting";
|
||||
import type { DescribeActionFormResponse } from "metabase-enterprise/table-editing/api/types";
|
||||
import type { DatasetColumn, RowValue, RowValues } from "metabase-types/api";
|
||||
|
||||
import type { TableDataGetColumnSortDirection } from "../use-edit-table-data";
|
||||
|
||||
import S from "./TableEditingCell.module.css";
|
||||
import { getEditingCellTemplate } from "./getEditingCellTemplate";
|
||||
import { getTableEditingHeaderTemplate } from "./getHeaderTemplate";
|
||||
import { EditDataGridCellState } from "./use-edit-datagrid-cell-state";
|
||||
import type { TableEditingCell } from "./use-table-editing-cell";
|
||||
|
||||
type UseEditDataGridColumnOptions = {
|
||||
cols: DatasetColumn[];
|
||||
editingCell: TableEditingCell | null;
|
||||
updateFormDescription?: DescribeActionFormResponse;
|
||||
getColumnSortDirection?: TableDataGetColumnSortDirection;
|
||||
onColumnSort?: (column: DatasetColumn) => void;
|
||||
onCancelEditing: () => void;
|
||||
onValueUpdated: (value: string | null) => void;
|
||||
getCellState: (
|
||||
columnId: string,
|
||||
rowIndex: number,
|
||||
) => EditDataGridCellState | undefined;
|
||||
};
|
||||
|
||||
export function useEditDataDridColumnOptions({
|
||||
cols,
|
||||
editingCell,
|
||||
updateFormDescription,
|
||||
getColumnSortDirection,
|
||||
onColumnSort,
|
||||
onCancelEditing,
|
||||
onValueUpdated,
|
||||
getCellState,
|
||||
}: UseEditDataGridColumnOptions) {
|
||||
const getIsEditing = useCallback(
|
||||
(columnId: string, rowIndex: number) => {
|
||||
return (
|
||||
editingCell?.columnId === columnId && editingCell?.rowIndex === rowIndex
|
||||
);
|
||||
},
|
||||
[editingCell],
|
||||
);
|
||||
|
||||
const getCellClassName = useCallback(
|
||||
(_value: RowValue, rowIndex: number, columnId: string) => {
|
||||
const cellState = getCellState(columnId, rowIndex);
|
||||
return cx({
|
||||
[S.updatingCell]: cellState === EditDataGridCellState.Updating,
|
||||
[S.errorCell]: cellState === EditDataGridCellState.Error,
|
||||
});
|
||||
},
|
||||
[getCellState],
|
||||
);
|
||||
|
||||
return useMemo<ColumnOptions<RowValues, RowValue>[]>(
|
||||
() =>
|
||||
cols.map((column, columnIndex) => {
|
||||
const sortDirection = getColumnSortDirection?.(column);
|
||||
|
||||
const options: ColumnOptions<RowValues, RowValue> = {
|
||||
id: column.name,
|
||||
name: column.display_name,
|
||||
accessorFn: (row) => row[columnIndex],
|
||||
formatter: (value) => formatValue(value, { column }),
|
||||
wrap: false,
|
||||
sortDirection,
|
||||
getCellClassName,
|
||||
header: getTableEditingHeaderTemplate({
|
||||
column,
|
||||
onColumnSort,
|
||||
sortDirection,
|
||||
}),
|
||||
};
|
||||
|
||||
const parameterDescription = updateFormDescription?.parameters.find(
|
||||
(parameter) => parameter.id === column.name,
|
||||
);
|
||||
|
||||
if (parameterDescription) {
|
||||
options.getIsEditing = getIsEditing;
|
||||
options.editingCell = getEditingCellTemplate({
|
||||
parameterDescription,
|
||||
onCancelEditing,
|
||||
onValueUpdated,
|
||||
});
|
||||
|
||||
if (parameterDescription.readonly) {
|
||||
options.getCellClassName = () => S.readonlyCell;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}),
|
||||
[
|
||||
cols,
|
||||
getColumnSortDirection,
|
||||
onColumnSort,
|
||||
getIsEditing,
|
||||
getCellClassName,
|
||||
onCancelEditing,
|
||||
onValueUpdated,
|
||||
updateFormDescription,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Row, Table } from "@tanstack/react-table";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { ColumnOptions } from "metabase/data-grid";
|
||||
import { Checkbox, Flex } from "metabase/ui";
|
||||
import type { RowValue, RowValues } from "metabase-types/api";
|
||||
|
||||
export const ROW_SELECT_COLUMN_ID = "__MB_ROW_SELECT";
|
||||
|
||||
export function useTableColumnRowSelect(hasRowSelection: boolean) {
|
||||
return useMemo<ColumnOptions<RowValues, RowValue> | undefined>(
|
||||
() => (hasRowSelection ? getRowSelectColumn() : undefined),
|
||||
[hasRowSelection],
|
||||
);
|
||||
}
|
||||
|
||||
export function getRowSelectColumn() {
|
||||
return {
|
||||
id: ROW_SELECT_COLUMN_ID,
|
||||
name: "",
|
||||
accessorFn: () => null,
|
||||
header: ({ table }: { table: Table<RowValues> }) => (
|
||||
<Flex px=".5rem" h="100%" align="center" bg="var(--cell-bg-color)">
|
||||
<Checkbox
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
variant="stacked"
|
||||
data-testid="row-select-all-checkbox"
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
cell: ({ row }: { row: Row<RowValues> }) => (
|
||||
<Flex p=".5rem" h="100%" align="start">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
indeterminate={row.getIsSomeSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
data-testid="row-select-checkbox"
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { isPK } from "metabase-lib/v1/types/utils/isa";
|
||||
import type { DatasetData, RowValue } from "metabase-types/api";
|
||||
|
||||
import type { RowCellsWithPkValue } from "../../api/types";
|
||||
import type { TableRowUpdateHandler } from "../use-table-crud";
|
||||
|
||||
import {
|
||||
EditDataGridCellState,
|
||||
useEditDataGridCellState,
|
||||
} from "./use-edit-datagrid-cell-state";
|
||||
|
||||
type UseTableEditingCellProps = {
|
||||
data: DatasetData;
|
||||
handleRowUpdate: TableRowUpdateHandler;
|
||||
};
|
||||
|
||||
export type TableEditingCell = {
|
||||
rowIndex: number;
|
||||
columnId: string;
|
||||
value: RowValue;
|
||||
actionInput: RowCellsWithPkValue;
|
||||
};
|
||||
|
||||
export function useTableEditingCell({
|
||||
data,
|
||||
handleRowUpdate,
|
||||
}: UseTableEditingCellProps) {
|
||||
const { getCellState, setCellState, resetCellState } =
|
||||
useEditDataGridCellState();
|
||||
const [editingCell, setEditingCell] = useState<TableEditingCell | null>(null);
|
||||
|
||||
const handleSelectEditingCell = useCallback(
|
||||
(rowIndex: number, columnId: string) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { rows, cols } = data;
|
||||
const row = rows[rowIndex];
|
||||
|
||||
const actionInput = cols.reduce((acc, col, index) => {
|
||||
if (isPK(col)) {
|
||||
acc[col.name] = rows[rowIndex][index];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as RowCellsWithPkValue);
|
||||
|
||||
const columnIndex = cols.findIndex((col) => col.name === columnId);
|
||||
const value = row[columnIndex];
|
||||
|
||||
setEditingCell({ rowIndex, columnId, value, actionInput });
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
const handleCancelEditing = useCallback(() => {
|
||||
setEditingCell(null);
|
||||
}, []);
|
||||
|
||||
const handleUpdateEditingCell = useCallback(
|
||||
async (value: string | null) => {
|
||||
if (!editingCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleCancelEditing();
|
||||
|
||||
if (editingCell.value?.toString() !== value?.toString()) {
|
||||
setCellState(
|
||||
editingCell.columnId,
|
||||
editingCell.rowIndex,
|
||||
EditDataGridCellState.Updating,
|
||||
);
|
||||
|
||||
const result = await handleRowUpdate({
|
||||
input: editingCell.actionInput,
|
||||
params: { [editingCell.columnId]: value },
|
||||
shouldPerformOptimisticUpdate: true,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
setCellState(
|
||||
editingCell.columnId,
|
||||
editingCell.rowIndex,
|
||||
EditDataGridCellState.Error,
|
||||
);
|
||||
} else {
|
||||
resetCellState(editingCell.columnId, editingCell.rowIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
editingCell,
|
||||
handleCancelEditing,
|
||||
handleRowUpdate,
|
||||
resetCellState,
|
||||
setCellState,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
editingCell,
|
||||
getCellState,
|
||||
handleSelectEditingCell,
|
||||
handleUpdateEditingCell,
|
||||
handleCancelEditing,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import cx from "classnames";
|
||||
import { useFormik } from "formik";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Icon,
|
||||
Loader,
|
||||
Modal,
|
||||
rem,
|
||||
} from "metabase/ui";
|
||||
import type { RowValue } from "metabase-types/api";
|
||||
|
||||
import type {
|
||||
DescribeActionFormResponse,
|
||||
RowCellsWithPkValue,
|
||||
TableActionFormParameter,
|
||||
} from "../../api/types";
|
||||
|
||||
import { ModalParameterActionInput } from "./ModalParameterActionInput";
|
||||
import S from "./TableActionFormModal.module.css";
|
||||
import { TableActionFormModalParameter } from "./TableActionFormModalParameter";
|
||||
|
||||
interface CreateRowActionFormModalProps {
|
||||
opened: boolean;
|
||||
description?: DescribeActionFormResponse;
|
||||
isLoading: boolean;
|
||||
initialValues?: RowCellsWithPkValue;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: RowCellsWithPkValue) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function CreateRowActionFormModal({
|
||||
opened,
|
||||
description,
|
||||
isLoading,
|
||||
initialValues,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: CreateRowActionFormModalProps) {
|
||||
const validateForm = useCallback(
|
||||
(values: RowCellsWithPkValue) => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
description?.parameters.forEach((parameter) => {
|
||||
const isRequired = !parameter.optional;
|
||||
if (isRequired && !values[parameter.id]) {
|
||||
errors[parameter.id] = t`This column is required`;
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
},
|
||||
[description?.parameters],
|
||||
);
|
||||
|
||||
const handleFormikSubmit = useCallback(
|
||||
async (values: RowCellsWithPkValue) => {
|
||||
const success = await onSubmit(values);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose, onSubmit],
|
||||
);
|
||||
|
||||
const {
|
||||
isValid,
|
||||
resetForm,
|
||||
setFieldValue,
|
||||
handleSubmit,
|
||||
validateForm: revalidateForm,
|
||||
} = useFormik({
|
||||
initialValues: initialValues ?? {},
|
||||
onSubmit: handleFormikSubmit,
|
||||
validate: validateForm,
|
||||
validateOnMount: true,
|
||||
});
|
||||
|
||||
// Reset form when modal is opened
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
resetForm({ values: initialValues });
|
||||
revalidateForm();
|
||||
}
|
||||
}, [opened, resetForm, revalidateForm, initialValues]);
|
||||
|
||||
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>{t`Create a new record`}</Modal.Title>
|
||||
<Group
|
||||
gap="xs"
|
||||
mr={rem(-5) /* aligns cross with modal right padding */}
|
||||
>
|
||||
<ActionIcon variant="subtle" onClick={onClose}>
|
||||
<Icon name="close" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Modal.Header>
|
||||
<Modal.Body px="xl" py="lg" className={cx(S.modalBody)}>
|
||||
{!description ? (
|
||||
<Center className={S.modalBodyLoader}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
description.parameters.map((parameter) => {
|
||||
return (
|
||||
<TableActionFormModalParameter
|
||||
key={parameter.id}
|
||||
parameter={parameter}
|
||||
>
|
||||
<ModalFormInput
|
||||
initialValue={initialValues?.[parameter.id]}
|
||||
parameter={parameter}
|
||||
onChange={setFieldValue}
|
||||
/>
|
||||
</TableActionFormModalParameter>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Modal.Body>
|
||||
<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"
|
||||
data-testid="create-row-form-submit-button"
|
||||
>
|
||||
{t`Create`}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
type ModalFormInputProps = {
|
||||
initialValue?: RowValue;
|
||||
parameter: TableActionFormParameter;
|
||||
onChange: (field: string, value: RowValue) => void;
|
||||
};
|
||||
|
||||
export function ModalFormInput({
|
||||
initialValue,
|
||||
parameter,
|
||||
onChange,
|
||||
}: ModalFormInputProps) {
|
||||
const handleChange = useCallback(
|
||||
(value: RowValue) => {
|
||||
onChange(parameter.id, value);
|
||||
},
|
||||
[parameter.id, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalParameterActionInput
|
||||
initialValue={initialValue?.toString()}
|
||||
parameter={parameter}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { msgid, ngettext, t } from "ttag";
|
||||
|
||||
import { Button, Group, Modal } from "metabase/ui";
|
||||
|
||||
type DeleteBulkRowConfirmationModalProps = {
|
||||
opened: boolean;
|
||||
rowCount: number;
|
||||
isLoading?: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
export function DeleteBulkRowConfirmationModal({
|
||||
opened,
|
||||
rowCount,
|
||||
isLoading,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: DeleteBulkRowConfirmationModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
title={ngettext(
|
||||
msgid`Delete ${rowCount} record?`,
|
||||
`Delete ${rowCount} records?`,
|
||||
rowCount,
|
||||
)}
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="danger"
|
||||
onClick={onConfirm}
|
||||
loading={isLoading}
|
||||
>
|
||||
{ngettext(
|
||||
msgid`Delete ${rowCount} record`,
|
||||
`Delete ${rowCount} records`,
|
||||
rowCount,
|
||||
)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Button, Group, Modal } from "metabase/ui";
|
||||
|
||||
type DeleteRowConfirmationModalProps = {
|
||||
isLoading: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
export function DeleteRowConfirmationModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isLoading,
|
||||
}: DeleteRowConfirmationModalProps) {
|
||||
return (
|
||||
<Modal size="md" title={t`Delete this record?`} opened onClose={onCancel}>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="subtle" onClick={onCancel}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="danger"
|
||||
onClick={onConfirm}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t`Delete record`}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
ParameterActionInput,
|
||||
type ParameterActionInputProps,
|
||||
} from "../../inputs/ParameterActionInput";
|
||||
|
||||
export function ModalParameterActionInput(props: ParameterActionInputProps) {
|
||||
const { parameter, ...rest } = props;
|
||||
|
||||
const placeholder = parameter.database_default
|
||||
? t`Auto populated`
|
||||
: parameter.optional
|
||||
? t`Optional`
|
||||
: undefined;
|
||||
|
||||
const disabled = parameter.readonly;
|
||||
|
||||
const inputProps = {
|
||||
...rest.inputProps,
|
||||
placeholder,
|
||||
disabled,
|
||||
"data-testid": `${parameter.display_name}-field-input`,
|
||||
};
|
||||
|
||||
return (
|
||||
<ParameterActionInput
|
||||
{...rest}
|
||||
inputProps={inputProps}
|
||||
parameter={parameter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
.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: 45% auto;
|
||||
row-gap: rem(24);
|
||||
max-height: calc(
|
||||
100vh - (var(--modal-y-offset) * 2) - rem(80) - rem(72)
|
||||
/* header + footer */
|
||||
);
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.modalBodyLoader {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.modalBodyEditing {
|
||||
max-height: calc(100vh - (var(--modal-y-offset) * 2) - rem(80) /* header */);
|
||||
}
|
||||
|
||||
.modalBodyColumn {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
background-color: var(--mb-color-background);
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
height: rem(72);
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { FIELD_SEMANTIC_TYPES_MAP } from "metabase/lib/core";
|
||||
import { Group, Icon, type IconName, Text } from "metabase/ui";
|
||||
|
||||
import {
|
||||
TableActionFormInputType,
|
||||
type TableActionFormParameter,
|
||||
} from "../../api/types";
|
||||
|
||||
import S from "./TableActionFormModal.module.css";
|
||||
|
||||
type ActionFormModalParameterProps = PropsWithChildren<{
|
||||
parameter: TableActionFormParameter;
|
||||
}>;
|
||||
|
||||
const DEFAULT_PARAMETER_ICON = "string";
|
||||
const PARAMETER_ICON_MAP: Record<TableActionFormInputType, IconName> = {
|
||||
[TableActionFormInputType.Text]: "string",
|
||||
[TableActionFormInputType.Textarea]: "string",
|
||||
[TableActionFormInputType.Date]: "calendar",
|
||||
[TableActionFormInputType.DateTime]: "calendar",
|
||||
[TableActionFormInputType.Dropdown]: "string",
|
||||
};
|
||||
|
||||
export function TableActionFormModalParameter({
|
||||
parameter,
|
||||
children,
|
||||
}: ActionFormModalParameterProps) {
|
||||
const iconName = parameter.semantic_type
|
||||
? FIELD_SEMANTIC_TYPES_MAP[parameter.semantic_type].icon
|
||||
: PARAMETER_ICON_MAP[parameter.input_type];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group h="2.5rem" align="center">
|
||||
<Icon
|
||||
className={S.modalBodyColumn}
|
||||
name={iconName ?? DEFAULT_PARAMETER_ICON}
|
||||
/>
|
||||
<Text className={S.modalBodyColumn}>
|
||||
{parameter.display_name}
|
||||
{!parameter.optional && (
|
||||
<Text component="span" c="error">
|
||||
*
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import cx from "classnames";
|
||||
import { useFormik } from "formik";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Icon,
|
||||
Loader,
|
||||
Modal,
|
||||
rem,
|
||||
} from "metabase/ui";
|
||||
import type { RowValue } from "metabase-types/api";
|
||||
|
||||
import type {
|
||||
DescribeActionFormResponse,
|
||||
RowCellsWithPkValue,
|
||||
TableActionFormParameter,
|
||||
} from "../../api/types";
|
||||
|
||||
import { DeleteRowConfirmationModal } from "./DeleteRowConfirmationModal";
|
||||
import { ModalParameterActionInput } from "./ModalParameterActionInput";
|
||||
import S from "./TableActionFormModal.module.css";
|
||||
import { TableActionFormModalParameter } from "./TableActionFormModalParameter";
|
||||
|
||||
interface UpdateRowActionFormModalProps {
|
||||
opened: boolean;
|
||||
description?: DescribeActionFormResponse;
|
||||
isLoading: boolean;
|
||||
isDeleting: boolean;
|
||||
initialValues?: RowCellsWithPkValue;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: RowCellsWithPkValue) => Promise<boolean>;
|
||||
onDelete: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
enum ModalState {
|
||||
Opened,
|
||||
DeleteRequested,
|
||||
Closed,
|
||||
}
|
||||
|
||||
export function UpdateRowActionFormModal({
|
||||
opened,
|
||||
description,
|
||||
isLoading,
|
||||
isDeleting,
|
||||
initialValues,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
}: UpdateRowActionFormModalProps) {
|
||||
const [modalState, setModalState] = useState<ModalState>(ModalState.Closed);
|
||||
|
||||
useEffect(() => {
|
||||
setModalState(opened ? ModalState.Opened : ModalState.Closed);
|
||||
}, [opened]);
|
||||
|
||||
const requestDeletion = useCallback(
|
||||
() => setModalState(ModalState.DeleteRequested),
|
||||
[],
|
||||
);
|
||||
|
||||
const closeDeletionModal = useCallback(
|
||||
() => setModalState(ModalState.Opened),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteConfirmation = useCallback(async () => {
|
||||
const result = await onDelete();
|
||||
|
||||
if (result) {
|
||||
onClose();
|
||||
}
|
||||
}, [onDelete, onClose]);
|
||||
|
||||
const validateForm = useCallback(
|
||||
(values: RowCellsWithPkValue) => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
const updatedKeys = Object.keys(values);
|
||||
if (updatedKeys.length === 0) {
|
||||
errors.all = t`No changes to save`;
|
||||
}
|
||||
|
||||
for (const key of updatedKeys) {
|
||||
const parameter = description?.parameters.find((p) => p.id === key);
|
||||
const isRequired = !parameter?.optional;
|
||||
if (isRequired && !values[key]) {
|
||||
errors[key] = t`This column is required`;
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
[description?.parameters],
|
||||
);
|
||||
|
||||
const handleFormikSubmit = useCallback(
|
||||
async (values: RowCellsWithPkValue) => {
|
||||
const success = await onSubmit(values);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose, onSubmit],
|
||||
);
|
||||
|
||||
const {
|
||||
isValid,
|
||||
resetForm,
|
||||
setFieldValue,
|
||||
handleSubmit,
|
||||
validateForm: revalidateForm,
|
||||
} = useFormik({
|
||||
// We want to track only changed values, not all values
|
||||
initialValues: {},
|
||||
onSubmit: handleFormikSubmit,
|
||||
validate: validateForm,
|
||||
validateOnMount: true,
|
||||
});
|
||||
|
||||
// Reset form when modal is opened
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
resetForm();
|
||||
revalidateForm({});
|
||||
}
|
||||
}, [opened, resetForm, revalidateForm]);
|
||||
|
||||
if (modalState === ModalState.DeleteRequested) {
|
||||
return (
|
||||
<DeleteRowConfirmationModal
|
||||
isLoading={isDeleting}
|
||||
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>{t`Edit record`}</Modal.Title>
|
||||
<Group
|
||||
gap="xs"
|
||||
mr={rem(-5) /* aligns cross with modal right padding */}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={requestDeletion}
|
||||
data-testid="delete-row-icon"
|
||||
>
|
||||
<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)}>
|
||||
{!description ? (
|
||||
<Center className={S.modalBodyLoader}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
description.parameters.map((parameter) => {
|
||||
return (
|
||||
<TableActionFormModalParameter
|
||||
key={parameter.id}
|
||||
parameter={parameter}
|
||||
>
|
||||
<ModalFormInput
|
||||
initialValue={initialValues?.[parameter.id]}
|
||||
parameter={parameter}
|
||||
onChange={setFieldValue}
|
||||
/>
|
||||
</TableActionFormModalParameter>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Modal.Body>
|
||||
<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"
|
||||
data-testid="update-row-save-button"
|
||||
>
|
||||
{t`Save`}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
type ModalFormInputProps = {
|
||||
initialValue?: RowValue;
|
||||
parameter: TableActionFormParameter;
|
||||
onChange: (field: string, value: RowValue) => void;
|
||||
};
|
||||
|
||||
export function ModalFormInput({
|
||||
initialValue,
|
||||
parameter,
|
||||
onChange,
|
||||
}: ModalFormInputProps) {
|
||||
const handleChange = useCallback(
|
||||
(value: RowValue) => {
|
||||
onChange(parameter.id, value);
|
||||
},
|
||||
[parameter.id, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalParameterActionInput
|
||||
initialValue={initialValue?.toString()}
|
||||
parameter={parameter}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Button, Group, Icon, Stack, Text } from "metabase/ui";
|
||||
|
||||
import { getUpdateApiErrorMessage } from "./getUpdateErrorMessage";
|
||||
|
||||
type ErrorUpdateToastProps = {
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export const ErrorUpdateToast = ({ error }: ErrorUpdateToastProps) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
const errorMessage = getUpdateApiErrorMessage(error);
|
||||
|
||||
if (showDetails) {
|
||||
return (
|
||||
<Stack gap="0.5rem" w="30rem" maw="100%">
|
||||
<Text c="text-white">{t`Couldn't save table changes:`}</Text>
|
||||
<Text c="text-white" style={{ fontFamily: "monospace" }}>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap="2.5rem" w="20rem">
|
||||
<Group gap="0.5rem">
|
||||
<Icon name="warning" c="danger" size={12} />
|
||||
<Text c="text-white" fw={700}>{t`Couldn't save table changes`}</Text>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
size="compact-lg"
|
||||
color="var(--mb-base-color-orion-80)"
|
||||
variant="filled"
|
||||
autoContrast
|
||||
radius="0.5rem"
|
||||
onClick={() => setShowDetails(true)}
|
||||
>{t`More info`}</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import type { GenericErrorResponse } from "metabase/lib/errors";
|
||||
|
||||
export const getUpdateApiErrorMessage = (
|
||||
error: GenericErrorResponse | unknown,
|
||||
): string => {
|
||||
const maybeError = error as GenericErrorResponse;
|
||||
|
||||
if (typeof maybeError.data === "string") {
|
||||
return maybeError.data;
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(maybeError.data?.errors) &&
|
||||
"error" in maybeError.data?.errors[0]
|
||||
) {
|
||||
return maybeError.data.errors[0].error;
|
||||
}
|
||||
|
||||
if (Array.isArray(maybeError.errors) && "error" in maybeError.errors[0]) {
|
||||
return maybeError.errors[0].error;
|
||||
}
|
||||
|
||||
return t`Unknown error`;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
|
||||
import { ErrorUpdateToast } from "./ErrorUpdateToast";
|
||||
|
||||
export type TableEditingToastControler = {
|
||||
showSuccessToast: (message: string) => void;
|
||||
};
|
||||
|
||||
export function useTableEditingToastController() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
showSuccessToast(message: string) {
|
||||
dispatch(
|
||||
addUndo({
|
||||
message,
|
||||
}),
|
||||
);
|
||||
},
|
||||
showErrorToast(error: unknown, onDismiss?: () => void) {
|
||||
dispatch(
|
||||
addUndo({
|
||||
toastColor: "bg-black",
|
||||
icon: null,
|
||||
renderChildren: () => <ErrorUpdateToast error={error} />,
|
||||
timeout: null, // removes automatic toast hide
|
||||
undo: false,
|
||||
onDismiss,
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
[dispatch],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Location } from "history";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { loadMetadataForTable } from "metabase/questions/actions";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import * as Lib from "metabase-lib";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import type { StructuredDatasetQuery } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
deserializeTableFilter,
|
||||
deserializeTableSorting,
|
||||
serializeMbqlParam,
|
||||
} from "../common/utils";
|
||||
|
||||
type UseAdHocTableQueryProps = {
|
||||
tableId: number;
|
||||
databaseId: number;
|
||||
location: Location<{ filter?: string; sorting?: string }>;
|
||||
};
|
||||
|
||||
export const useAdHocTableQuery = ({
|
||||
tableId,
|
||||
databaseId,
|
||||
location,
|
||||
}: UseAdHocTableQueryProps) => {
|
||||
const filterQueryParam = useMemo(
|
||||
() =>
|
||||
location.query?.filter
|
||||
? deserializeTableFilter(location.query.filter)
|
||||
: null,
|
||||
[location.query.filter],
|
||||
);
|
||||
|
||||
const sortingQueryParam = useMemo(
|
||||
() =>
|
||||
location.query?.sorting
|
||||
? deserializeTableSorting(location.query.sorting)
|
||||
: null,
|
||||
[location.query.sorting],
|
||||
);
|
||||
|
||||
const metadata = useSelector(getMetadata);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const table = metadata.table(tableId);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadMetadataForTable(tableId));
|
||||
}, [dispatch, tableId]);
|
||||
|
||||
const tableQuestion = useMemo(() => {
|
||||
if (table) {
|
||||
let question = Question.create({ databaseId, tableId, metadata });
|
||||
|
||||
if (filterQueryParam || sortingQueryParam) {
|
||||
const legacyQuery = Lib.toLegacyQuery(
|
||||
question.query(),
|
||||
) as StructuredDatasetQuery;
|
||||
|
||||
if (filterQueryParam) {
|
||||
legacyQuery.query.filter = filterQueryParam;
|
||||
}
|
||||
|
||||
if (sortingQueryParam) {
|
||||
legacyQuery.query["order-by"] = sortingQueryParam;
|
||||
}
|
||||
|
||||
question = question.setDatasetQuery(legacyQuery);
|
||||
}
|
||||
|
||||
return question;
|
||||
}
|
||||
}, [
|
||||
table,
|
||||
databaseId,
|
||||
tableId,
|
||||
metadata,
|
||||
filterQueryParam,
|
||||
sortingQueryParam,
|
||||
]);
|
||||
|
||||
const handleTableQuestionChange = useCallback(
|
||||
(newQuestion: Question) => {
|
||||
const legacyQuery = Lib.toLegacyQuery(
|
||||
newQuestion.query(),
|
||||
) as StructuredDatasetQuery;
|
||||
const newFilterMbql = legacyQuery.query.filter;
|
||||
const newSortingMbql = legacyQuery.query["order-by"];
|
||||
|
||||
if (newFilterMbql || newSortingMbql) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (newFilterMbql) {
|
||||
searchParams.append("filter", serializeMbqlParam(newFilterMbql));
|
||||
}
|
||||
if (newSortingMbql) {
|
||||
searchParams.append("sorting", serializeMbqlParam(newSortingMbql));
|
||||
}
|
||||
|
||||
// don't set filter param if it is empty
|
||||
dispatch(
|
||||
push(`${window.location.pathname}?${searchParams.toString()}`),
|
||||
);
|
||||
} else {
|
||||
dispatch(push(window.location.pathname));
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const tableQuery = useMemo(() => {
|
||||
if (tableQuestion) {
|
||||
return Lib.toLegacyQuery(tableQuestion.query());
|
||||
}
|
||||
}, [tableQuestion]);
|
||||
|
||||
return {
|
||||
tableQuestion,
|
||||
tableQuery,
|
||||
handleTableQuestionChange,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Location } from "history";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { skipToken, useGetAdhocQueryQuery } from "metabase/api";
|
||||
import type { OrderByDirection, Query } from "metabase-lib";
|
||||
import {
|
||||
changeDirection,
|
||||
displayInfo,
|
||||
findMatchingColumn,
|
||||
fromLegacyColumn,
|
||||
orderBy,
|
||||
orderBys,
|
||||
orderableColumns,
|
||||
removeOrderBys,
|
||||
} from "metabase-lib";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import type { DatasetColumn } from "metabase-types/api";
|
||||
|
||||
import { useAdHocTableQuery } from "./use-adhoc-table-query";
|
||||
|
||||
type Props = {
|
||||
tableId: number;
|
||||
databaseId: number;
|
||||
location: Location<{ filter?: string }>;
|
||||
};
|
||||
|
||||
export type TableDataGetColumnSortDirection = (
|
||||
column: DatasetColumn,
|
||||
) => OrderByDirection | undefined;
|
||||
|
||||
export function useEditTableData({ tableId, databaseId, location }: Props) {
|
||||
const { tableQuestion, tableQuery, handleTableQuestionChange } =
|
||||
useAdHocTableQuery({ tableId, databaseId, location });
|
||||
|
||||
const getColumnSortDirection = useCallback(
|
||||
(columnOrField: DatasetColumn) => {
|
||||
if (!tableQuestion || !columnOrField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { query, stageIndex, column } = getQueryColumn(
|
||||
tableQuestion,
|
||||
columnOrField,
|
||||
);
|
||||
|
||||
if (column != null) {
|
||||
const columnInfo = displayInfo(query, stageIndex, column);
|
||||
if (columnInfo.orderByPosition != null) {
|
||||
const orderBysMap = orderBys(query, stageIndex);
|
||||
const orderBy = orderBysMap[columnInfo.orderByPosition];
|
||||
const orderByInfo = displayInfo(query, stageIndex, orderBy);
|
||||
return orderByInfo.direction;
|
||||
}
|
||||
}
|
||||
},
|
||||
[tableQuestion],
|
||||
);
|
||||
|
||||
const handleChangeColumnSort = useCallback(
|
||||
(columnOrField: DatasetColumn) => {
|
||||
if (!tableQuestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { query, stageIndex, column } = getQueryColumn(
|
||||
tableQuestion,
|
||||
columnOrField,
|
||||
);
|
||||
|
||||
if (column) {
|
||||
const sortDirection = getColumnSortDirection(columnOrField);
|
||||
|
||||
let newQuery: Query;
|
||||
if (sortDirection === "asc") {
|
||||
// have existing sorting by asc, then make it desc
|
||||
const clauses = orderBys(query, stageIndex);
|
||||
newQuery = changeDirection(query, clauses[0]);
|
||||
} else if (sortDirection === "desc") {
|
||||
// have existing sorting by desc, then remove sorting - WRK-446
|
||||
newQuery = removeOrderBys(query, stageIndex);
|
||||
} else {
|
||||
// no existing sorting on this column - apply new sorting (default - by asc)
|
||||
newQuery = orderBy(
|
||||
removeOrderBys(query, stageIndex),
|
||||
stageIndex,
|
||||
column,
|
||||
);
|
||||
}
|
||||
|
||||
const newQuestion = tableQuestion.setQuery(newQuery);
|
||||
handleTableQuestionChange?.(newQuestion);
|
||||
}
|
||||
},
|
||||
[tableQuestion, getColumnSortDirection, handleTableQuestionChange],
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching } = useGetAdhocQueryQuery(
|
||||
tableQuery || skipToken,
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
tableQuestion,
|
||||
tableQuery,
|
||||
getColumnSortDirection,
|
||||
handleTableQuestionChange,
|
||||
handleChangeColumnSort,
|
||||
};
|
||||
}
|
||||
|
||||
const getQueryColumn = (question: Question, columnOrField: DatasetColumn) => {
|
||||
const query = question.query();
|
||||
const stageIndex = -1;
|
||||
const column = findMatchingColumn(
|
||||
query,
|
||||
stageIndex,
|
||||
fromLegacyColumn(query, stageIndex, columnOrField),
|
||||
orderableColumns(query, stageIndex),
|
||||
);
|
||||
|
||||
return {
|
||||
query,
|
||||
stageIndex,
|
||||
column,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
type UseEditTableLoadingOverlayProps = {
|
||||
isDatasetLoading: boolean;
|
||||
isDatasetFetching: boolean;
|
||||
};
|
||||
|
||||
export function useEditTableLoadingOverlay({
|
||||
isDatasetLoading,
|
||||
isDatasetFetching,
|
||||
}: UseEditTableLoadingOverlayProps) {
|
||||
return useMemo(() => {
|
||||
if (isDatasetLoading) {
|
||||
return {
|
||||
show: true,
|
||||
message: t`Loading data...`,
|
||||
};
|
||||
}
|
||||
|
||||
if (isDatasetFetching) {
|
||||
return {
|
||||
show: true,
|
||||
message: t`Updating...`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
show: false,
|
||||
};
|
||||
}, [isDatasetLoading, isDatasetFetching]);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { useDescribeActionFormMutation } from "../api";
|
||||
import type { TableActionId, TableEditingActionScope } from "../api/types";
|
||||
|
||||
type UseTableDescribeTmpModalProps = {
|
||||
actionId: TableActionId;
|
||||
scope: TableEditingActionScope;
|
||||
fetchOnMount?: boolean;
|
||||
};
|
||||
|
||||
export const useActionFormDescription = ({
|
||||
actionId,
|
||||
scope,
|
||||
fetchOnMount = true,
|
||||
}: UseTableDescribeTmpModalProps) => {
|
||||
const [fetchModalDescription, { data, isLoading }] =
|
||||
useDescribeActionFormMutation();
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
fetchModalDescription({
|
||||
action: actionId,
|
||||
scope,
|
||||
});
|
||||
}, [actionId, scope, fetchModalDescription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchOnMount) {
|
||||
refetch();
|
||||
}
|
||||
}, [fetchOnMount, refetch]);
|
||||
|
||||
return {
|
||||
refetch,
|
||||
data,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
import { TableActionId, type TableEditingActionScope } from "../api/types";
|
||||
|
||||
import { useActionFormDescription } from "./use-table-action-form-description";
|
||||
|
||||
type UseTableCreateRowProps = {
|
||||
scope: TableEditingActionScope;
|
||||
};
|
||||
|
||||
export function useTableCreateRow({ scope }: UseTableCreateRowProps) {
|
||||
const [
|
||||
isCreateRowModalOpen,
|
||||
{ open: openCreateRowModal, close: closeCreateRowModal },
|
||||
] = useDisclosure(false);
|
||||
|
||||
const { data: formDescription } = useActionFormDescription({
|
||||
actionId: TableActionId.CreateRow,
|
||||
scope,
|
||||
});
|
||||
|
||||
return {
|
||||
isCreateRowModalOpen,
|
||||
openCreateRowModal,
|
||||
closeCreateRowModal,
|
||||
formDescription,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useCallback } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import type { DatasetData } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
useDeleteTableRowsMutation,
|
||||
useInsertTableRowsMutation,
|
||||
useUpdateTableRowsMutation,
|
||||
} from "../api";
|
||||
import type {
|
||||
RowCellsWithPkValue,
|
||||
TableEditingActionScope,
|
||||
} from "../api/types";
|
||||
|
||||
import { useTableEditingToastController } from "./toasts/use-table-editing-toast-controller";
|
||||
import type { TableEditingStateUpdateStrategy } from "./use-table-state-update-strategy";
|
||||
|
||||
type UseTableCRUDProps = {
|
||||
scope: TableEditingActionScope;
|
||||
datasetData: DatasetData | null | undefined;
|
||||
stateUpdateStrategy: TableEditingStateUpdateStrategy;
|
||||
};
|
||||
|
||||
export type TableRowUpdateHandler = (props: {
|
||||
input: RowCellsWithPkValue;
|
||||
params: RowCellsWithPkValue;
|
||||
shouldPerformOptimisticUpdate?: boolean;
|
||||
}) => Promise<boolean>;
|
||||
|
||||
export type TableRowDeleteHandler = (
|
||||
input: RowCellsWithPkValue,
|
||||
cascadeDelete?: boolean,
|
||||
) => Promise<boolean>;
|
||||
|
||||
export const useTableCRUD = ({
|
||||
scope,
|
||||
datasetData,
|
||||
stateUpdateStrategy,
|
||||
}: UseTableCRUDProps) => {
|
||||
const toastController = useTableEditingToastController();
|
||||
|
||||
const [deleteTableRows, { isLoading: isDeleting }] =
|
||||
useDeleteTableRowsMutation();
|
||||
const [updateTableRows, { isLoading: isUpdating }] =
|
||||
useUpdateTableRowsMutation();
|
||||
const [insertTableRows, { isLoading: isInserting }] =
|
||||
useInsertTableRowsMutation();
|
||||
|
||||
const handleRowUpdateBulk = useCallback(
|
||||
async ({
|
||||
inputs,
|
||||
params,
|
||||
shouldPerformOptimisticUpdate,
|
||||
}: {
|
||||
inputs: RowCellsWithPkValue[];
|
||||
params: RowCellsWithPkValue;
|
||||
shouldPerformOptimisticUpdate?: boolean;
|
||||
}) => {
|
||||
if (!datasetData) {
|
||||
console.warn(
|
||||
"Failed to update table data - no data is loaded for a table",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldPerformOptimisticUpdate) {
|
||||
const updatedRows = inputs.map((input) => ({
|
||||
...input,
|
||||
...params,
|
||||
}));
|
||||
|
||||
stateUpdateStrategy.onRowsUpdated(updatedRows);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await updateTableRows({
|
||||
inputs,
|
||||
params,
|
||||
scope,
|
||||
});
|
||||
|
||||
if (!response.error && response.data) {
|
||||
stateUpdateStrategy.onRowsUpdated(
|
||||
response.data.outputs.map((output) => output.row),
|
||||
);
|
||||
|
||||
toastController.showSuccessToast(t`Successfully updated`);
|
||||
} else {
|
||||
toastController.showErrorToast(response.error);
|
||||
}
|
||||
|
||||
return !response.error;
|
||||
} catch (error) {
|
||||
toastController.showErrorToast(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[datasetData, updateTableRows, scope, stateUpdateStrategy, toastController],
|
||||
);
|
||||
|
||||
const handleRowUpdate = useCallback<TableRowUpdateHandler>(
|
||||
async ({ input, params, shouldPerformOptimisticUpdate }) => {
|
||||
return handleRowUpdateBulk({
|
||||
inputs: [input],
|
||||
params,
|
||||
shouldPerformOptimisticUpdate,
|
||||
});
|
||||
},
|
||||
[handleRowUpdateBulk],
|
||||
);
|
||||
|
||||
const handleRowCreate = useCallback(
|
||||
async (data: RowCellsWithPkValue): Promise<boolean> => {
|
||||
const response = await insertTableRows({
|
||||
rows: [data],
|
||||
scope,
|
||||
});
|
||||
|
||||
if (!response.error && response.data) {
|
||||
stateUpdateStrategy.onRowsCreated(
|
||||
response.data.outputs.map((output) => output.row),
|
||||
);
|
||||
|
||||
toastController.showSuccessToast(t`Record successfully created`);
|
||||
} else {
|
||||
toastController.showErrorToast(response.error);
|
||||
}
|
||||
|
||||
return !response.error;
|
||||
},
|
||||
[insertTableRows, scope, stateUpdateStrategy, toastController],
|
||||
);
|
||||
|
||||
const handleRowDeleteBulk = useCallback(
|
||||
async (inputs: RowCellsWithPkValue[], cascadeDelete = false) => {
|
||||
if (!datasetData) {
|
||||
console.warn(
|
||||
"Failed to update table data - no data is loaded for a table",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const requestParams = cascadeDelete
|
||||
? { params: { "delete-children": true } }
|
||||
: {};
|
||||
|
||||
const response = await deleteTableRows({
|
||||
inputs,
|
||||
scope,
|
||||
...requestParams,
|
||||
});
|
||||
|
||||
if (response.data?.outputs) {
|
||||
stateUpdateStrategy.onRowsDeleted(
|
||||
response.data.outputs.map((output) => output.row),
|
||||
);
|
||||
|
||||
toastController.showSuccessToast(t`Successfully deleted`);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
toastController.showErrorToast(response.error);
|
||||
}
|
||||
|
||||
return !response.error;
|
||||
},
|
||||
[datasetData, deleteTableRows, scope, stateUpdateStrategy, toastController],
|
||||
);
|
||||
|
||||
const handleRowDelete = useCallback(
|
||||
async (input: RowCellsWithPkValue, cascadeDelete = false) => {
|
||||
return handleRowDeleteBulk([input], cascadeDelete);
|
||||
},
|
||||
[handleRowDeleteBulk],
|
||||
);
|
||||
|
||||
const handleRowDeleteWithCascade = useCallback(
|
||||
async (inputs: RowCellsWithPkValue[]) => {
|
||||
return handleRowDeleteBulk(inputs, true);
|
||||
},
|
||||
[handleRowDeleteBulk],
|
||||
);
|
||||
|
||||
return {
|
||||
isInserting,
|
||||
isDeleting,
|
||||
isUpdating,
|
||||
|
||||
handleRowCreate,
|
||||
handleRowUpdate,
|
||||
handleRowUpdateBulk,
|
||||
handleRowDelete,
|
||||
handleRowDeleteBulk,
|
||||
handleRowDeleteWithCascade,
|
||||
};
|
||||
};
|
||||
|
||||
export const isForeignKeyConstraintErrorResponse = (error: any): boolean => {
|
||||
if (!error?.data?.errors) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isForeignKeyConstraintError(error.data.errors);
|
||||
};
|
||||
|
||||
export function isForeignKeyConstraintError(error: any): boolean {
|
||||
return error?.type === "metabase.actions.error/children-exist";
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { isPK } from "metabase-lib/v1/types/utils/isa";
|
||||
import type { DatasetColumn, DatasetData, RowValues } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
type RowCellsWithPkValue,
|
||||
TableActionId,
|
||||
type TableEditingActionScope,
|
||||
} from "../api/types";
|
||||
|
||||
import { useActionFormDescription } from "./use-table-action-form-description";
|
||||
import type {
|
||||
TableRowDeleteHandler,
|
||||
TableRowUpdateHandler,
|
||||
} from "./use-table-crud";
|
||||
|
||||
type UseTableExpandedUpdateRowProps = {
|
||||
datasetData?: DatasetData;
|
||||
scope: TableEditingActionScope;
|
||||
handleRowUpdate: TableRowUpdateHandler;
|
||||
handleRowDelete: TableRowDeleteHandler;
|
||||
};
|
||||
|
||||
export type ExpandedRow = {
|
||||
input: RowCellsWithPkValue;
|
||||
params: RowCellsWithPkValue;
|
||||
};
|
||||
|
||||
export function useTableExpandedUpdateRow({
|
||||
datasetData,
|
||||
scope,
|
||||
handleRowUpdate,
|
||||
handleRowDelete,
|
||||
}: UseTableExpandedUpdateRowProps) {
|
||||
const [expandedRow, setExpandedRow] = useState<ExpandedRow | null>(null);
|
||||
|
||||
const handleExpandRow = useCallback(
|
||||
(rowIndex: number) => {
|
||||
if (!datasetData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { rows, cols } = datasetData;
|
||||
const { input, params } = getRowInputAndParamsFromRow(
|
||||
cols,
|
||||
rows[rowIndex],
|
||||
);
|
||||
|
||||
setExpandedRow({ input, params });
|
||||
},
|
||||
[datasetData],
|
||||
);
|
||||
|
||||
const closeExpandedRow = useCallback(() => {
|
||||
setExpandedRow(null);
|
||||
}, []);
|
||||
|
||||
const handleExpandedRowUpdate = useCallback(
|
||||
(params: RowCellsWithPkValue) => {
|
||||
if (!expandedRow) {
|
||||
// Type safety
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return handleRowUpdate({
|
||||
input: expandedRow.input,
|
||||
params,
|
||||
});
|
||||
},
|
||||
[expandedRow, handleRowUpdate],
|
||||
);
|
||||
|
||||
const handleExpandedRowDelete = useCallback(() => {
|
||||
if (!expandedRow) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return handleRowDelete(expandedRow.input);
|
||||
}, [expandedRow, handleRowDelete]);
|
||||
|
||||
const { data: formDescription } = useActionFormDescription({
|
||||
actionId: TableActionId.UpdateRow,
|
||||
scope,
|
||||
});
|
||||
|
||||
return {
|
||||
expandedRow,
|
||||
handleExpandRow,
|
||||
handleExpandedRowUpdate,
|
||||
handleExpandedRowDelete,
|
||||
closeExpandedRow,
|
||||
formDescription,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRowInputAndParamsFromRow(
|
||||
cols: DatasetColumn[],
|
||||
row: RowValues,
|
||||
) {
|
||||
const input: RowCellsWithPkValue = {};
|
||||
const params: RowCellsWithPkValue = {};
|
||||
|
||||
cols.forEach((col, index) => {
|
||||
if (isPK(col)) {
|
||||
input[col.name] = row[index];
|
||||
}
|
||||
|
||||
params[col.name] = row[index];
|
||||
});
|
||||
|
||||
return { input, params };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { RowSelectionState } from "@tanstack/react-table";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export function useEditingTableRowSelection() {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const selectedRowIndices = useMemo(
|
||||
() => Object.keys(rowSelection).map(Number),
|
||||
[rowSelection],
|
||||
);
|
||||
|
||||
return {
|
||||
rowSelection,
|
||||
selectedRowIndices,
|
||||
setRowSelection,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ThunkDispatch, UnknownAction } from "@reduxjs/toolkit";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { datasetApi } from "metabase/api";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { checkNotNull } from "metabase/lib/types";
|
||||
import type { DatasetQuery } from "metabase-types/api";
|
||||
|
||||
import type { RowCellsWithPkValue } from "../api/types";
|
||||
import {
|
||||
getPkColumns,
|
||||
getRowObjectPkUniqueKeyByColumnNames,
|
||||
getRowUniqueKeyByPkIndexes,
|
||||
} from "../common/utils";
|
||||
|
||||
import type { TableEditingStateUpdateStrategy } from "./use-table-state-update-strategy";
|
||||
import {
|
||||
createPrimaryKeyToUpdatedRowObjectMap,
|
||||
mapDataEditingRowObjectsToRowValues,
|
||||
} from "./use-table-state-update-strategy";
|
||||
|
||||
export function useTableEditingStateAdHocQueryUpdateStrategy(
|
||||
nullableAdhocQuery: DatasetQuery | undefined,
|
||||
): TableEditingStateUpdateStrategy {
|
||||
// "metabase-api" state is not typed, so we need to use the `any` type
|
||||
const dispatch = useDispatch() as ThunkDispatch<any, any, UnknownAction>;
|
||||
|
||||
const onRowsCreated = useCallback(
|
||||
(rows: RowCellsWithPkValue[]) => {
|
||||
const adhocQuery = checkNotNull(nullableAdhocQuery);
|
||||
|
||||
dispatch(
|
||||
datasetApi.util.updateQueryData(
|
||||
"getAdhocQuery",
|
||||
adhocQuery,
|
||||
({ data }) => {
|
||||
data.rows.push(
|
||||
...mapDataEditingRowObjectsToRowValues(rows, data.cols),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
[dispatch, nullableAdhocQuery],
|
||||
);
|
||||
|
||||
const onRowsUpdated = useCallback(
|
||||
(rows: RowCellsWithPkValue[]) => {
|
||||
const adhocQuery = checkNotNull(nullableAdhocQuery);
|
||||
|
||||
const patchResult = dispatch(
|
||||
datasetApi.util.updateQueryData(
|
||||
"getAdhocQuery",
|
||||
adhocQuery,
|
||||
({ data }) => {
|
||||
const { indexes: pkIndexes, names: pkNames } = getPkColumns(
|
||||
data.cols,
|
||||
);
|
||||
|
||||
const primaryKeyToUpdatedRowObjectMap =
|
||||
createPrimaryKeyToUpdatedRowObjectMap(pkNames, rows);
|
||||
|
||||
for (const rowArray of data.rows) {
|
||||
const pkUniqueKey = getRowUniqueKeyByPkIndexes(
|
||||
pkIndexes,
|
||||
rowArray,
|
||||
);
|
||||
const updatedRowObject =
|
||||
primaryKeyToUpdatedRowObjectMap.get(pkUniqueKey);
|
||||
|
||||
if (updatedRowObject) {
|
||||
for (let i = 0; i < data.cols.length; i++) {
|
||||
const columnName = data.cols[i].name;
|
||||
if (columnName in updatedRowObject) {
|
||||
rowArray[i] = updatedRowObject[columnName];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
revert: patchResult.undo,
|
||||
};
|
||||
},
|
||||
[dispatch, nullableAdhocQuery],
|
||||
);
|
||||
|
||||
const onRowsDeleted = useCallback(
|
||||
(rows: RowCellsWithPkValue[]) => {
|
||||
const adhocQuery = checkNotNull(nullableAdhocQuery);
|
||||
|
||||
dispatch(
|
||||
datasetApi.util.updateQueryData(
|
||||
"getAdhocQuery",
|
||||
adhocQuery,
|
||||
({ data }) => {
|
||||
const { indexes: pkIndexes, names: pkNames } = getPkColumns(
|
||||
data.cols,
|
||||
);
|
||||
|
||||
const deletedPKsSet = new Set(
|
||||
rows.map((row) =>
|
||||
getRowObjectPkUniqueKeyByColumnNames(pkNames, row),
|
||||
),
|
||||
);
|
||||
|
||||
data.rows = data.rows.filter(
|
||||
(row) =>
|
||||
!deletedPKsSet.has(getRowUniqueKeyByPkIndexes(pkIndexes, row)),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
[dispatch, nullableAdhocQuery],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onRowsCreated,
|
||||
onRowsUpdated,
|
||||
onRowsDeleted,
|
||||
}),
|
||||
[onRowsCreated, onRowsUpdated, onRowsDeleted],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { DatasetColumn } from "metabase-types/api";
|
||||
|
||||
import type { RowCellsWithPkValue } from "../api/types";
|
||||
import { getRowObjectPkUniqueKeyByColumnNames } from "../common/utils";
|
||||
|
||||
export type OptimisticUpdatePatchResult = {
|
||||
revert: () => void;
|
||||
};
|
||||
|
||||
export interface TableEditingStateUpdateStrategy {
|
||||
onRowsCreated: (rows: RowCellsWithPkValue[]) => void;
|
||||
onRowsUpdated: (
|
||||
rows: RowCellsWithPkValue[],
|
||||
) => OptimisticUpdatePatchResult | void;
|
||||
onRowsDeleted: (rows: RowCellsWithPkValue[]) => void;
|
||||
}
|
||||
|
||||
export function mapDataEditingRowObjectsToRowValues(
|
||||
rows: RowCellsWithPkValue[],
|
||||
columns: DatasetColumn[],
|
||||
) {
|
||||
return rows.map((row) => columns.map((column) => row[column.name]));
|
||||
}
|
||||
|
||||
export function createPrimaryKeyToUpdatedRowObjectMap(
|
||||
pkColumnNames: string[],
|
||||
rows: RowCellsWithPkValue[],
|
||||
) {
|
||||
return new Map(
|
||||
rows.map((row) => {
|
||||
const pkUniqueKey = getRowObjectPkUniqueKeyByColumnNames(
|
||||
pkColumnNames,
|
||||
row,
|
||||
);
|
||||
return [pkUniqueKey, row];
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { DatabaseId, TableId } from "metabase-types/api";
|
||||
|
||||
export function getTableEditUrl(tableId: TableId, databaseId: DatabaseId) {
|
||||
return `/browse/databases/${databaseId}/tables/${tableId}/edit`;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export type DatabaseSettings = {
|
||||
|
||||
export type DatabaseFeature =
|
||||
| "actions"
|
||||
| "actions/data-editing"
|
||||
| "basic-aggregations"
|
||||
| "binning"
|
||||
| "case-sensitivity-string-filter-options"
|
||||
|
||||
@@ -156,7 +156,7 @@ export interface GetFieldValuesResponse {
|
||||
export interface SearchFieldValuesRequest {
|
||||
fieldId: FieldId;
|
||||
searchFieldId: FieldId;
|
||||
value: string;
|
||||
value?: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ export const createMockTokenFeatures = (
|
||||
ai_sql_generation: false,
|
||||
database_routing: false,
|
||||
development_mode: false,
|
||||
table_data_editing: false,
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
||||
@@ -286,6 +286,7 @@ export const tokenFeatures = [
|
||||
"development_mode",
|
||||
"etl_connections",
|
||||
"etl_connections_pg",
|
||||
"table_data_editing",
|
||||
] as const;
|
||||
|
||||
export type TokenFeature = (typeof tokenFeatures)[number];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Card, CardType } from "./card";
|
||||
import type { Database, DatabaseId, InitialSyncStatus } from "./database";
|
||||
import type { DatasetData } from "./dataset";
|
||||
import type { Field, FieldDimensionOption, FieldId } from "./field";
|
||||
import type { Segment } from "./segment";
|
||||
|
||||
@@ -129,3 +130,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;
|
||||
};
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface Undo {
|
||||
action: () => void;
|
||||
};
|
||||
ref?: RefObject<HTMLDivElement>;
|
||||
renderChildren?: (undo: Undo) => ReactNode;
|
||||
onDismiss?: (undoId: string | number) => void;
|
||||
}
|
||||
|
||||
export type UndoState = Undo[];
|
||||
|
||||
@@ -18,6 +18,7 @@ import { connect, useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import {
|
||||
PLUGIN_DATABASE_REPLICATION,
|
||||
PLUGIN_DB_ROUTING,
|
||||
PLUGIN_TABLE_EDITING,
|
||||
} from "metabase/plugins";
|
||||
import { getUserIsAdmin } from "metabase/selectors/user";
|
||||
import { Box, Divider, Flex } from "metabase/ui";
|
||||
@@ -123,6 +124,11 @@ function DatabaseEditAppInner({
|
||||
database={database}
|
||||
/>
|
||||
|
||||
<PLUGIN_TABLE_EDITING.AdminDatabaseTableEditingSection
|
||||
database={database}
|
||||
updateDatabase={updateDatabase}
|
||||
/>
|
||||
|
||||
<PLUGIN_DB_ROUTING.DatabaseRoutingSection
|
||||
database={database}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {
|
||||
Field,
|
||||
GetTableDataRequest,
|
||||
GetTableQueryMetadataRequest,
|
||||
GetTableRequest,
|
||||
Table,
|
||||
TableData,
|
||||
TableId,
|
||||
TableListQuery,
|
||||
UpdateTableFieldsOrderRequest,
|
||||
@@ -45,6 +47,12 @@ export const tableApi = Api.injectEndpoints({
|
||||
}),
|
||||
providesTags: (table) => (table ? provideTableTags(table) : []),
|
||||
}),
|
||||
getTableData: builder.query<TableData, GetTableDataRequest>({
|
||||
query: ({ tableId }) => ({
|
||||
method: "GET",
|
||||
url: `/api/table/${tableId}/data`,
|
||||
}),
|
||||
}),
|
||||
listTableForeignKeys: builder.query<Field[], TableId>({
|
||||
query: (id) => ({
|
||||
method: "GET",
|
||||
@@ -128,6 +136,7 @@ export const {
|
||||
useListTablesQuery,
|
||||
useGetTableQuery,
|
||||
useGetTableQueryMetadataQuery,
|
||||
useGetTableDataQuery,
|
||||
useLazyListTableForeignKeysQuery,
|
||||
useUpdateTableMutation,
|
||||
useUpdateTableListMutation,
|
||||
|
||||
@@ -8,7 +8,11 @@ import { BrowserCrumbs } from "metabase/common/components/BrowserCrumbs";
|
||||
import Link from "metabase/common/components/Link";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { color } from "metabase/lib/colors";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { isSyncInProgress } from "metabase/lib/syncing";
|
||||
import { PLUGIN_TABLE_EDITING } from "metabase/plugins";
|
||||
import { getDatabases } from "metabase/reference/selectors";
|
||||
import { getUserIsAdmin } from "metabase/selectors/user";
|
||||
import { Box, Group, Icon, Loader } from "metabase/ui";
|
||||
import { isVirtualCardId } from "metabase-lib/v1/metadata/utils/saved-questions";
|
||||
|
||||
@@ -18,7 +22,6 @@ import { trackTableClick } from "../analytics";
|
||||
import { useDatabaseCrumb } from "./useDatabaseCrumb";
|
||||
|
||||
const propTypes = {
|
||||
database: PropTypes.object,
|
||||
tables: PropTypes.array.isRequired,
|
||||
getTableUrl: PropTypes.func.isRequired,
|
||||
metadata: PropTypes.object,
|
||||
@@ -37,7 +40,14 @@ export const TableBrowser = ({
|
||||
xraysEnabled,
|
||||
showSchemaInHeader = true,
|
||||
}) => {
|
||||
const databases = useSelector(getDatabases);
|
||||
const database = databases[dbId];
|
||||
const isAdmin = useSelector(getUserIsAdmin);
|
||||
const databaseCrumb = useDatabaseCrumb(dbId);
|
||||
const canEditTables =
|
||||
database &&
|
||||
isAdmin &&
|
||||
PLUGIN_TABLE_EDITING.isDatabaseTableEditingEnabled(database);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -59,6 +69,7 @@ export const TableBrowser = ({
|
||||
getTableUrl={getTableUrl}
|
||||
xraysEnabled={xraysEnabled}
|
||||
metadata={metadata}
|
||||
canEditTables={canEditTables}
|
||||
/>
|
||||
))}
|
||||
</BrowseGrid>
|
||||
@@ -74,6 +85,7 @@ const itemPropTypes = {
|
||||
xraysEnabled: PropTypes.bool,
|
||||
metadata: PropTypes.object,
|
||||
getTableUrl: PropTypes.func.isRequired,
|
||||
canEditTables: PropTypes.bool,
|
||||
};
|
||||
|
||||
const TableBrowserItem = ({
|
||||
@@ -82,6 +94,7 @@ const TableBrowserItem = ({
|
||||
xraysEnabled,
|
||||
metadata,
|
||||
getTableUrl,
|
||||
canEditTables,
|
||||
}) => {
|
||||
const isVirtual = isVirtualCardId(table.id);
|
||||
const isLoading = isSyncInProgress(table);
|
||||
@@ -100,6 +113,7 @@ const TableBrowserItem = ({
|
||||
tableId={table.id}
|
||||
dbId={dbId}
|
||||
xraysEnabled={xraysEnabled}
|
||||
canEditTables={canEditTables}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -113,9 +127,15 @@ const itemButtonsPropTypes = {
|
||||
tableId: PropTypes.number,
|
||||
dbId: PropTypes.number,
|
||||
xraysEnabled: PropTypes.bool,
|
||||
canEditTables: PropTypes.bool,
|
||||
};
|
||||
|
||||
const TableBrowserItemButtons = ({ tableId, dbId, xraysEnabled }) => {
|
||||
const TableBrowserItemButtons = ({
|
||||
tableId,
|
||||
dbId,
|
||||
xraysEnabled,
|
||||
canEditTables,
|
||||
}) => {
|
||||
return (
|
||||
<Box className={cx(CS.hoverChild)}>
|
||||
<Group gap="md">
|
||||
@@ -128,6 +148,18 @@ const TableBrowserItemButtons = ({ tableId, dbId, xraysEnabled }) => {
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
{canEditTables && (
|
||||
<Link
|
||||
to={PLUGIN_TABLE_EDITING.getTableEditUrl(tableId, dbId)}
|
||||
data-testid="edit-table-icon"
|
||||
>
|
||||
<Icon
|
||||
name="pencil"
|
||||
tooltip={t`Edit this table`}
|
||||
color={"var(--mb-color-text-medium)"}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
<Link to={`/reference/databases/${dbId}/tables/${tableId}`}>
|
||||
<Icon
|
||||
name="reference"
|
||||
|
||||
@@ -128,7 +128,11 @@ function UndoToast({
|
||||
color={undo.iconColor ?? "var(--mb-color-text-secondary-inverse)"}
|
||||
/>
|
||||
)}
|
||||
<Ellipsified showTooltip={false}>{renderMessage(undo)}</Ellipsified>
|
||||
{undo.renderChildren ? (
|
||||
undo.renderChildren(undo)
|
||||
) : (
|
||||
<Ellipsified showTooltip={false}>{renderMessage(undo)}</Ellipsified>
|
||||
)}
|
||||
</CardContentSide>
|
||||
<ControlsCardContent>
|
||||
{undo.actions && undo.actions.length > 0 && (
|
||||
@@ -174,7 +178,10 @@ export function UndoListing() {
|
||||
<UndoListOverlay
|
||||
undos={undos}
|
||||
onUndo={(undo) => dispatch(performUndo(undo.id))}
|
||||
onDismiss={(undo) => dispatch(dismissUndo({ undoId: undo.id }))}
|
||||
onDismiss={(undo) => {
|
||||
undo.onDismiss?.(undo.id);
|
||||
dispatch(dismissUndo({ undoId: undo.id }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useMount } from "react-use";
|
||||
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { closeNavbar } from "metabase/redux/app";
|
||||
|
||||
export function useCloseNavbarOnMount() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useMount(() => {
|
||||
dispatch(closeNavbar());
|
||||
});
|
||||
}
|
||||
@@ -71,6 +71,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>>;
|
||||
|
||||
@@ -78,10 +81,18 @@ export interface ColumnOptions<TRow extends RowData, TValue = unknown> {
|
||||
cellVariant?: BodyCellVariant;
|
||||
|
||||
/** Function to determine CSS class names for cells */
|
||||
getCellClassName?: (value: TValue, rowIndex: number) => string;
|
||||
getCellClassName?: (
|
||||
value: TValue,
|
||||
rowIndex: number,
|
||||
columnId: string,
|
||||
) => string;
|
||||
|
||||
/** Function to determine CSS styles for cells */
|
||||
getCellStyle?: (value: TValue, rowIndex: number) => React.CSSProperties;
|
||||
getCellStyle?: (
|
||||
value: TValue,
|
||||
rowIndex: number,
|
||||
columnId: string,
|
||||
) => React.CSSProperties;
|
||||
|
||||
/** Visual style of the header cell */
|
||||
headerVariant?: HeaderCellVariant;
|
||||
@@ -109,6 +120,9 @@ export interface ColumnOptions<TRow extends RowData, TValue = unknown> {
|
||||
|
||||
/** Function to format cell values when copying to clipboard */
|
||||
clipboardFormatter?: PlainCellFormatter<TValue>;
|
||||
|
||||
/** Function to determine if a cell is in editing state */
|
||||
getIsEditing?: (columnId: string, rowIndex: number) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,17 +24,23 @@ const getDefaultCellTemplate = <TRow, TValue>(
|
||||
wrap,
|
||||
getCellClassName,
|
||||
getCellStyle,
|
||||
getIsEditing,
|
||||
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(
|
||||
props: CellContext<TRow, TValue> & { isSelected?: boolean },
|
||||
) {
|
||||
const { getValue, row, isSelected } = props;
|
||||
const value = getValue();
|
||||
const backgroundColor = getBackgroundColor?.(value, row?.index);
|
||||
const isEditing = getIsEditing?.(id, row.index);
|
||||
|
||||
if (isEditing && EditingCellComponent) {
|
||||
return <EditingCellComponent {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BodyCell
|
||||
@@ -49,8 +55,8 @@ const getDefaultCellTemplate = <TRow, TValue>(
|
||||
onExpand={onExpand}
|
||||
variant={cellVariant}
|
||||
wrap={wrap}
|
||||
className={getCellClassName?.(value, row.index)}
|
||||
style={getCellStyle?.(value, row.index)}
|
||||
className={getCellClassName?.(value, row.index, id)}
|
||||
style={getCellStyle?.(value, row.index, id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -574,7 +574,7 @@ export const FIELD_SEMANTIC_TYPES: FieldSemanticType[] = [
|
||||
|
||||
export const FIELD_SEMANTIC_TYPES_MAP = FIELD_SEMANTIC_TYPES.reduce(
|
||||
(map, type) => Object.assign({}, map, { [type.id]: type }),
|
||||
{},
|
||||
{} as Record<FieldSemanticType["id"], FieldSemanticType>,
|
||||
);
|
||||
|
||||
export const HAS_FIELD_VALUES_OPTIONS = [
|
||||
|
||||
@@ -67,6 +67,8 @@ import type {
|
||||
DashCardId,
|
||||
Dashboard,
|
||||
DashboardId,
|
||||
DatabaseData,
|
||||
DatabaseId,
|
||||
Database as DatabaseType,
|
||||
Dataset,
|
||||
Group,
|
||||
@@ -804,3 +806,16 @@ export const PLUGIN_SMTP_OVERRIDE: {
|
||||
CloudSMTPConnectionCard: PluginPlaceholder,
|
||||
SMTPOverrideConnectionForm: PluginPlaceholder,
|
||||
};
|
||||
|
||||
export const PLUGIN_TABLE_EDITING = {
|
||||
isEnabled: () => false,
|
||||
isDatabaseTableEditingEnabled: (_database: DatabaseType): boolean => false,
|
||||
getRoutes: () => null as React.ReactElement | null,
|
||||
getTableEditUrl: (_tableId: TableId, _databaseId: DatabaseId): string => "/",
|
||||
AdminDatabaseTableEditingSection: PluginPlaceholder as ComponentType<{
|
||||
database: DatabaseType;
|
||||
updateDatabase: (
|
||||
database: { id: DatabaseId } & Partial<DatabaseData>,
|
||||
) => Promise<void>;
|
||||
}>,
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
PLUGIN_EMBEDDING_IFRAME_SDK_SETUP,
|
||||
PLUGIN_LANDING_PAGE,
|
||||
PLUGIN_METABOT,
|
||||
PLUGIN_TABLE_EDITING,
|
||||
} from "metabase/plugins";
|
||||
import { QueryBuilder } from "metabase/query_builder/containers/QueryBuilder";
|
||||
import { loadCurrentUser } from "metabase/redux/user";
|
||||
@@ -306,6 +307,8 @@ export const getRoutes = (store) => {
|
||||
component={BrowseTables}
|
||||
/>
|
||||
|
||||
{PLUGIN_TABLE_EDITING.getRoutes()}
|
||||
|
||||
{/* These two Redirects support legacy paths in v48 and earlier */}
|
||||
<Redirect from=":dbId-:slug" to="databases/:dbId-:slug" />
|
||||
<Redirect
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
|
||||
import CalendarStyles from "../Calendar/Calendar.module.css";
|
||||
|
||||
import Styles from "./DateTimePicker.module.css";
|
||||
|
||||
export const dateTimePickerOverrides = {
|
||||
DateTimePicker: DateTimePicker.extend({
|
||||
defaultProps: {
|
||||
size: "md",
|
||||
popoverProps: {
|
||||
styles: {
|
||||
dropdown: {
|
||||
/* Padding workaround since popover padding is overridden to zero */
|
||||
"--popover-padding": "var(--mantine-spacing-md)",
|
||||
},
|
||||
},
|
||||
},
|
||||
submitButtonProps: {
|
||||
variant: "light",
|
||||
},
|
||||
},
|
||||
classNames: {
|
||||
levelsGroup: Styles.levelsGroup,
|
||||
timeWrapper: Styles.timeWrapper,
|
||||
day: CalendarStyles.day,
|
||||
weekday: CalendarStyles.weekday,
|
||||
month: CalendarStyles.month,
|
||||
monthRow: CalendarStyles.row,
|
||||
monthCell: CalendarStyles.cell,
|
||||
monthsList: CalendarStyles.monthsList,
|
||||
monthsListRow: CalendarStyles.row,
|
||||
monthsListCell: CalendarStyles.cell,
|
||||
yearsList: CalendarStyles.yearsList,
|
||||
yearsListRow: CalendarStyles.row,
|
||||
yearsListCell: CalendarStyles.cell,
|
||||
calendarHeader: CalendarStyles.calendarHeader,
|
||||
calendarHeaderLevel: CalendarStyles.calendarHeaderLevel,
|
||||
calendarHeaderControl: CalendarStyles.calendarHeaderControl,
|
||||
},
|
||||
styles: {
|
||||
weekdaysRow: {
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Canvas, Story, Meta } from "@storybook/blocks";
|
||||
import { DatePicker, Box } from "metabase/ui";
|
||||
import * as DateTimePickerStories from "./DateTimePicker.stories";
|
||||
|
||||
<Meta of={DateTimePickerStories} />
|
||||
|
||||
# DateTimePicker
|
||||
|
||||
Our themed wrapper around [Mantine DateTimePicker](https://mantine.dev/dates/date-time-picker/).
|
||||
|
||||
## Docs
|
||||
|
||||
- [Mantine DatePicker Docs](https://mantine.dev/dates/date-time-picker/)
|
||||
|
||||
## Examples
|
||||
|
||||
<Canvas of={DateTimePickerStories.Default} />
|
||||
|
||||
### With Seconds
|
||||
|
||||
<Canvas of={DateTimePickerStories.WithSeconds} />
|
||||
|
||||
### Value Format
|
||||
|
||||
<Canvas of={DateTimePickerStories.ValueFormat} />
|
||||
@@ -0,0 +1,11 @@
|
||||
.levelsGroup {
|
||||
/* Padding workaround since popover padding is overridden to zero */
|
||||
padding: var(--popover-padding);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timeWrapper {
|
||||
/* Padding workaround since popover padding is overridden to zero */
|
||||
padding: var(--popover-padding);
|
||||
padding-top: 0;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { DateTimePicker } from "metabase/ui";
|
||||
|
||||
const args = {};
|
||||
|
||||
const sampleArgs = {
|
||||
date1: new Date(2023, 9, 8, 9, 1, 2),
|
||||
date2: new Date(2023, 9, 24, 10, 2, 3),
|
||||
date3: new Date(2023, 9, 16, 23, 54, 0),
|
||||
};
|
||||
|
||||
const argTypes = {};
|
||||
|
||||
export default {
|
||||
title: "Components/Inputs/DateTimePicker",
|
||||
component: DateTimePicker,
|
||||
args,
|
||||
argTypes,
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
args: {
|
||||
defaultValue: sampleArgs.date1,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSeconds = {
|
||||
args: {
|
||||
defaultValue: sampleArgs.date2,
|
||||
withSeconds: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ValueFormat = {
|
||||
args: {
|
||||
defaultValue: sampleArgs.date3,
|
||||
valueFormat: "HH:mm on YYYY-MM-DD",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { DateTimePicker } from "@mantine/dates";
|
||||
export type { DateTimePickerProps } from "@mantine/dates";
|
||||
export { dateTimePickerOverrides } from "./DateTimePicker.config";
|
||||
@@ -5,6 +5,7 @@ export * from "./Chip";
|
||||
export * from "./Combobox";
|
||||
export * from "./DateInput";
|
||||
export * from "./DatePicker";
|
||||
export * from "./DateTimePicker";
|
||||
export * from "./FileInput";
|
||||
export * from "./Input";
|
||||
export * from "./MonthPicker";
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
comboboxOverrides,
|
||||
dateInputOverrides,
|
||||
datePickerOverrides,
|
||||
dateTimePickerOverrides,
|
||||
dividerOverrides,
|
||||
fileInputOverrides,
|
||||
hoverCardOverrides,
|
||||
@@ -148,6 +149,7 @@ export const getThemeOverrides = (): MantineThemeOverride => ({
|
||||
...codeOverrides,
|
||||
...dateInputOverrides,
|
||||
...datePickerOverrides,
|
||||
...dateTimePickerOverrides,
|
||||
...dividerOverrides,
|
||||
...fileInputOverrides,
|
||||
...inputOverrides,
|
||||
|
||||
@@ -37,17 +37,17 @@
|
||||
(mu/defmethod channel/send! :channel/http
|
||||
[{{:keys [url method auth-method auth-info]} :details} :- HTTPChannel
|
||||
request]
|
||||
(let [req (merge
|
||||
{:accept :json
|
||||
:content-type :json
|
||||
:method :post
|
||||
:url url}
|
||||
(when method
|
||||
{:method (keyword method)})
|
||||
(cond-> request
|
||||
(= "request-body" auth-method) (update :body merge auth-info)
|
||||
(= "header" auth-method) (update :headers merge auth-info)
|
||||
(= "query-param" auth-method) (update :query-params merge auth-info)))]
|
||||
(let [req #p (merge
|
||||
{:accept :json
|
||||
:content-type :json
|
||||
:method :get
|
||||
:url url}
|
||||
(when method
|
||||
{:method (keyword method)})
|
||||
(cond-> request
|
||||
(= "request-body" auth-method) (update :body merge auth-info)
|
||||
(= "header" auth-method) (update :headers merge auth-info)
|
||||
(= "query-param" auth-method) (update :query-params merge auth-info)))]
|
||||
(http/request (cond-> req
|
||||
(or (map? (:body req))
|
||||
(sequential? (:body req))) (update :body json/encode)))))
|
||||
|
||||
@@ -716,7 +716,7 @@
|
||||
:type :query
|
||||
:query {:source-table table-id
|
||||
:filter (row->mbql-filter-clause pk-name->id pk-column->value)}
|
||||
:update-row (apply dissoc row pk-names)}))
|
||||
:update-row #p (apply dissoc row pk-names)}))
|
||||
|
||||
(mu/defn- table-row-update!
|
||||
[_action context inputs :- [:sequential ::table-row-input]]
|
||||
|
||||
Reference in New Issue
Block a user