Compare commits

...

7 Commits

Author SHA1 Message Date
Ngoc Khuat
909d0bcc1c simple validation 2025-08-12 18:29:20 +07:00
Ngoc Khuat
56021cd416 Merge branch 'master' into feature/table-data-editing-frontend 2025-08-11 17:00:25 +07:00
Konstantin Darutkin
ece9d3752a Trigger Build 2025-08-07 14:06:13 +03:00
Konstantin Darutkin
51ca0bdd86 Merge branch 'master' into feature/table-data-editing-frontend 2025-08-07 10:53:39 +03:00
Chris Truter
daffebebd0 Reapply "fix: admin check and more tests (#61594)"
This reverts commit 71df2022c1.
2025-07-31 10:38:31 +02:00
Chris Truter
40d30364b0 Reapply "feat: hide enable editing setting for unsupported database drivers (#61544)"
This reverts commit 30bb713240.
2025-07-31 10:38:31 +02:00
Chris Truter
171903cd20 Reapply "feature(enterprise): table editing frontend plugin (#61326)"
This reverts commit 6970110108.
2025-07-31 10:38:31 +02:00
89 changed files with 4936 additions and 28 deletions

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

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

View File

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

View File

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

View File

@@ -39,3 +39,4 @@ import "./user_provisioning";
import "./clean_up";
import "./metabot";
import "./database_replication";
import "./table-editing";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
import { msgid, ngettext, t } from "ttag";
import { formatNumber } from "metabase/lib/formatting/numbers";
import { HARD_ROW_LIMIT } from "metabase-lib/v1/queries/utils/index";
import type { Dataset } from "metabase-types/api";
export const formatRowCount = (count: number) => {
const countString = formatNumber(count);
return ngettext(msgid`${countString} row`, `${countString} rows`, count);
};
export function getRowCountMessage(result: Dataset): string {
if (result.data.rows_truncated > 0) {
return t`Showing first ${formatRowCount(result.row_count)}`;
}
if (result.row_count === HARD_ROW_LIMIT) {
return t`Showing first ${HARD_ROW_LIMIT} rows`;
}
return t`Showing ${formatRowCount(result.row_count)}`;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ export type DatabaseSettings = {
export type DatabaseFeature =
| "actions"
| "actions/data-editing"
| "basic-aggregations"
| "binning"
| "case-sensitivity-string-filter-options"

View File

@@ -156,7 +156,7 @@ export interface GetFieldValuesResponse {
export interface SearchFieldValuesRequest {
fieldId: FieldId;
searchFieldId: FieldId;
value: string;
value?: string;
limit: number;
}

View File

@@ -134,6 +134,7 @@ export const createMockTokenFeatures = (
ai_sql_generation: false,
database_routing: false,
development_mode: false,
table_data_editing: false,
...opts,
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export { DateTimePicker } from "@mantine/dates";
export type { DateTimePickerProps } from "@mantine/dates";
export { dateTimePickerOverrides } from "./DateTimePicker.config";

View File

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

View File

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

View File

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

View File

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