Compare commits
11 Commits
python-run
...
fk-walk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73b96227c9 | ||
|
|
f849f7d306 | ||
|
|
51343df528 | ||
|
|
e4909e99e7 | ||
|
|
c087211a78 | ||
|
|
7ac49ae78f | ||
|
|
fddd3f49f9 | ||
|
|
e0ba19cf92 | ||
|
|
72fe3f86fe | ||
|
|
395f14cd9f | ||
|
|
830f7e44b6 |
34
e2e/test/scenarios/data-editing/data-editing.cy.spec.js
Normal file
34
e2e/test/scenarios/data-editing/data-editing.cy.spec.js
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 []})))
|
||||
@@ -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}
|
||||
|
||||
@@ -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})))))
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
.container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridWrapper {
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { TableDataContainer } from "./TableDataContainer";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -35,3 +35,4 @@ import "./resource_downloads";
|
||||
import "./user_provisioning";
|
||||
import "./clean_up";
|
||||
import "./troubleshooting";
|
||||
import "./data_editing";
|
||||
|
||||
@@ -131,6 +131,7 @@ export const createMockTokenFeatures = (
|
||||
query_reference_validation: false,
|
||||
serialization: false,
|
||||
cache_preemptive: false,
|
||||
table_data_editing: false,
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>,
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
.empty {
|
||||
color: var(--mb-color-text-light);
|
||||
}
|
||||
|
||||
.option {
|
||||
&:not([data-combobox-selected]):hover {
|
||||
background-color: var(--mb-color-bg-medium);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Combobox, useCombobox } from "@mantine/core";
|
||||
export { comboboxOverrides } from "./Combobox.config";
|
||||
@@ -20,3 +20,4 @@ export * from "./TextInput";
|
||||
export * from "./TextInputBlurChange";
|
||||
export * from "./Textarea";
|
||||
export * from "./TimeInput";
|
||||
export * from "./Combobox";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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?)})
|
||||
|
||||
Reference in New Issue
Block a user