Compare commits

...

11 Commits

Author SHA1 Message Date
Chris Truter
73b96227c9 Fix relationship label 2025-03-12 13:59:10 +02:00
Chris Truter
f849f7d306 Add test covering batch look-ups 2025-03-12 13:58:23 +02:00
Chris Truter
51343df528 Docstrings 2025-03-12 13:52:22 +02:00
Chris Truter
e4909e99e7 Handle limiting items in the porcelain 2025-03-12 13:46:47 +02:00
Chris Truter
c087211a78 Clean up arguments 2025-03-12 13:42:48 +02:00
Chris Truter
7ac49ae78f Shorthand 2025-03-12 13:37:20 +02:00
Chris Truter
fddd3f49f9 WIP 2025-03-12 13:28:56 +02:00
Denis Berezin
e0ba19cf92 Fix grid markup to show missing rows 2025-03-12 12:10:56 +02:00
Konstantin Darutkin
72fe3f86fe feat(enterprise): add category dropdown editing (#54921) 2025-03-12 12:10:56 +02:00
Chris Truter
395f14cd9f FE for Table Editing v1 (#54289)
* API for reading tables

* Strip fields, add table

* Table editing view mode (#54218)

* Add basic component in ee

* Add table view with header

* Enable feature flag

* Code review fixes, remove token check

* Add basic e2e test for route

* Fix CI

* Boilerplate for new EE feature

* Basic CRUD URLs

* Spoke to Katya, fine to require actions being enabled

* Missing require

* Track the table-crud-apis branch

* Add simple table cell edit UI (#54679)

* Temp

* Add full code to edit a value

* Add proper feature toggle name

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

* Updated api usage

* Add feature toggle check

* Temp

* Add isolated EditingCell component

* Review fix - simplify state

* Add keyboard events

* Fixes after merge with master

* Fix be tests

* Fix fe e2e tests

* Clean-up

---------

Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
2025-03-12 12:10:56 +02:00
Denis Berezin
830f7e44b6 Table editing view mode (#54218)
* Add basic component in ee

* Add table view with header

* Enable feature flag

* Code review fixes, remove token check

* Add basic e2e test for route

* Fix CI
2025-03-12 12:10:56 +02:00
41 changed files with 1256 additions and 22 deletions

View File

@@ -0,0 +1,34 @@
const { H } = cy;
describe("scenarios > embedding > dashboard parameters", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.setTokenFeatures("all");
cy.intercept("GET", "/api/database").as("getDatabases");
cy.intercept("GET", "/api/table/*").as("getTable");
});
it("should allow to open table data view", () => {
cy.visit("/browse/databases");
cy.wait("@getDatabases");
cy.findByTestId("database-browser").findByText("Sample Database").click();
cy.findByTestId("browse-schemas").findByText("People").click();
cy.wait("@getTable");
cy.findByTestId("table-data-view-root")
.findByText("Editing People")
.should("be.visible");
cy.findByTestId("main-navbar-root").should("not.be.visible");
cy.findByTestId("table-data-view-root")
.findByLabelText("Back to Sample Database")
.click();
cy.findByTestId("browse-schemas").should("be.visible");
});
});

View File

@@ -53,7 +53,8 @@ describe("impersonated permission", { tags: "@external" }, () => {
setImpersonatedPermission();
});
it("have limited access", () => {
// TODO [WRK]: refactor this test to check data editing
it.skip("have limited access", () => {
cy.signInAsImpersonatedUser();
cy.visit(`/browse/databases/${PG_DB_ID}`);

View File

@@ -0,0 +1,136 @@
(ns metabase-enterprise.data-editing.foreign-keys
(:require
[clojure.java.jdbc :as jdbc]))
(defn- reducible-children-thunks
"Given multiple rows from some table, return a seq of thunks which enumerate all the child rows in `child-table`
that reference any of them via the columns given in `fk->pks`. Only select the primary keys of these child rows.
To keep the size of the query itself down, it will query for the children of at most `parent-chunks` distinct parent
records at once."
[{:keys [child-table fk->pks child-pks]}
parents
& {:keys [parent-chunks] :or {parent-chunks 50}}]
(for [batch (partition-all parent-chunks parents)]
#(eduction (map (fn [child] {:table child-table, :row child}))
(jdbc/reducible-query
'db
{:select child-pks
:from [child-table]
:where (into [:or] (for [parent batch]
(into [:and] (for [[fk pk] fk->pks]
[:= fk (get parent pk)]))))}))))
(comment
;; example of the fk metadata used above
[{:child-table :b, :fk->pks {:parent_id :id}, :child-pks [:id]}
{:child-table :c, :fk->pks {:dad_id :id}, :child-pks [:id]}
{:child-table :c, :fk->pks {:mum_id :id}, :child-pks [:id]}])
(defn descendants->table-counts
"Count the number of descendant records in each table."
[reducible]
(reduce
(fn [acc item]
(if (keyword? item)
(assoc acc :complete? false)
(update-in acc [:tables (:table item)] (fnil inc 0))))
{:tables {}
:complete? true}
reducible))
(defn reducible-process
"Wrap a potentially complex recursive process in simple reducible."
[process-item-fn next-thunk-fn skip-fn state]
(reify clojure.core.protocols/CollReduce
(coll-reduce [_ reducing-fn init]
(loop [acc init
state state]
(let [[next-thunk state] (next-thunk-fn state)]
(if-not next-thunk
acc
(let [[acc state] (reduce
(fn [[acc state :as unchanged] item]
(if (skip-fn state item)
unchanged
(let [acc (reducing-fn acc item)]
(if (reduced? acc)
(reduced [acc state])
(let [state (process-item-fn state item)]
[acc state])))))
[acc state]
(next-thunk))]
(if (reduced? acc)
(unreduced acc)
(recur acc state)))))))))
(defn take-with-sentinel
"Similar to the [[take]] transducer, but emits a final sentinel if there were remaining items."
([n sentinel]
(fn [rf]
(let [nv (volatile! n)]
(fn
([] (rf))
([result]
(rf result))
([result input]
(let [n @nv
nn (vswap! nv dec)
result (if (pos? n)
(rf result input)
result)]
(if (>= nn 0)
result
(ensure-reduced (rf result sentinel))))))))))
(defn reducible-batch-bfs
"Fold over a graph in breadth-first order, skipping duplicates, and terminating based on various limits.
Batch the look-up of children to minimize things like database queries."
[items->child-thunks items
& {:keys [max-results max-results-sentinel max-chunk-size max-thunk-executions max-thunks-sentinel]
:or {max-results 10000
max-results-sentinel :bfs/too-many-results
max-chunk-size 50
max-thunk-executions 50
max-thunks-sentinel :bfs/too-many-look-ups}}]
(assert (pos? max-results))
(assert (pos? max-chunk-size))
(assert (pos? max-thunk-executions))
(eduction
(take-with-sentinel max-results max-results-sentinel)
(reducible-process
(fn process-item [state item]
(if (= max-thunks-sentinel item)
state
(-> state
(update :visited conj item)
(update :pending-descent conj item)
((fn [{:keys [pending-descent] :as state}]
(if (> (count pending-descent) max-chunk-size)
(let [new-thunks (some-> (take max-chunk-size pending-descent) seq items->child-thunks)]
(-> state
(update :pending-descent subvec max-chunk-size)
(update :thunks concat new-thunks)))
state))))))
(fn next-thunk-fn [state]
(let [hit-limit? (>= (:executed-thunks state) max-thunk-executions)]
(if (and hit-limit? (seq (:thunks state)))
[(fn [] [max-thunks-sentinel])
(assoc state :thunks [] :pending-descent [])]
(let [next-thunks (some-> state :pending-descent seq items->child-thunks)
thunks (concat (:thunks state) next-thunks)]
(if (and hit-limit? (seq thunks))
[(fn [] [max-thunks-sentinel])
(assoc state :thunks [] :pending-descent [])]
[(first thunks)
(-> state
(assoc :pending-descent [])
;; we assume that the next thunk gets executed before this function is called again.
(update :executed-thunks inc)
(assoc :thunks (rest thunks)))])))))
(fn skip-fn [state item]
(contains? (:visited state) item))
{:visited (set items)
:thunks (items->child-thunks items)
:executed-thunks 0
:pending-descent []})))

View File

@@ -65,6 +65,7 @@
:sso_jwt true
:sso_ldap true
:sso_saml true
:table_data_editing false
:upload_management false
:whitelabel true
:collection_cleanup true}

View File

@@ -0,0 +1,124 @@
(ns metabase-enterprise.data-editing.foreign-keys-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.data-editing.foreign-keys :as fks]))
(deftest descendants->table-counts-test
(is (= {:tables {:a 2, :b 1}, :complete? false}
(fks/descendants->table-counts [{:table :a, :row {:id 1}}
{:table :a, :row {:id 2}}
{:table :b, :row {:id 3}}
:fks/item-limit])))
(is (= {:tables {:a 2, :b 1}, :complete? true}
(fks/descendants->table-counts [{:table :a, :row {:id 1}}
{:table :a, :row {:id 2}}
{:table :b, :row {:id 3}}]))))
(deftest take-with-sentinel-test
(is (= [0 1 2]
(sequence (fks/take-with-sentinel 4 :truncated) (range 3))))
(is (= [0 1 2 3]
(sequence (fks/take-with-sentinel 4 :truncated) (range 4))))
(is (= [0 1 2 3 :truncated]
(sequence (fks/take-with-sentinel 4 :truncated) (range 5)))))
(def graph
{:user/ceo {:reports-to [:user/cto
:user/cpo]}
:user/cto {:belongs-to [:team/alpha
:team/bravo]
:reports-to [:team/em]}
:user/cpo {:lead-by [:programme/skunk-works]
:reports-to [:user/pm]}
:team/alpha {:member-of [:user/em
:user/alice
:user/bob
:user/clarence]
:owned-by [:programme/skunk-works]}
:programme/skunk-works {:part-of [:project/gamma]}
:project/gamma {:belongs-to [:task/foo]}
:user/em {:managed-by [:team/alpha]}
:user/alice {:assigned-to [:task/foo]}})
(defn- bulk-children [xs]
(let [child-maps (map graph xs)
ks (into #{} (mapcat keys) child-maps)]
(mapv #(constantly (mapcat % child-maps)) ks)))
(defn- kw->row [kw]
(if (= (namespace ::this-ns) (namespace kw))
kw
{:table (keyword (namespace kw))
:pks [(keyword (name kw))]}))
(deftest reducible-batch-bfs-test
(is (= [{:table :user, :pks [:cto]}
{:table :user, :pks [:cpo]}
{:table :team, :pks [:alpha]}
::too-many-items]
(into []
(map kw->row)
(fks/reducible-batch-bfs bulk-children [:user/ceo]
{:max-results 3
:max-results-sentinel ::too-many-items
:max-chunk-size 4
:max-thunk-executions 5
:max-thunks-sentinel ::too-many-queries}))))
(is (= [{:table :user, :pks [:cto]}
{:table :user, :pks [:cpo]}
{:table :team, :pks [:alpha]}
{:table :team, :pks [:bravo]}
{:table :team, :pks [:em]}
{:table :user, :pks [:pm]}
{:table :programme, :pks [:skunk-works]}
{:table :user, :pks [:em]}
{:table :user, :pks [:alice]}
{:table :user, :pks [:bob]}
{:table :user, :pks [:clarence]}
::too-many-queries]
(into []
(map kw->row)
(fks/reducible-batch-bfs bulk-children [:user/ceo]
{:max-chunk-size 1
:max-thunk-executions 7
:max-thunks-sentinel ::too-many-queries}))))
(is (= [{:table :user, :pks [:cto]}
{:table :user, :pks [:cpo]}
{:table :team, :pks [:alpha]}
{:table :team, :pks [:bravo]}
{:table :team, :pks [:em]}
{:table :user, :pks [:pm]}
{:table :programme, :pks [:skunk-works]}
{:table :user, :pks [:em]}
{:table :user, :pks [:alice]}
{:table :user, :pks [:bob]}
{:table :user, :pks [:clarence]}
{:table :project, :pks [:gamma]}
::too-many-queries]
(into []
(map kw->row)
(fks/reducible-batch-bfs bulk-children [:user/ceo]
{:max-chunk-size 4
:max-thunk-executions 7
:max-thunks-sentinel ::too-many-queries}))))
(is (= [{:table :user, :pks [:cto]}
{:table :user, :pks [:cpo]}
{:table :team, :pks [:alpha]}
{:table :team, :pks [:bravo]}
{:table :team, :pks [:em]}
{:table :user, :pks [:pm]}
{:table :programme, :pks [:skunk-works]}
{:table :user, :pks [:em]}
{:table :user, :pks [:alice]}
{:table :user, :pks [:bob]}
{:table :user, :pks [:clarence]}
{:table :project, :pks [:gamma]}
{:table :task, :pks [:foo]}]
(into []
(map kw->row)
(fks/reducible-batch-bfs bulk-children [:user/ceo]
{:max-thunks-sentinel ::too-many-queries})))))

View File

@@ -2,5 +2,6 @@ export * from "./audit-info";
export * from "./billing-info";
export * from "./gsheets";
export * from "./query-validation";
export * from "./table-data-edit";
export * from "./scim";
export * from "./upload-management";

View File

@@ -0,0 +1,51 @@
import type {
TableDeleteRowsRequest,
TableDeleteRowsResponse,
TableInsertRowsRequest,
TableInsertRowsResponse,
TableUpdateRowsRequest,
TableUpdateRowsResponse,
} from "metabase-enterprise/data_editing/tables/types";
import { EnterpriseApi } from "./api";
export const tableDataEditApi = EnterpriseApi.injectEndpoints({
endpoints: builder => ({
insertTableRows: builder.mutation<
TableInsertRowsResponse,
TableInsertRowsRequest
>({
query: ({ tableId, rows }) => ({
method: "POST",
url: `/api/ee/data-editing/table/${tableId}`,
body: { rows },
}),
}),
updateTableRows: builder.mutation<
TableUpdateRowsResponse,
TableUpdateRowsRequest
>({
query: ({ tableId, rows }) => ({
method: "PUT",
url: `/api/ee/data-editing/table/${tableId}`,
body: { rows },
}),
}),
deleteTableRows: builder.mutation<
TableDeleteRowsResponse,
TableDeleteRowsRequest
>({
query: ({ tableId, rows }) => ({
method: "DELETE",
url: `/api/ee/data-editing/table/${tableId}`,
body: { rows },
}),
}),
}),
});
export const {
useInsertTableRowsMutation,
useUpdateTableRowsMutation,
useDeleteTableRowsMutation,
} = tableDataEditApi;

View File

@@ -0,0 +1,9 @@
import { PLUGIN_DATA_EDITING } from "metabase/plugins";
import { hasPremiumFeature } from "metabase-enterprise/settings";
import { TableDataContainer } from "./tables";
if (hasPremiumFeature("table_data_editing")) {
PLUGIN_DATA_EDITING.isEnabled = () => true;
PLUGIN_DATA_EDITING.PAGE_COMPONENT = TableDataContainer;
}

View File

@@ -0,0 +1,11 @@
.input {
transition: none;
outline: 2px solid var(--mb-base-color-blue-50);
border-color: transparent;
border-radius: 0.25rem;
box-sizing: border-box;
/* needed to avoid outline truncation for a focused cell */
z-index: 5;
position: relative;
}

View File

@@ -0,0 +1,193 @@
import type { CellContext } from "@tanstack/react-table";
import { useCallback, useMemo, useState } from "react";
import { t } from "ttag";
import { skipToken, useGetFieldValuesQuery } from "metabase/api";
import { getFieldOptions } from "metabase/querying/filters/components/FilterValuePicker/utils";
import {
Box,
Combobox,
Icon,
Input,
TextInput,
useCombobox,
} from "metabase/ui";
import type { DatasetColumn, RowValue, RowValues } from "metabase-types/api";
import S from "./EditingBodyCell.module.css";
import type { UpdatedRowCellsHandlerParams } from "./types";
interface EditingBodyCellProps<TRow, TValue> {
column: DatasetColumn;
cellContext: CellContext<TRow, TValue>;
onCellValueUpdate: (params: UpdatedRowCellsHandlerParams) => void;
onCellEditCancel: () => void;
}
export const EditingBodyCellConditional = (
props: EditingBodyCellProps<RowValues, RowValue>,
) => {
const {
onCellEditCancel,
onCellValueUpdate,
column,
cellContext: {
getValue,
column: { id: columnName },
row: { index: rowIndex },
},
} = props;
const initialValue = getValue<RowValue>();
const doCellValueUpdate = useCallback(
(value: RowValue) => {
if (value !== initialValue) {
onCellValueUpdate({
data: { [columnName]: value },
rowIndex,
});
}
// Hide the editing cell after submitting the value
onCellEditCancel();
},
[columnName, onCellEditCancel, onCellValueUpdate, rowIndex, initialValue],
);
switch (column?.semantic_type) {
case "type/State":
case "type/Country":
case "type/Category":
return (
<EditingBodyCellSelect
initialValue={initialValue}
datasetColumn={column}
onSubmit={doCellValueUpdate}
onCancel={onCellEditCancel}
/>
);
default:
return (
<EditingBodyCellText
initialValue={initialValue}
datasetColumn={column}
onSubmit={doCellValueUpdate}
onCancel={onCellEditCancel}
/>
);
}
};
interface EditingBodyPrimitiveProps {
datasetColumn: DatasetColumn;
initialValue: RowValue;
onSubmit: (value: RowValue) => unknown;
onCancel: () => unknown;
}
export const EditingBodyCellText = ({
initialValue,
onSubmit,
onCancel,
}: EditingBodyPrimitiveProps) => {
return (
<Input
defaultValue={(initialValue ?? "").toString()}
className={S.input}
variant="unstyled"
size="xs"
autoFocus
onKeyUp={event => {
if (event.key === "Escape") {
onCancel();
} else if (event.key === "Enter") {
onSubmit(event.currentTarget.value);
}
}}
onBlur={event => {
onSubmit(event.currentTarget.value);
}}
/>
);
};
export const EditingBodyCellSelect = ({
initialValue,
datasetColumn,
onSubmit,
onCancel,
}: EditingBodyPrimitiveProps) => {
const { data: fieldData, isLoading } = useGetFieldValuesQuery(
datasetColumn.id ?? skipToken,
);
const [search, setSearch] = useState("");
const combobox = useCombobox({
defaultOpened: true,
onDropdownClose: onCancel,
});
const options = useMemo(
() =>
fieldData
? getFieldOptions(fieldData.values).filter(item =>
item.label.toLowerCase().includes(search.toLowerCase().trim()),
)
: [],
[fieldData, search],
);
return (
<Combobox
store={combobox}
position="bottom-start"
onOptionSubmit={onSubmit}
>
<Combobox.Target>
<Input
value={(initialValue ?? "").toString()}
variant="unstyled"
pointer
onClick={() => combobox.toggleDropdown()}
onMouseDown={onCancel}
className={S.input}
size="xs"
/>
</Combobox.Target>
<Combobox.Dropdown mah="none" miw={250}>
<Box p="0.5rem" pb="0 " bg="white" pos="sticky" top={0}>
<TextInput
value={search}
onChange={event => setSearch(event.currentTarget.value)}
placeholder={t`Search the list`}
leftSection={<Icon name="search" />}
autoFocus
/>
</Box>
<Combobox.Options p="0.5rem">
{options.length > 0 ? (
options.map(item => (
<Combobox.Option
selected={initialValue === item.value}
value={item.value}
key={item.value}
>
{item.label}
</Combobox.Option>
))
) : isLoading ? (
<Combobox.Empty>{t`Loading values...`}</Combobox.Empty>
) : (
<Combobox.Option value={search}>
{t`Add option:`} <strong>{search}</strong>
</Combobox.Option>
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};

View File

@@ -0,0 +1,116 @@
import { useCallback } from "react";
import { useMount } from "react-use";
import {
useGetDatabaseMetadataQuery,
useGetTableDataQuery,
useGetTableQuery,
} from "metabase/api";
import { useDispatch } from "metabase/lib/redux";
import { closeNavbar } from "metabase/redux/app";
import { Box, Flex } from "metabase/ui";
import { useUpdateTableRowsMutation } from "metabase-enterprise/api";
import { isPK } from "metabase-lib/v1/types/utils/isa";
import { TableDataView } from "./TableDataView";
import S from "./TableDataView.module.css";
import { TableDataViewHeader } from "./TableDataViewHeader";
import type { UpdatedRowCellsHandlerParams } from "./types";
type TableDataViewProps = {
params: {
dbId: string;
tableId: string;
};
};
export const TableDataContainer = ({
params: { dbId: dbIdParam, tableId: tableIdParam },
}: TableDataViewProps) => {
const dbId = parseInt(dbIdParam, 10);
const tableId = parseInt(tableIdParam, 10);
const dispatch = useDispatch();
const { data: database } = useGetDatabaseMetadataQuery({ id: dbId }); // TODO: consider using just "dbId" to avoid extra data request
const { data: table } = useGetTableQuery({ id: tableId });
const {
data: datasetData,
isLoading,
refetch: refetchTableDataQuery,
} = useGetTableDataQuery({
tableId,
});
const [updateTableRows] = useUpdateTableRowsMutation();
useMount(() => {
dispatch(closeNavbar());
});
const handleCellValueUpdate = useCallback(
async ({ data, rowIndex }: UpdatedRowCellsHandlerParams) => {
if (!datasetData) {
console.warn(
"Failed to update table data - no data is loaded for a table",
);
return;
}
const columns = datasetData.data.cols;
const rowData = datasetData.data.rows[rowIndex];
const pkColumnIndex = columns.findIndex(isPK);
const pkColumn = columns[pkColumnIndex];
const rowPkValue = rowData[pkColumnIndex];
const updatedRowWithPk = {
...data,
[pkColumn.name]: rowPkValue,
};
await updateTableRows({
tableId: tableId,
rows: [updatedRowWithPk],
});
// TODO: do an optimistic data update here using RTK cache
refetchTableDataQuery();
},
[datasetData, refetchTableDataQuery, tableId, updateTableRows],
);
if (isLoading) {
// TODO: show loader
return null;
}
if (!datasetData) {
// TODO: show error
return null;
}
return (
<Flex
className={S.container}
data-testid="table-data-view-root"
direction="column"
justify="stretch"
>
{database && table && (
<TableDataViewHeader
database={database}
tableName={table?.display_name}
/>
)}
<Box pos="relative" className={S.gridWrapper}>
<TableDataView
data={datasetData}
onCellValueUpdate={handleCellValueUpdate}
/>
</Box>
</Flex>
);
};

View File

@@ -0,0 +1,10 @@
.container {
height: 100%;
width: 100%;
overflow: hidden;
}
.gridWrapper {
overflow: hidden;
flex: 1 1 auto;
}

View File

@@ -0,0 +1,98 @@
import type { ColumnSizingState } from "@tanstack/react-table";
import type React from "react";
import { useCallback, useMemo } from "react";
import {
type ColumnOptions,
DataGrid,
type RowIdColumnOptions,
useDataGridInstance,
} from "metabase/data-grid";
import { formatValue } from "metabase/lib/formatting/value";
import type { Dataset, RowValue, RowValues } from "metabase-types/api";
import { EditingBodyCellConditional } from "./EditingBodyCell";
import type { UpdatedRowCellsHandlerParams } from "./types";
import { useTableEditing } from "./use-table-editing";
type TableDataViewProps = {
data: Dataset;
onCellValueUpdate: (params: UpdatedRowCellsHandlerParams) => void;
};
export const TableDataView = ({
data,
onCellValueUpdate,
}: TableDataViewProps) => {
const { cols, rows } = data.data;
const { editingCellId, onCellClickToEdit, onCellEditCancel } =
useTableEditing();
const columnOrder = useMemo(() => cols.map(({ name }) => name), [cols]);
const columnSizingMap = useMemo(() => {
return cols.reduce((acc: ColumnSizingState, column) => {
acc[column.name] = 100;
return acc;
}, {});
}, [cols]);
const columnsOptions: ColumnOptions<RowValues, RowValue>[] = useMemo(() => {
return cols.map((column, columnIndex) => {
const options: ColumnOptions<RowValues, RowValue> = {
id: column.name,
name: column.display_name,
accessorFn: (row: RowValues) => row[columnIndex],
formatter: value => formatValue(value, { column }),
wrap: false,
editingCell: cellContext => (
<EditingBodyCellConditional
cellContext={cellContext}
column={column}
onCellValueUpdate={onCellValueUpdate}
onCellEditCancel={onCellEditCancel}
/>
),
getIsCellEditing: (cellId: string) => editingCellId === cellId,
};
return options;
});
}, [cols, editingCellId, onCellEditCancel, onCellValueUpdate]);
const rowId: RowIdColumnOptions = useMemo(
() => ({
variant: "expandButton",
}),
[],
);
const tableProps = useDataGridInstance({
data: rows,
rowId,
columnOrder,
columnSizingMap,
columnsOptions,
});
const handleCellClick = useCallback(
(
e: React.MouseEvent<HTMLDivElement>,
{
cellId,
}: {
cellId: string;
},
) => {
// Prevents event from bubbling up inside editing cell
// Otherwise requires special handling in EditingBodyCell
if (editingCellId !== cellId) {
onCellClickToEdit(cellId);
}
},
[onCellClickToEdit, editingCellId],
);
return <DataGrid {...tableProps} onBodyCellClick={handleCellClick} />;
};

View File

@@ -0,0 +1,30 @@
import { Link } from "react-router";
import { t } from "ttag";
import * as Urls from "metabase/lib/urls";
import { ActionIcon, Icon, Tooltip } from "metabase/ui";
import type { Database } from "metabase-types/api";
export const TableDataViewBackButton = ({
database,
}: {
database: Database;
}) => {
const label = t`Back to ${database.name}`;
return (
<Tooltip label={label}>
<Link to={Urls.browseDatabase(database)}>
<ActionIcon
variant="outline"
radius="xl"
size="2.625rem"
color="border"
aria-label={label}
>
<Icon c="text-dark" name="arrow_left" />
</ActionIcon>
</Link>
</Tooltip>
);
};

View File

@@ -0,0 +1,30 @@
import type React from "react";
import { t } from "ttag";
import { Box, Group, Title } from "metabase/ui";
import type { Database } from "metabase-types/api";
import { TableDataViewBackButton } from "./TableDataViewBackButton";
interface TableDataViewHeaderProps {
database: Database;
tableName?: string;
}
export const TableDataViewHeader = ({
database,
tableName,
}: TableDataViewHeaderProps): React.JSX.Element => {
return (
<Box
p="lg"
data-testid="table-data-view-header"
bd="1px solid var(--mb-color-border)"
>
<Group gap="sm">
<TableDataViewBackButton database={database} />
{tableName && <Title>{t`Editing ${tableName}`}</Title>}
</Group>
</Box>
);
};

View File

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

View File

@@ -0,0 +1,34 @@
import type {
ConcreteTableId,
DatasetColumn,
RowValue,
RowValues,
} from "metabase-types/api";
export type RowCellsWithPkValue = Record<DatasetColumn["name"], RowValue>;
export type TableInsertRowsRequest = {
tableId: ConcreteTableId;
rows: RowCellsWithPkValue[];
};
export type TableInsertRowsResponse = { "created-rows": RowValues[] };
export type TableUpdateRowsRequest = {
tableId: ConcreteTableId;
rows: RowCellsWithPkValue[];
};
export type TableUpdateRowsResponse = { "rows-updated": number };
export type TableDeleteRowsRequest = {
tableId: ConcreteTableId;
rows: RowCellsWithPkValue[];
};
export type TableDeleteRowsResponse = { success: boolean };
export type UpdatedRowCellsHandlerParams = {
data: Record<DatasetColumn["name"], RowValue>;
rowIndex: number;
};

View File

@@ -0,0 +1,23 @@
import { useCallback, useState } from "react";
import type { DataGridCellId } from "metabase/data-grid";
export const useTableEditing = () => {
const [editingCellId, setEditingCellId] = useState<DataGridCellId | null>(
null,
);
const onCellClickToEdit = useCallback((cellId: DataGridCellId) => {
setEditingCellId(cellId);
}, []);
const onCellEditCancel = useCallback(() => {
setEditingCellId(null);
}, []);
return {
onCellClickToEdit,
onCellEditCancel,
editingCellId,
};
};

View File

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

View File

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

View File

@@ -195,6 +195,7 @@ export const tokenFeatures = [
"cache_granular_controls",
"disable_password_login",
"content_verification",
// "data_editing", // TODO[WRK]: enable this check after this feature token is added on the BE
"embedding",
"embedding_sdk",
"hosting",
@@ -217,6 +218,7 @@ export const tokenFeatures = [
"collection_cleanup",
"query_reference_validation",
"cache_preemptive",
"table_data_editing",
] as const;
export type TokenFeature = (typeof tokenFeatures)[number];

View File

@@ -1,3 +1,5 @@
import type { DatasetData } from "metabase-types/api/dataset";
import type { Card, CardType } from "./card";
import type { Database, DatabaseId, InitialSyncStatus } from "./database";
import type { Field, FieldDimensionOption, FieldId } from "./field";
@@ -129,3 +131,28 @@ export interface DeleteUploadTableRequest {
tableId: TableId;
"archive-cards"?: boolean;
}
export interface GetTableDataRequest {
tableId: TableId;
}
export type TableData = {
data: DatasetData;
database_id: DatabaseId;
table_id: TableId;
row_count: number;
running_time: number;
error?:
| string
| {
status: number; // HTTP status code
data?: string;
};
error_type?: string;
error_is_curated?: boolean;
status?: string;
/** In milliseconds */
average_execution_time?: number;
/** A date in ISO 8601 format */
started_at?: string;
};

View File

@@ -1,8 +1,10 @@
import type {
Field,
GetTableDataRequest,
GetTableQueryMetadataRequest,
GetTableRequest,
Table,
TableData,
TableId,
TableListQuery,
UpdateTableFieldsOrderRequest,
@@ -52,6 +54,12 @@ export const tableApi = Api.injectEndpoints({
}),
providesTags: [listTag("field")],
}),
getTableData: builder.query<TableData, GetTableDataRequest>({
query: ({ tableId }) => ({
method: "GET",
url: `/api/table/${tableId}/data`,
}),
}),
updateTable: builder.mutation<Table, UpdateTableRequest>({
query: ({ id, ...body }) => ({
method: "PUT",
@@ -115,6 +123,7 @@ export const {
useGetTableQuery,
useGetTableQueryMetadataQuery,
useLazyListTableForeignKeysQuery,
useGetTableDataQuery,
useUpdateTableMutation,
useUpdateTableListMutation,
useUpdateTableFieldsOrderMutation,

View File

@@ -4,6 +4,7 @@ import Tables from "metabase/entities/tables";
import { connect } from "metabase/lib/redux";
import { isSyncInProgress } from "metabase/lib/syncing";
import * as Urls from "metabase/lib/urls";
import { PLUGIN_DATA_EDITING } from "metabase/plugins";
import { getMetadata } from "metabase/selectors/metadata";
import { getSetting } from "metabase/selectors/settings";
import { SAVED_QUESTIONS_VIRTUAL_DB_ID } from "metabase-lib/v1/metadata/utils/saved-questions";
@@ -35,7 +36,11 @@ const getSchemaName = props => {
const getReloadInterval = (_state, _props, tables = []) =>
tables.some(t => isSyncInProgress(t)) ? RELOAD_INTERVAL : 0;
export const getTableUrl = (table, metadata) => {
const getTableUrl = (table, metadata, dbId) => {
if (PLUGIN_DATA_EDITING.isEnabled()) {
return `/browse/databases/${dbId}/tables/${table.id}`;
}
const metadataTable = metadata?.table(table.id);
return ML_Urls.getUrl(metadataTable?.newQuestion(), { clean: false });
};

View File

@@ -22,7 +22,6 @@ import {
import { useDatabaseCrumb } from "./useDatabaseCrumb";
const propTypes = {
database: PropTypes.object,
tables: PropTypes.array.isRequired,
getTableUrl: PropTypes.func.isRequired,
metadata: PropTypes.object,
@@ -33,7 +32,6 @@ const propTypes = {
};
export const TableBrowser = ({
database,
tables,
getTableUrl,
metadata,
@@ -61,12 +59,13 @@ export const TableBrowser = ({
<TableCard hoverable={!isSyncInProgress(table)}>
<TableLink
to={
!isSyncInProgress(table) ? getTableUrl(table, metadata) : ""
!isSyncInProgress(table)
? getTableUrl(table, metadata, dbId)
: ""
}
onClick={() => trackTableClick(table.id)}
>
<TableBrowserItem
database={database}
table={table}
dbId={dbId}
xraysEnabled={xraysEnabled}
@@ -89,7 +88,7 @@ const itemPropTypes = {
xraysEnabled: PropTypes.bool,
};
const TableBrowserItem = ({ database, table, dbId, xraysEnabled }) => {
const TableBrowserItem = ({ table, dbId, xraysEnabled }) => {
const isVirtual = isVirtualCardId(table.id);
const isLoading = isSyncInProgress(table);

View File

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

View File

@@ -286,7 +286,11 @@ export const DataGrid = function DataGrid<TData>({
key={cell.id}
className={cx(S.bodyCell, classNames?.bodyCell)}
onClick={e =>
onBodyCellClick?.(e, cell.row.index, cell.column.id)
onBodyCellClick?.(e, {
rowIndex: cell.row.index,
columnId: cell.column.id,
cellId: cell.id,
})
}
style={style}
>

View File

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

View File

@@ -59,6 +59,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>>;
@@ -94,6 +97,8 @@ export interface ColumnOptions<TRow extends RowData, TValue = unknown> {
/** Function to format cell values for display */
formatter?: CellFormatter<TValue>;
getIsCellEditing?: (cellId: string) => boolean;
}
/**
@@ -177,6 +182,8 @@ export type BodyCellVariant = "text" | "pill";
export type HeaderCellVariant = "light" | "outline";
export type RowIdVariant = "indexExpand" | "expandButton";
export type DataGridCellId = string;
export type CellFormatter<TValue> = (
value: TValue,
rowIndex: number,
@@ -198,8 +205,11 @@ export interface DataGridInstance<TData> {
) => void;
onBodyCellClick?: (
event: React.MouseEvent<HTMLDivElement>,
rowIndex: number,
columnId: string,
cellProps: {
rowIndex: number;
columnId: string;
cellId: string;
},
) => void;
onAddColumnClick?: React.MouseEventHandler<HTMLButtonElement>;
onScroll?: React.UIEventHandler<HTMLDivElement>;

View File

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

View File

@@ -597,3 +597,13 @@ export const PLUGIN_RESOURCE_DOWNLOADS = {
downloads?: boolean | null;
}) => true,
};
export const PLUGIN_DATA_EDITING = {
isEnabled: () => false,
PAGE_COMPONENT: PluginPlaceholder as ComponentType<{
params: {
dbId: string;
tableId: string;
};
}>,
};

View File

@@ -37,7 +37,11 @@ import { Onboarding } from "metabase/home/components/Onboarding";
import { trackPageView } from "metabase/lib/analytics";
import NewModelOptions from "metabase/models/containers/NewModelOptions";
import { getRoutes as getModelRoutes } from "metabase/models/routes";
import { PLUGIN_COLLECTIONS, PLUGIN_LANDING_PAGE } from "metabase/plugins";
import {
PLUGIN_COLLECTIONS,
PLUGIN_DATA_EDITING,
PLUGIN_LANDING_PAGE,
} from "metabase/plugins";
import { QueryBuilder } from "metabase/query_builder/containers/QueryBuilder";
import { loadCurrentUser } from "metabase/redux/user";
import DatabaseDetailContainer from "metabase/reference/databases/DatabaseDetailContainer";
@@ -270,6 +274,12 @@ export const getRoutes = store => {
path="databases/:dbId/schema/:schemaName"
component={BrowseTables}
/>
{PLUGIN_DATA_EDITING.isEnabled() && (
<Route
path="databases/:dbId/tables/:tableId"
component={PLUGIN_DATA_EDITING.PAGE_COMPONENT}
/>
)}
{/* These two Redirects support legacy paths in v48 and earlier */}
<Redirect from=":dbId-:slug" to="databases/:dbId-:slug" />

View File

@@ -0,0 +1,15 @@
import { Combobox } from "@mantine/core";
import ComboboxStyles from "./Combobox.module.css";
export const comboboxOverrides = {
Combobox: Combobox.extend({
defaultProps: {
size: "md",
},
classNames: {
empty: ComboboxStyles.empty,
option: ComboboxStyles.option,
},
}),
};

View File

@@ -0,0 +1,35 @@
import { Canvas, Story, Meta } from "@storybook/blocks";
import * as ComboboxStories from "./Combobox.stories";
<Meta of={ComboboxStories} />
# Combobox
Our themed wrapper around [Mantine Combobox](https://mantine.dev/core/combobox/).
## Docs
- [Mantine Select Docs](https://mantine.dev/core/combobox/)
## Examples
<Canvas story={{ height: "200px" }}>
<Story of={ComboboxStories.Default} />
</Canvas>
#### Min Popover Width
<Canvas story={{ height: "200px" }}>
<Story of={ComboboxStories.MinWidth} />
</Canvas>
#### Async Data Fetch
<Canvas story={{ height: "200px" }}>
<Story of={ComboboxStories.AsyncDataFetch} />
</Canvas>
#### Searchable (Async)
<Canvas story={{ height: "200px" }}>
<Story of={ComboboxStories.AsyncSearchable} />
</Canvas>

View File

@@ -0,0 +1,9 @@
.empty {
color: var(--mb-color-text-light);
}
.option {
&:not([data-combobox-selected]):hover {
background-color: var(--mb-color-bg-medium);
}
}

View File

@@ -0,0 +1,163 @@
import { useEffect, useState } from "react";
import {
Button,
Center,
Combobox,
Stack,
Text,
useCombobox,
} from "metabase/ui";
const groceries = [
"🍎 Apples",
"🍌 Bananas",
"🥦 Broccoli",
"🥕 Carrots",
"🍫 Chocolate",
"🍇 Grapes",
];
const args = {
minWidth: 0,
data: groceries,
size: "md",
asyncDataFetch: false,
searchable: false,
};
const argTypes = {
data: {
control: { type: "json" },
},
size: {
options: ["sm", "md", "lg"],
control: { type: "inline-radio" },
},
asyncDataFetch: {
control: { type: "boolean" },
},
minWidth: {
control: { type: "number" },
},
searchable: {
control: { type: "boolean" },
},
};
type StorybookProps = typeof args;
function ComboboxTemplate(props: StorybookProps) {
const combobox = useCombobox({
defaultOpened: true,
onDropdownClose: () => combobox.resetSelectedOption(),
});
const [value, setValue] = useState<string | undefined>();
const [search, setSearch] = useState("");
const [data] = useState<string[]>(props.data);
const [loading, setLoading] = useState(props.asyncDataFetch);
// Emulate loading
useEffect(() => {
if (props.asyncDataFetch) {
setLoading(true);
setTimeout(() => {
setLoading(false);
}, 1000);
}
}, [props.asyncDataFetch]);
const options = data
.filter(item => item.toLowerCase().includes(search.toLowerCase().trim()))
.map(item => (
<Combobox.Option value={item} key={item} selected={item === value}>
{item}
</Combobox.Option>
));
return (
<Combobox
size={props.size}
store={combobox}
withinPortal={false}
onOptionSubmit={val => {
setValue(val);
combobox.closeDropdown();
}}
>
<Center>
<Stack gap="xs">
<Text>
Selected: <strong>{value ?? "Nothing"}</strong>
</Text>
<Combobox.Target>
<Button onClick={() => combobox.toggleDropdown()}>
Toggle Combobox
</Button>
</Combobox.Target>
</Stack>
</Center>
<Combobox.Dropdown miw={props.minWidth || undefined} p="2px">
{props.searchable && (
<Combobox.Search
value={search}
placeholder="Search..."
onChange={event => setSearch(event.target.value)}
/>
)}
<Combobox.Options>
{loading ? (
<Combobox.Empty>Loading...</Combobox.Empty>
) : options.length > 0 ? (
options
) : (
<Combobox.Empty>No results found</Combobox.Empty>
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
}
export default {
title: "Components/Inputs/Combobox",
component: Combobox,
args,
argTypes,
};
export const Default = {
render: ComboboxTemplate,
args: {
size: "md",
},
};
export const MinWidth = {
render: ComboboxTemplate,
args: {
minWidth: 300,
},
};
export const AsyncDataFetch = {
render: ComboboxTemplate,
args: {
size: "md",
asyncDataFetch: true,
},
};
export const AsyncSearchable = {
render: ComboboxTemplate,
args: {
size: "md",
minWidth: 300,
asyncDataFetch: true,
searchable: true,
creatable: true,
},
};

View File

@@ -0,0 +1,2 @@
export { Combobox, useCombobox } from "@mantine/core";
export { comboboxOverrides } from "./Combobox.config";

View File

@@ -20,3 +20,4 @@ export * from "./TextInput";
export * from "./TextInputBlurChange";
export * from "./Textarea";
export * from "./TimeInput";
export * from "./Combobox";

View File

@@ -17,6 +17,7 @@ import {
checkboxOverrides,
chipOverrides,
codeOverrides,
comboboxOverrides,
dateInputOverrides,
datePickerOverrides,
dividerOverrides,
@@ -132,6 +133,7 @@ export const getThemeOverrides = (): MantineThemeOverride => ({
...checkboxOverrides,
...chipOverrides,
...codeOverrides,
...comboboxOverrides,
...dateInputOverrides,
...datePickerOverrides,
...dividerOverrides,

View File

@@ -217,9 +217,16 @@ export const TableInteractiveInner = forwardRef(function TableInteractiveInner(
const handleBodyCellClick = useCallback(
(
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
rowIndex: number,
columnId: string,
{
rowIndex,
columnId: columnIdParam,
}: {
rowIndex: number;
columnId: string;
cellId: string;
},
) => {
let columnId = columnIdParam;
if (isPivoted) {
columnId = getColumnIdFromPivotedColumnId(columnId);
}

View File

@@ -878,6 +878,7 @@ See [fonts](../configuring-metabase/fonts.md).")
:sso_jwt (premium-features/enable-sso-jwt?)
:sso_ldap (premium-features/enable-sso-ldap?)
:sso_saml (premium-features/enable-sso-saml?)
:table_data_editing (premium-features/table-data-editing?)
:upload_management (premium-features/enable-upload-management?)
:whitelabel (premium-features/enable-whitelabeling?)
:llm_autodescription (premium-features/enable-llm-autodescription?)})