Compare commits
55 Commits
hackathon-
...
nav-split-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16bbf677a5 | ||
|
|
e1d1d31a1a | ||
|
|
7b91252075 | ||
|
|
8dfb8c5b67 | ||
|
|
e8f85e28e0 | ||
|
|
63cbcc92e3 | ||
|
|
16ad7b75f2 | ||
|
|
eedda27c89 | ||
|
|
1a0ff4340b | ||
|
|
0bdad48e91 | ||
|
|
9b193b0937 | ||
|
|
1139be54dd | ||
|
|
32176e152a | ||
|
|
1d5c08b71d | ||
|
|
bc75a88888 | ||
|
|
cd6bcc23e0 | ||
|
|
381ab6c1a0 | ||
|
|
176d0682e2 | ||
|
|
daa0c256a6 | ||
|
|
2df3f6daa0 | ||
|
|
503f96cb55 | ||
|
|
f2e185369f | ||
|
|
9db952d568 | ||
|
|
9e27bdf31f | ||
|
|
20f3f6cbdc | ||
|
|
2f3fe9904e | ||
|
|
57a08becb3 | ||
|
|
fc5cf5c469 | ||
|
|
c03c02a9a1 | ||
|
|
af8ca49a25 | ||
|
|
cfb8cbd6f9 | ||
|
|
3eff0fac3d | ||
|
|
b6f625ed4b | ||
|
|
bb9482f0fd | ||
|
|
8317274709 | ||
|
|
c796ec01ec | ||
|
|
fa7362a1e4 | ||
|
|
361fc91d80 | ||
|
|
f4ab93af92 | ||
|
|
6c4b0182c1 | ||
|
|
401167cc00 | ||
|
|
2947b47c23 | ||
|
|
abb710334f | ||
|
|
fed0dbfd58 | ||
|
|
edd44ef851 | ||
|
|
1cd4d62061 | ||
|
|
426c9e6406 | ||
|
|
feb7aa645c | ||
|
|
5349fb75d7 | ||
|
|
5073c20eb1 | ||
|
|
9d6d4c656e | ||
|
|
2f02a29249 | ||
|
|
3b9a1df5ac | ||
|
|
7e9ffc83ce | ||
|
|
35bf6ca092 |
@@ -31,7 +31,8 @@
|
||||
|
||||
(def ^:private value-operators
|
||||
"Operators whose second argument is a value rather than a column."
|
||||
'#{= not= < > <= >= like not-like ilike not-ilike in not-in between})
|
||||
'#{= not= != <> is-distinct-from is-not-distinct-from
|
||||
< > <= >= like not-like ilike not-ilike in not-in between not-between})
|
||||
|
||||
(defn- marked?
|
||||
"Whether `node` is an `[:auto/param v]` marker."
|
||||
@@ -39,28 +40,138 @@
|
||||
(and (hooks/vector-node? node)
|
||||
(= :auto/param (some-> (first (:children node)) hooks/sexpr))))
|
||||
|
||||
(defn- distinct-by
|
||||
"`coll` with only the first element for each distinct `(f element)`."
|
||||
[f coll]
|
||||
(->> coll (reduce (fn [[seen acc] x]
|
||||
(let [k (f x)]
|
||||
(if (contains? seen k) [seen acc] [(conj seen k) (conj acc x)])))
|
||||
[#{} []])
|
||||
second))
|
||||
|
||||
(def ^:private identifier-clauses
|
||||
"Clause keys whose entries name a column or a table rather than carrying a value.
|
||||
|
||||
A symbol here is a name, and `metabase.app-db.value-guard` refuses a marker in one, so reporting
|
||||
it would ask for a fix that throws at compile. `:order-by` and `:group-by` are included even
|
||||
though the guard allows a marker there: it compiles to `ORDER BY ?` and loses the ordering, so
|
||||
asking for one is still wrong."
|
||||
#{:select :select-distinct :select-top :from :join :left-join :right-join :inner-join :full-join
|
||||
:cross-join :update :insert-into :delete-from :returning :with :with-columns :using
|
||||
:order-by :group-by :partition-by :window})
|
||||
|
||||
(def ^:private expression-entry-clauses
|
||||
"Identifier clauses where index 0 of an `[expr alias]` entry is an expression, not a name.
|
||||
|
||||
HoneySQL binds a parameter there -- `SELECT ? AS a` -- so the guard accepts a marker, and mirrors
|
||||
this set as `expression-entry-clauses` in `metabase.app-db.value-guard`. Everything after index 0
|
||||
is an alias, and a bare entry is a name; the guard refuses a marker in both."
|
||||
#{:select :select-distinct :select-top :returning :partition-by})
|
||||
|
||||
(defn- comparison-node?
|
||||
"Whether `node` is a keyword-headed comparison -- the one shape inside an identifier clause that
|
||||
does hold values, as in the computed projection `[[:= :engine v] :is_match]`."
|
||||
[node]
|
||||
(and (hooks/vector-node? node)
|
||||
(let [op (some-> (first (:children node)) hooks/sexpr)]
|
||||
(and (keyword? op) (contains? value-operators (symbol (name op)))))))
|
||||
|
||||
(declare value-nodes)
|
||||
|
||||
(defn- identifier-clause-value-nodes
|
||||
"The value slots reachable inside an identifier clause: a subquery's own clauses, and the
|
||||
arguments of a comparison. The names themselves are not candidates."
|
||||
[node]
|
||||
(cond
|
||||
(hooks/map-node? node) (value-nodes node)
|
||||
(comparison-node? node) (value-nodes node)
|
||||
(hooks/vector-node? node) (mapcat identifier-clause-value-nodes (:children node))
|
||||
(hooks/list-node? node) (mapcat identifier-clause-value-nodes (:children node))
|
||||
:else nil))
|
||||
|
||||
(defn- expression-entry-value-nodes
|
||||
"The value slots of an [[expression-entry-clauses]] clause: index 0 of each `[expr alias]` entry."
|
||||
[node]
|
||||
(when (hooks/vector-node? node)
|
||||
(mapcat (fn [entry]
|
||||
(when (hooks/vector-node? entry)
|
||||
(when-let [expr (first (:children entry))]
|
||||
;; Keep the expression AND descend, so a bare symbol is reported and a computed
|
||||
;; projection or a subquery sitting there has its own values checked.
|
||||
(cons expr (value-nodes expr)))))
|
||||
(:children node))))
|
||||
|
||||
(defn- clause-value-nodes
|
||||
"The value slots of the value under clause key `clause`."
|
||||
[clause node]
|
||||
(cond
|
||||
(contains? expression-entry-clauses clause) (expression-entry-value-nodes node)
|
||||
(contains? identifier-clauses clause) (identifier-clause-value-nodes node)
|
||||
:else (value-nodes node)))
|
||||
|
||||
(defn- value-nodes
|
||||
"The nodes sitting in a value slot of `node`, following the clause shapes a query map uses."
|
||||
[node]
|
||||
(cond
|
||||
;; A query map -- walk its clause values, not its keys.
|
||||
;; A query map -- walk its clause values, not its keys. A clause that names columns or tables
|
||||
;; contributes only what is nested inside it.
|
||||
(hooks/map-node? node)
|
||||
(mapcat value-nodes (take-nth 2 (rest (:children node))))
|
||||
(mapcat (fn [[k v]]
|
||||
(if (hooks/keyword-node? k)
|
||||
(clause-value-nodes (hooks/sexpr k) v)
|
||||
(value-nodes v)))
|
||||
(partition 2 (:children node)))
|
||||
|
||||
(hooks/vector-node? node)
|
||||
(let [[head & args] (:children node)
|
||||
op (some-> head hooks/sexpr)]
|
||||
(cond
|
||||
;; A marker IS the value slot -- never descend into its payload, or the marked value
|
||||
;; gets reported as though it were bare.
|
||||
(marked? node)
|
||||
nil
|
||||
|
||||
(contains? #{:and :or :not} op)
|
||||
(mapcat value-nodes args)
|
||||
|
||||
;; `[:= col v]`, `[:in col vs]`, `[:between col lo hi]` -- the first argument is the
|
||||
;; column and the rest are values, whatever the arity.
|
||||
;; column and the rest are values, whatever the arity. Keep each value AND descend into it,
|
||||
;; so a subquery in a value slot -- `[:in :id {:select ... :where [:= :z z]}]` -- has its
|
||||
;; own values checked too. Descending alone would drop the plain-token case.
|
||||
(and (keyword? op) (contains? value-operators (symbol (name op))))
|
||||
(rest args)
|
||||
(mapcat #(cons % (value-nodes %)) (rest args))
|
||||
|
||||
;; A function-call form -- `[:lower v]` -- or a literal collection -- `[a b]` in an `:in`.
|
||||
;; Both hold values, so yield the children themselves as candidates as well as
|
||||
;; descending, since a token child returns nothing on its own.
|
||||
:else
|
||||
(mapcat value-nodes (:children node))))
|
||||
(mapcat #(cons % (value-nodes %)) (:children node))))
|
||||
|
||||
;; A clause built conditionally -- `(when flag [:= :col v])`, `(if ... )`, `(cond-> ...)`. The
|
||||
;; value slots are inside, so walk the children rather than stopping. Without this a value
|
||||
;; wrapped in `when` is invisible to the check, which is a false negative in a security lint.
|
||||
;;
|
||||
;; An `assoc` call gets its clause context back: `(assoc q :order-by [[col dir]])` is the same
|
||||
;; clause as `{:order-by [[col dir]]}`, but written outside a map literal, where the walk would
|
||||
;; otherwise fall back to shape alone and report the column and the direction as values.
|
||||
;; `cond->` is covered because the walk descends into the nested `assoc`.
|
||||
(hooks/list-node? node)
|
||||
(let [children (vec (:children node))
|
||||
assoc? (= 'assoc (some-> (first children) hooks/sexpr))
|
||||
;; index of a clause value -> its clause key
|
||||
clauses (when assoc?
|
||||
(into {} (keep-indexed (fn [i child]
|
||||
(when (and (hooks/keyword-node? child)
|
||||
(contains? identifier-clauses (hooks/sexpr child))
|
||||
(< (inc i) (count children)))
|
||||
[(inc i) (hooks/sexpr child)]))
|
||||
children)))]
|
||||
(mapcat (fn [i child]
|
||||
(if-let [clause (get clauses i)]
|
||||
(clause-value-nodes clause child)
|
||||
(value-nodes child)))
|
||||
(range)
|
||||
children))
|
||||
|
||||
:else nil))
|
||||
|
||||
@@ -77,35 +188,102 @@
|
||||
(let [f (some-> (first (:children node)) hooks/sexpr)]
|
||||
(not (contains? write-fns (some-> f name symbol)))))
|
||||
|
||||
(defn- kv-arg-pair
|
||||
"The first `:column value` pair a query call passes after its model, if any.
|
||||
(def ^:private clause-keys
|
||||
"Query-map clause keys. A map keyed by these is a query map rather than a map of conditions."
|
||||
#{:select :select-distinct :from :where :join :left-join :right-join :inner-join :full-join
|
||||
:cross-join :group-by :having :order-by :limit :offset :for :union :union-all :with
|
||||
:with-columns :returning :values :set})
|
||||
|
||||
(defn- query-map-node?
|
||||
"Whether `node` is a query map rather than a map of column/value conditions.
|
||||
|
||||
`(t2/delete! :model/X {:where [:= :k k]})` passes a query map, whose values [[value-nodes]]
|
||||
already walks -- treating it as conditions too would report every value twice."
|
||||
[node]
|
||||
(boolean (some #(and (hooks/keyword-node? %) (contains? clause-keys (hooks/sexpr %)))
|
||||
(take-nth 2 (:children node)))))
|
||||
|
||||
(def ^:private changes-map-fns
|
||||
"Conditions-map fns whose arglist also ends in a changes map, so a lone trailing map is changes."
|
||||
'#{update! update-or-insert!})
|
||||
|
||||
(def ^:private conditions-map-fns
|
||||
"Calls whose first argument after the model is a map of CONDITIONS rather than a query map.
|
||||
|
||||
`(t2/update! :model/X {:key k} {:v 1})` filters on `k`, so it is a where-clause value -- but it
|
||||
reaches the query as a plain map entry rather than a `:where` clause, so neither the query-map
|
||||
walker nor the kv-arg walker sees it. It is a shape the sweep leaves in place, so it has to be
|
||||
checked here."
|
||||
'#{update! update-or-insert! delete!})
|
||||
|
||||
(defn- conditions-map-value-nodes
|
||||
"The values of a conditions map passed to one of [[conditions-map-fns]]."
|
||||
[f args]
|
||||
(when (contains? conditions-map-fns (some-> f name symbol))
|
||||
(let [after-model (->> args
|
||||
(drop-while #(not (and (hooks/keyword-node? %)
|
||||
(= "model" (namespace (hooks/sexpr %))))))
|
||||
rest)]
|
||||
;; Only the map immediately after the model is conditions. A later map is the changes map,
|
||||
;; whose values are written rather than filtered on, and a written value is never marked: a
|
||||
;; column's `:in` transform runs on the marker itself and stores it as data.
|
||||
;; `update!`'s arglist ENDS in the changes map, so its first map is conditions only when
|
||||
;; another argument follows: `(t2/update! model {:v written})` is the two-arity call whose
|
||||
;; single map is CHANGES, so flagging it would ask for a marker that corrupts the write.
|
||||
;; `delete!` has no changes map, so its map is always conditions.
|
||||
(when-let [m (when (or (not (contains? changes-map-fns (some-> f name symbol)))
|
||||
(next after-model))
|
||||
(first after-model))]
|
||||
(when (and (hooks/map-node? m) (not (query-map-node? m)))
|
||||
(mapcat (fn [v]
|
||||
;; The column comes from the map key, so an operator form here holds only
|
||||
;; values -- `{:key [:in ks]}` -- exactly as a kv-arg pair does.
|
||||
(if (and (hooks/vector-node? v) (not (marked? v)))
|
||||
(let [[head & op-args] (:children v)
|
||||
op (some-> head hooks/sexpr)]
|
||||
(if (and (keyword? op) (contains? value-operators (symbol (name op))))
|
||||
(mapcat #(cons % (value-nodes %)) op-args)
|
||||
(cons v (value-nodes v))))
|
||||
(cons v (value-nodes v))))
|
||||
(take-nth 2 (rest (:children m)))))))))
|
||||
|
||||
(defn- kv-arg-value-nodes
|
||||
"The value nodes of a call written as `:column value` pairs.
|
||||
|
||||
`(t2/select :model/X :locale locale)`. Several of these fns take an argument before the model --
|
||||
`(t2/select-one-fn :value :model/X :key k)` -- so the pairs do not start at a fixed offset; they
|
||||
start after the `:model/...` keyword. A call that does not name a literal model is not checked."
|
||||
start after the `:model/...` keyword. A call that does not name a literal model is not checked.
|
||||
|
||||
A trailing query map is not a pair, and its values are reached by [[value-nodes]] instead. A
|
||||
value that is itself an operator form -- `:id [:in ids]` -- has its own values walked, so the
|
||||
collection inside is checked rather than the form."
|
||||
[args]
|
||||
(let [after-model (->> args
|
||||
(drop-while #(not (and (hooks/keyword-node? %)
|
||||
(= "model" (namespace (hooks/sexpr %))))))
|
||||
rest)
|
||||
[k v] after-model]
|
||||
(when (and k v (hooks/keyword-node? k))
|
||||
k)))
|
||||
rest)]
|
||||
(->> after-model
|
||||
;; `partition-all` rather than `partition` so a trailing odd argument (a query map, which
|
||||
;; is not a pair) is still seen and skipped by the keyword-node? test below.
|
||||
(partition-all 2)
|
||||
(mapcat (fn [[k v]]
|
||||
(when (and v (hooks/keyword-node? k))
|
||||
(if-not (hooks/vector-node? v)
|
||||
[v]
|
||||
(let [[head & op-args] (:children v)
|
||||
op (some-> head hooks/sexpr)]
|
||||
(cond
|
||||
;; Already marked -- the marker is the value slot.
|
||||
(marked? v) [v]
|
||||
|
||||
(defn- lint-kv-args!
|
||||
"Register a finding for a query written as `:column value` pairs.
|
||||
;; `:id [:in ids]`. Toucan folds the column in from the pair key, so
|
||||
;; EVERY argument here is a value -- unlike a query-map clause, where
|
||||
;; the first argument is the column.
|
||||
(and (keyword? op)
|
||||
(contains? value-operators (symbol (name op))))
|
||||
(mapcat #(cons % (value-nodes %)) op-args)
|
||||
|
||||
Toucan builds those into the where clause itself, so the query's shape is not on the page: there
|
||||
is nothing for a reader, this linter, or the `.sql` extraction that follows this project to read.
|
||||
The same call takes a map -- `{:where [:= :locale v]}` for a select, `{:locale v}` for an
|
||||
`update!` -- which is the statement, near enough to read off."
|
||||
[node]
|
||||
(when-let [k (kv-arg-pair (rest (:children node)))]
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta k)
|
||||
:message (format "Pass this query a map rather than `%s ...` pairs, so its shape is on the page."
|
||||
(hooks/sexpr k))
|
||||
:type :metabase/unsafe-app-db-query))))
|
||||
:else (cons v (value-nodes v)))))))))))
|
||||
|
||||
(defn- lint-unmarked-values!
|
||||
"Register a finding for each argument of the enclosing function that reaches a value slot unmarked.
|
||||
@@ -113,7 +291,15 @@
|
||||
Only a symbol is reported. A literal cannot carry a request value, and a value built inside the
|
||||
function is out of reach of a check that does not follow it across a call."
|
||||
[node]
|
||||
(doseq [value (mapcat value-nodes (rest (:children node)))
|
||||
(doseq [value (let [args (rest (:children node))
|
||||
f (some-> (first (:children node)) hooks/sexpr)]
|
||||
;; The three walkers overlap on some shapes -- a multi-arg operator in a kv-arg
|
||||
;; reaches both `value-nodes` and `kv-arg-value-nodes` -- so dedupe by source
|
||||
;; position rather than trying to keep them disjoint.
|
||||
(->> (concat (mapcat value-nodes args)
|
||||
(kv-arg-value-nodes args)
|
||||
(conditions-map-value-nodes f args))
|
||||
(distinct-by #(select-keys (meta %) [:row :col]))))
|
||||
:when (and (hooks/token-node? value)
|
||||
(symbol? (hooks/sexpr value))
|
||||
(not (marked? value)))]
|
||||
@@ -141,6 +327,5 @@
|
||||
(db-namespace? (modules/config input) ns)
|
||||
(not (test-file? filename))
|
||||
(read-call? node))
|
||||
(lint-unmarked-values! node)
|
||||
(lint-kv-args! node))
|
||||
(lint-unmarked-values! node))
|
||||
input)
|
||||
|
||||
@@ -103,24 +103,101 @@
|
||||
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :locale locale]})
|
||||
'metabase.foo.db "test/metabase/foo/db_test.clj")))))
|
||||
|
||||
(deftest ^:parallel kv-arg-style-test
|
||||
(testing "a query written as :column value pairs is flagged whatever the values are"
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query, :message #"Pass this query a map.*"}]
|
||||
(deftest ^:parallel kv-arg-style-is-not-flagged-test
|
||||
(testing "call style is not linted: a query written as :column value pairs is left alone"
|
||||
;; Extraction to a .sql file is semantic rather than a transcription of the HoneySQL shape, so a
|
||||
;; query map is no closer to that target than kv-args are. Converting also skips Toucan's type
|
||||
;; transforms, which only run in kv-arg position -- that bug shipped twice.
|
||||
(are [form] (empty? (filter #(re-find #"Pass this query a map" (str (:message %)))
|
||||
(lint-query-call form 'metabase.foo.db)))
|
||||
'(t2/select :model/X :locale [:auto/param locale])
|
||||
'(t2/select :model/X :archived false)
|
||||
'(t2/select-one :model/X :id (long id))
|
||||
'(t2/select-one-fn :value :model/Setting :key [:auto/param k])
|
||||
'(t2/select :model/X {:where [:= :locale [:auto/param locale]]})))
|
||||
(testing "an unmarked value in a kv-arg is still flagged -- only the style check went away"
|
||||
(is (=? [{:type :metabase/unsafe-app-db-query, :message #".*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call '(t2/select :model/X :locale locale) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel conditions-map-values-are-linted-test
|
||||
(testing "a conditions map -- a shape the sweep leaves in place -- is checked"
|
||||
;; `(t2/update! model {:col v} changes)` filters on v, but it is not a `:where` clause, so
|
||||
;; neither the query-map walker nor the kv-arg walker used to see it. A namespace written this
|
||||
;; way passed the lint vacuously.
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #".*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/update! :model/X {:key k} {:v 1})
|
||||
'(t2/delete! :model/X {:key k})
|
||||
'(t2/update! :model/X {:key [:in ks]} {:v 1})))
|
||||
(testing "a marked or coerced conditions value is not flagged"
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/update! :model/X {:key [:auto/param k]} {:v 1})
|
||||
'(t2/update! :model/X {:id (long id)} {:v 1})))
|
||||
(testing "only the map right after the model is conditions -- the changes map is exempt"
|
||||
;; rule 1: a written value must arrive as itself, so it is never flagged.
|
||||
(is (empty? (lint-query-call '(t2/update! :model/X {:id (long id)} {:v written})
|
||||
'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel subquery-values-are-linted-test
|
||||
(testing "a value inside a subquery in a value slot is reached"
|
||||
(is (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #".*`z`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call
|
||||
'(t2/select :model/X {:where [:in :id {:select [:id] :from [:y] :where [:= :z z]}]})
|
||||
'metabase.foo.db))))
|
||||
(testing "the plain token case still works -- descending must not replace it"
|
||||
(is (=? [{:message #"`v`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call '(t2/select :model/X {:where [:= :k v]}) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel conditionally-built-clause-test
|
||||
(testing "a value slot inside a conditional form is still reached"
|
||||
;; `value-nodes` used to stop at a list node, so anything a `when`/`if`/`cond->` built was
|
||||
;; invisible -- a false negative in a security lint. permissions/db.clj and collections/db.clj
|
||||
;; both build clauses this way.
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #".*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X {:where [:and (when k [:= :key k])]})))
|
||||
(testing "both branches of an if are reached"
|
||||
(is (= 2 (count (lint-query-call
|
||||
'(t2/select :model/X {:where [:and (if flag [:= :key k] [:= :key other-k])]})
|
||||
'metabase.foo.db)))))
|
||||
(testing "a marked value inside a conditional form is not flagged"
|
||||
(is (empty? (lint-query-call
|
||||
'(t2/select :model/X {:where [:and (when k [:= :key [:auto/param k]])]})
|
||||
'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel kv-arg-values-are-linted-test
|
||||
(testing "an unmarked value in a kv-arg pair is flagged, so dropping the style check left no gap"
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #".*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X :locale locale)
|
||||
'(t2/select :model/X :archived false)
|
||||
'(t2/select :model/X :locale [:auto/param locale])
|
||||
'(t2/select-one :model/X :id (long id))))
|
||||
(testing "one finding per call, not one per pair"
|
||||
(is (= 1 (count (lint-query-call '(t2/select :model/X :locale locale :msgid msgid)
|
||||
'metabase.foo.db)))))
|
||||
(testing "a fn with an argument before the model is still recognised"
|
||||
(is (=? [{:message #"Pass this query a map.*"}]
|
||||
(lint-query-call '(t2/select-one-fn :value :model/Setting :key k) 'metabase.foo.db))))
|
||||
(testing "a map query is not flagged"
|
||||
'(t2/select-one :model/X :id id)
|
||||
;; the pairs do not start at a fixed offset -- :value comes before the model here
|
||||
'(t2/select-one-fn :value :model/Setting :key k)
|
||||
;; a value inside an operator form is reached, rather than the form being treated as a value
|
||||
'(t2/select :model/X :id [:in ids])))
|
||||
(testing "a marked or coerced kv-arg value is not flagged"
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X {:where [:= :locale [:auto/param locale]]})
|
||||
'(t2/update! :model/X {:locale [:auto/param locale]} {:msgstr "x"}))))
|
||||
'(t2/select :model/X :locale [:auto/param locale])
|
||||
'(t2/select-one :model/X :id (long id))
|
||||
'(t2/select :model/X :id [:in (mapv long ids)])
|
||||
'(t2/select :model/X :id [:in [:auto/param ids]])))
|
||||
(testing "a literal kv-arg value is not flagged"
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X :archived false)
|
||||
'(t2/select :model/X :status "pending")
|
||||
'(t2/select :model/X :engine :appdb)))
|
||||
(testing "one unmarked value per pair is reported"
|
||||
(is (= 2 (count (lint-query-call '(t2/select :model/X :locale locale :msgid msgid)
|
||||
'metabase.foo.db)))))
|
||||
(testing "a call mixing kv-args with a trailing query map reports each value exactly once"
|
||||
;; The two walkers run over the same arguments, so this pins that they do not double-report.
|
||||
(is (= 2 (count (lint-query-call
|
||||
'(t2/select :model/X :locale locale {:where [:= :other other]})
|
||||
'metabase.foo.db))))))
|
||||
|
||||
(deftest ^:parallel write-calls-are-not-linted-for-values-test
|
||||
(testing "an insert's values are written, not filtered on, so they are not flagged"
|
||||
@@ -136,3 +213,112 @@
|
||||
(testing "a value operator with an unexpected arity still has its args examined"
|
||||
(is (seq (lint-query-call '(t2/select :model/X {:where [:= :a b c]}) 'metabase.foo.db)))
|
||||
(is (seq (lint-query-call '(t2/select :model/X {:where [:between :a lo hi]}) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel operator-coverage-test
|
||||
(testing "comparison operators HoneySQL accepts as aliases for not= are checked"
|
||||
;; The set used to list only `not=`, so `[:!= :col v]` -- live in collections/db.clj -- was
|
||||
;; silently unchecked even though HoneySQL binds it as `<>`.
|
||||
(are [form] (=? [{:message #"`v`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X {:where [:!= :k v]})
|
||||
'(t2/select :model/X {:where [:<> :k v]})
|
||||
'(t2/select :model/X {:where [:is-distinct-from :k v]})))
|
||||
(testing "a two-value operator reports both values"
|
||||
(is (= 2 (count (lint-query-call '(t2/select :model/X {:where [:not-between :k lo hi]})
|
||||
'metabase.foo.db)))))
|
||||
(testing "a value wrapped in a function-call form is reached"
|
||||
;; `[:lower v]` compiles to `LOWER(?)`, so `v` is a real bind slot.
|
||||
(are [form] (seq (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X {:where [:= :k [:lower v]]})
|
||||
'(t2/select :model/X {:where [:= :k [:cast v :text]]})
|
||||
'(t2/select :model/X :k [:lower v])))
|
||||
(testing "values in a literal collection are reached"
|
||||
;; `[:in :k [a b]]` binds each element -- `IN (?, ?)`.
|
||||
(is (= 2 (count (lint-query-call '(t2/select :model/X {:where [:in :k [a b]]})
|
||||
'metabase.foo.db))))))
|
||||
|
||||
(deftest ^:parallel no-duplicate-findings-test
|
||||
(testing "a multi-arg operator in a kv-arg reports each value exactly once"
|
||||
;; value-nodes misreads `[:between lo hi]` as a clause (lo as column), while
|
||||
;; kv-arg-value-nodes reads it correctly, so the walkers overlap on `hi`. Deduping by source
|
||||
;; position makes that harmless.
|
||||
(is (= 2 (count (lint-query-call '(t2/select :model/X :d [:between lo hi]) 'metabase.foo.db))))
|
||||
(is (= 2 (count (lint-query-call '(t2/delete! :model/X {:d [:between lo hi]})
|
||||
'metabase.foo.db)))))
|
||||
(testing "a query map passed where conditions go reports each value once"
|
||||
;; `{:where ...}` is a query map that value-nodes already walks; treating it as conditions too
|
||||
;; reported everything twice. Live in mcp/db.clj, metabot/db.clj, testing_api/db.clj.
|
||||
(is (= 1 (count (lint-query-call '(t2/delete! :model/X {:where [:= :k k]}) 'metabase.foo.db))))
|
||||
(is (= 2 (count (lint-query-call '(t2/update! :model/X {:where [:and [:= :a a] [:= :b b]]} {:v 1})
|
||||
'metabase.foo.db))))))
|
||||
|
||||
(deftest ^:parallel two-arity-update-changes-map-test
|
||||
(testing "update!'s lone trailing map is CHANGES, not conditions, so its values are not flagged"
|
||||
;; Toucan's arglist ends in the changes map. remote_sync/db.clj's mark-all-rsos-synced! is this
|
||||
;; shape; flagging `written` would ask for a marker on a written value, which the column's
|
||||
;; `:in` transform stores as data.
|
||||
(is (empty? (lint-query-call '(t2/update! :model/X {:v written}) 'metabase.foo.db))))
|
||||
(testing "delete! has no changes map, so its lone map is always conditions"
|
||||
(is (=? [{:message #"`k`.*"}]
|
||||
(lint-query-call '(t2/delete! :model/X {:k k}) 'metabase.foo.db))))
|
||||
(testing "update!'s FIRST map is still conditions when a changes map follows"
|
||||
(is (=? [{:message #"`k`.*"}]
|
||||
(lint-query-call '(t2/update! :model/X {:k k} {:v 1}) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel identifier-clauses-are-not-value-slots-test
|
||||
(testing "a symbol naming a column or a table is not reported"
|
||||
;; `value-guard` refuses a marker in these clauses, so reporting one asks for a fix that throws
|
||||
;; at compile. data_studio/db.clj, task_history/db.clj and permissions/db.clj are written this
|
||||
;; way. `:select` is the exception: index 0 of its entries is an expression, so it has its own
|
||||
;; test in expression-entry-clauses-test.
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query {:select [:*] :from [[union-query :subquery]]})
|
||||
'(t2/query {:select [:*] :from [:t] :order-by [[:sort_key sort-direction]]})
|
||||
'(t2/query {:select [:*] :from [:t] :group-by [group-column]})))
|
||||
(testing "a value nested inside an identifier clause is still reported"
|
||||
(are [form] (=? [{:message #"`v`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
;; a computed projection is a real comparison
|
||||
'(t2/query {:select [[[:= :engine v] :is_match]] :from [:t]})
|
||||
;; so is a CASE sort key
|
||||
'(t2/query {:select [:*] :from [:t] :order-by [[[:case [:= :a v] 1 :else 2] :asc]]})
|
||||
;; a subquery carries its own where clause
|
||||
'(t2/query {:select [:*] :from [[{:select [:*] :from [:u] :where [:= :k v]} :s]]}))))
|
||||
|
||||
(deftest ^:parallel expression-entry-clauses-test
|
||||
(testing "index 0 of an entry is a value slot, because HoneySQL binds a parameter there"
|
||||
;; `{:select [[[:auto/param "a"] :k]]}` compiles to `SELECT ? AS "K"`, so `value-guard` accepts
|
||||
;; a marker there. Treating the whole clause as names would hide a request value.
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #"`v`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query {:select [[v :label]] :from [:t]})
|
||||
'(t2/query {:select-distinct [[v :label]] :from [:t]})
|
||||
'(t2/query {:insert-into [:t] :returning [[v :label]]})))
|
||||
(testing "the alias, a bare entry and a table entry are names"
|
||||
;; `value-guard` refuses a marker in each of these, so asking for one would break the query.
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query {:select [[:key alias-sym]] :from [:t]})
|
||||
'(t2/query {:select [cols] :from [:t]})
|
||||
'(t2/query {:select [:*] :from [[tbl :x]]})))
|
||||
(testing "a computed projection and a subquery entry still reach their values"
|
||||
(are [form] (=? [{:message #"`v`.*"}] (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query {:select [[[:= :engine v] :is_match]] :from [:t]})
|
||||
'(t2/query {:select [:*] :from [[{:select [:*] :from [:u] :where [:= :k v]} :s]]}))))
|
||||
|
||||
(deftest ^:parallel clause-context-survives-assoc-test
|
||||
(testing "a clause key in an assoc call gives its value the clause context back"
|
||||
;; task_history/db.clj:174 and five other namespaces build `:order-by` this way. Without this
|
||||
;; the lint asks for a marker on a column name and on a sort direction, and the fix the message
|
||||
;; names loses the ordering with no error.
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query (assoc q :order-by [[sort-column sort-direction] [:id :desc]]))
|
||||
'(t2/query (cond-> q flag (assoc :order-by [[sort-column sort-direction]])))
|
||||
'(t2/query (assoc q :group-by [group-column]))
|
||||
'(t2/query (assoc q :select [[:key alias-sym]]))))
|
||||
(testing "a value clause built the same way is still reported"
|
||||
(is (=? [{:message #"`v`.*"}]
|
||||
(lint-query-call '(t2/query (assoc q :where [:= :k v])) 'metabase.foo.db))))
|
||||
(testing "an expression entry built the same way is still reported"
|
||||
(is (=? [{:message #"`v`.*"}]
|
||||
(lint-query-call '(t2/query (assoc q :select [[v :label]])) 'metabase.foo.db)))))
|
||||
|
||||
14
.github/actions/collect-profile/action.yml
vendored
14
.github/actions/collect-profile/action.yml
vendored
@@ -22,6 +22,9 @@ runs:
|
||||
# `awk 'NR<=25'` rather than `head -25`: the runner ignores SIGPIPE, so `head` exiting early
|
||||
# leaves `sort` with an EPIPE write error and `pipefail` fails the step. `|| true` covers `grep`
|
||||
# finding no junit files.
|
||||
#
|
||||
# `-r --include` rather than a `target/junit/*.xml` glob: a multi-JVM run nests one directory
|
||||
# per partition under `target/junit`, and a non-recursive glob matches none of it.
|
||||
- name: Summarize test wall times
|
||||
if: ${{ inputs.profiled == 'true' }}
|
||||
continue-on-error: true
|
||||
@@ -30,13 +33,13 @@ runs:
|
||||
{
|
||||
echo "### Slowest test namespaces (seconds)"
|
||||
echo '```'
|
||||
grep -h '<testsuite' target/junit/*.xml 2>/dev/null \
|
||||
grep -rh --include='*.xml' '<testsuite' target/junit 2>/dev/null \
|
||||
| sed -E 's|.*<testsuite name="([^"]*)" time="([^"]*)".*|\2 \1|' \
|
||||
| sort -rn | awk 'NR<=25' || true
|
||||
echo '```'
|
||||
echo "### Slowest tests (seconds)"
|
||||
echo '```'
|
||||
grep -h '<testcase' target/junit/*.xml 2>/dev/null \
|
||||
grep -rh --include='*.xml' '<testcase' target/junit 2>/dev/null \
|
||||
| sed -E 's|.*classname="([^"]*)" name="([^"]*)" time="([^"]*)".*|\3 \1/\2|' \
|
||||
| sort -rn | awk 'NR<=25' || true
|
||||
echo '```'
|
||||
@@ -53,9 +56,12 @@ runs:
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
# Globs, because a multi-JVM run nests its junit XML one directory per partition and
|
||||
# names each log `test-log-p<index>.json`. The flamegraphs stay flat, with that same
|
||||
# `p<index>-` prefix.
|
||||
path: |
|
||||
target/clj-async-profiler/results/*flamegraph.html
|
||||
target/junit/*.xml
|
||||
logs/test-log.json
|
||||
target/junit/**/*.xml
|
||||
logs/test-log*.json
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
10
.github/actions/test-driver/action.yml
vendored
10
.github/actions/test-driver/action.yml
vendored
@@ -130,6 +130,7 @@ runs:
|
||||
if [ "$PROFILE_TESTS" = "true" ]; then
|
||||
echo "Profiling this run; a flamegraph will be attached to the job."
|
||||
echo "profiled=true" >> "$GITHUB_OUTPUT"
|
||||
ALIASES="$ALIASES:profile-tests"
|
||||
BASE="$BASE:profile-tests"
|
||||
fi
|
||||
# An explicit selection wins over the rerun narrowing below: it was asked for by hand, on this
|
||||
@@ -149,6 +150,15 @@ runs:
|
||||
shell: bash
|
||||
id: run-test
|
||||
env:
|
||||
# Both derived from test.profile-backend.yml, the sole dispatcher; its runs always profile. Inside
|
||||
# a called workflow `github.event` is the caller's payload, so these resolve here rather than
|
||||
# in each calling workflow. On a normal CI run there are no dispatch inputs, so these render
|
||||
# `false` and empty and the job's own selection stands.
|
||||
#
|
||||
# ONLY_TESTS replaces the job's own test selection, so every matrix leg of the chosen job
|
||||
# runs the whole list.
|
||||
PROFILE_TESTS: ${{ github.event.inputs.suite != '' }}
|
||||
ONLY_TESTS: ${{ github.event.inputs.tests }}
|
||||
TEST_RUNNER_INDEX: ${{ inputs.runner-index }}
|
||||
TEST_RUNNER_COUNT: ${{ inputs.runner-count }}
|
||||
TEST_JVMS_PER_RUNNER: ${{ inputs.jvms-per-runner }}
|
||||
|
||||
9
.github/scripts/run-backend-test-jvms.sh
vendored
9
.github/scripts/run-backend-test-jvms.sh
vendored
@@ -98,6 +98,15 @@ merge_outputs() {
|
||||
if [ -f "$jvm_dir/logs/test-log.json" ]; then
|
||||
cp -a "$jvm_dir/logs/test-log.json" "$WORKSPACE/logs/test-log-p$idx.json" || true
|
||||
fi
|
||||
# Flamegraphs, when the run was profiled. Flat with a `p$idx-` prefix, not nested: the
|
||||
# collect-profile action globs `results/*flamegraph.html` without descending.
|
||||
if [ -d "$jvm_dir/target/clj-async-profiler/results" ]; then
|
||||
mkdir -p "$WORKSPACE/target/clj-async-profiler/results"
|
||||
for f in "$jvm_dir"/target/clj-async-profiler/results/*flamegraph.html; do
|
||||
[ -e "$f" ] || continue
|
||||
cp -a "$f" "$WORKSPACE/target/clj-async-profiler/results/p$idx-$(basename "$f")" || true
|
||||
done
|
||||
fi
|
||||
done
|
||||
}
|
||||
trap merge_outputs EXIT
|
||||
|
||||
38
.github/workflows/app-db.yml
vendored
38
.github/workflows/app-db.yml
vendored
@@ -11,30 +11,6 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Run one app-DB job on demand, against the ref you dispatch on, optionally under a profiler.
|
||||
# Everything else about the job -- services, matrix legs, test args, quarantine gating -- is
|
||||
# whatever the normal CI run uses, because it is the same job.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
app-db:
|
||||
description: 'Which app-DB job to run. Only this one runs; the others are skipped.'
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- mariadb
|
||||
- mysql
|
||||
- postgres
|
||||
tests:
|
||||
description: 'Optional: run only these tests, comma- or space-separated, e.g. metabase.db.custom-migrations-test/some-test. Bare namespaces work too. Blank runs the job''s usual set.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
profile:
|
||||
description: 'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-app-db
|
||||
cancel-in-progress: true
|
||||
@@ -45,14 +21,6 @@ concurrency:
|
||||
env:
|
||||
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
# Both read by the test-driver action, which is what actually runs the tests. Set here rather than
|
||||
# passed as inputs to each job so that turning either on is one line, not one line per job.
|
||||
# Empty (and so off) on workflow_call, where neither input exists.
|
||||
#
|
||||
# ONLY_TESTS replaces the job's own test selection, so every matrix leg of the chosen job runs the
|
||||
# whole list -- each app DB has three.
|
||||
PROFILE_TESTS: ${{ inputs.profile }}
|
||||
ONLY_TESTS: ${{ inputs.tests }}
|
||||
|
||||
jobs:
|
||||
app-db-matrix:
|
||||
@@ -162,7 +130,7 @@ jobs:
|
||||
|
||||
be-tests-mariadb:
|
||||
needs: app-db-matrix
|
||||
if: ${{ !inputs.skip && (inputs.app-db == '' || inputs.app-db == 'mariadb') }}
|
||||
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'mariadb') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -223,7 +191,7 @@ jobs:
|
||||
|
||||
be-tests-mysql:
|
||||
needs: app-db-matrix
|
||||
if: ${{ !inputs.skip && (inputs.app-db == '' || inputs.app-db == 'mysql') }}
|
||||
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'mysql') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -282,7 +250,7 @@ jobs:
|
||||
|
||||
be-tests-postgres:
|
||||
needs: app-db-matrix
|
||||
if: ${{ !inputs.skip && (inputs.app-db == '' || inputs.app-db == 'postgres') }}
|
||||
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'postgres') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
|
||||
33
.github/workflows/backend.yml
vendored
33
.github/workflows/backend.yml
vendored
@@ -23,22 +23,6 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Run the backend test suite on demand, against the ref you dispatch on, optionally under a
|
||||
# profiler. Everything else about the jobs -- matrix legs, tag filters, quarantine gating -- is
|
||||
# whatever the normal CI run uses, because they are the same jobs.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tests:
|
||||
description: 'Optional: run only these tests, comma- or space-separated, e.g. metabase.query-processor-test/some-test. Bare namespaces work too. Blank runs each leg''s usual set.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
profile:
|
||||
description: 'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-backend
|
||||
cancel-in-progress: true
|
||||
@@ -49,13 +33,6 @@ concurrency:
|
||||
env:
|
||||
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
# Both read by the be-tests run step below. Set here rather than passed per job so that turning
|
||||
# either on is one line. Empty (and so off) on workflow_call, where neither input exists.
|
||||
#
|
||||
# ONLY_TESTS replaces the leg's own test selection, so every matrix leg runs the whole list: the
|
||||
# partition args no longer apply, and legs that hold none of the named tests run nothing.
|
||||
PROFILE_TESTS: ${{ inputs.profile }}
|
||||
ONLY_TESTS: ${{ inputs.tests }}
|
||||
|
||||
jobs:
|
||||
project-checks:
|
||||
@@ -374,6 +351,16 @@ jobs:
|
||||
else
|
||||
$BASE :exclude-tags '[:mb/driver-tests ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]' ${{ matrix.job.test-args }}
|
||||
fi
|
||||
env:
|
||||
# Both derived from test.profile-backend.yml, the sole dispatcher; its runs always profile. On a
|
||||
# normal CI run there are no dispatch inputs, so these render `false` and empty and the
|
||||
# leg's own selection stands.
|
||||
#
|
||||
# ONLY_TESTS replaces the leg's own test selection, so every matrix leg runs the whole
|
||||
# list: the partition args no longer apply, and legs that hold none of the named tests run
|
||||
# nothing.
|
||||
PROFILE_TESTS: ${{ github.event.inputs.suite != '' }}
|
||||
ONLY_TESTS: ${{ github.event.inputs.tests }}
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
53
.github/workflows/drivers.yml
vendored
53
.github/workflows/drivers.yml
vendored
@@ -10,45 +10,6 @@ on:
|
||||
description: Run the full suite regardless of the diff (master / release branches, ci:run-all label).
|
||||
type: boolean
|
||||
default: false
|
||||
# Run one driver job on demand, against the ref you dispatch on, optionally under a profiler.
|
||||
# Everything else about the job -- credentials, services, test args, quarantine gating -- is
|
||||
# whatever the normal CI run uses, because it is the same job.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
driver:
|
||||
description: 'Which driver job to run. Only this one runs; the usual decision rules are bypassed.'
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- athena
|
||||
- bigquery
|
||||
- clickhouse
|
||||
- databricks
|
||||
- druid-jdbc
|
||||
- h2
|
||||
- mongo
|
||||
- mongo-sharded-cluster
|
||||
- mongo-ssl
|
||||
- mysql-mariadb
|
||||
- oracle
|
||||
- postgres
|
||||
- presto-jdbc
|
||||
- redshift
|
||||
- snowflake
|
||||
- sparksql
|
||||
- sqlite
|
||||
- sqlserver
|
||||
- vertica
|
||||
tests:
|
||||
description: 'Optional: run only these tests, comma- or space-separated, e.g. metabase.driver.bigquery-cloud-sdk-test/sync-test. Bare namespaces work too. Blank runs the job''s usual set.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
profile:
|
||||
description: 'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-drivers
|
||||
@@ -60,14 +21,6 @@ concurrency:
|
||||
env:
|
||||
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
# Both read by the test-driver action, which is what actually runs the tests. Set here rather than
|
||||
# passed as inputs to each job so that turning either on is one line, not one line per driver.
|
||||
# Empty (and so off) on workflow_call, where neither input exists.
|
||||
#
|
||||
# ONLY_TESTS replaces the job's own test selection, so every matrix leg of the chosen driver runs
|
||||
# the whole list -- BigQuery, for instance, has three legs.
|
||||
PROFILE_TESTS: ${{ inputs.profile }}
|
||||
ONLY_TESTS: ${{ inputs.tests }}
|
||||
|
||||
jobs:
|
||||
# This job determines which driver tests should run based on PR context.
|
||||
@@ -117,7 +70,7 @@ jobs:
|
||||
--force-run="${FORCE_RUN}"
|
||||
--pr-labels="${{ join(github.event.pull_request.labels.*.name, ',') }}"
|
||||
--skip="${{ inputs.skip }}"
|
||||
--only-driver="${ONLY_DRIVER}"
|
||||
--only-driver="${PROFILE_DRIVER}"
|
||||
)
|
||||
echo "Running:"
|
||||
echo "./bin/mage -driver-decisions ${MAGE_ARGS[*]}"
|
||||
@@ -132,7 +85,9 @@ jobs:
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref || github.ref_name }}
|
||||
FORCE_RUN: ${{ inputs.force-run }}
|
||||
ONLY_DRIVER: ${{ inputs.driver }}
|
||||
# Set only by test.profile-backend.yml. Absent on a normal CI run, which leaves the decision rules
|
||||
# alone; mage rejects a name that is not a driver.
|
||||
PROFILE_DRIVER: ${{ github.event.inputs.db }}
|
||||
shell: bash
|
||||
|
||||
be-tests-athena-ee:
|
||||
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
run: NODE_ENV=development bun run build-pure:cljs
|
||||
|
||||
- name: Build Storybook
|
||||
run: bun run build-storybook
|
||||
run: bun run build-storybook:test
|
||||
env:
|
||||
STORYBOOK_STORIES_FILTER: ${{ needs.detect-changed-stories.outputs.story_files }}
|
||||
|
||||
|
||||
2
.github/workflows/loki.yml
vendored
2
.github/workflows/loki.yml
vendored
@@ -88,7 +88,7 @@ jobs:
|
||||
- name: Build Storybook
|
||||
env:
|
||||
STORYBOOK_STORY_PATHS_FILE: ${{ steps.loki-selection.outputs.paths-file }}
|
||||
run: bun run build-storybook
|
||||
run: bun run build-storybook:test
|
||||
|
||||
- name: Run Loki Visual Tests
|
||||
# Loki drops stories with parameters.loki.skip, and exits non-zero when that leaves the selection empty.
|
||||
|
||||
11
.github/workflows/schedule-patch.yml
vendored
11
.github/workflows/schedule-patch.yml
vendored
@@ -14,14 +14,17 @@ jobs:
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch patch release for the current and previous major
|
||||
- name: Dispatch patch release for the current and recent majors
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: | # js
|
||||
// Nightly patches cover the current supported major and the one
|
||||
// before it. release-auto.yml releases one major per dispatch.
|
||||
const autoPatchVersionsCount = Number('${{ vars.AUTO_PATCH_VERSIONS_COUNT }}') || 0;
|
||||
const currentRelease = Number('${{ vars.CURRENT_VERSION }}');
|
||||
const majors = [currentRelease];
|
||||
// [currentRelease, currentRelease - 1, ..., currentRelease - autoPatchVersionsCount]
|
||||
const majors = Array.from(
|
||||
{ length: autoPatchVersionsCount + 1 },
|
||||
(_, i) => currentRelease - i,
|
||||
);
|
||||
|
||||
await Promise.all(majors.map(version =>
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
|
||||
44
.github/workflows/semantic-search.yml
vendored
44
.github/workflows/semantic-search.yml
vendored
@@ -11,32 +11,6 @@ on:
|
||||
default: false
|
||||
description: Only run Postgres Latest to do a quick resource-efficient sanity check, intended to be set for PRs.
|
||||
|
||||
# Run one semantic-search job on demand, against the ref you dispatch on, optionally under a
|
||||
# profiler. Everything else about the job -- services, matrix legs, test args, quarantine gating --
|
||||
# is whatever the normal CI run uses, because it is the same job.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
suite:
|
||||
description: 'Which semantic-search job to run. Only this one runs; the others are skipped.'
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- h2
|
||||
- mariadb
|
||||
- mysql
|
||||
- postgres
|
||||
- appdb-mode
|
||||
tests:
|
||||
description: 'Optional: run only these tests, comma- or space-separated, e.g. metabase-enterprise.semantic-search.index-test/some-test. Bare namespaces work too. Blank runs the job''s usual set.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
profile:
|
||||
description: 'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-semantic-search
|
||||
cancel-in-progress: true
|
||||
@@ -47,18 +21,10 @@ concurrency:
|
||||
env:
|
||||
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
# Both read by the test-driver action, which is what actually runs the tests. Set here rather than
|
||||
# passed as inputs to each job so that turning either on is one line, not one line per job.
|
||||
# Empty (and so off) on workflow_call, where neither input exists.
|
||||
#
|
||||
# ONLY_TESTS replaces the job's own test selection, so every matrix leg of the chosen job runs the
|
||||
# whole list.
|
||||
PROFILE_TESTS: ${{ inputs.profile }}
|
||||
ONLY_TESTS: ${{ inputs.tests }}
|
||||
|
||||
jobs:
|
||||
semantic-search-tests-h2:
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (inputs.suite == '' || inputs.suite == 'h2') }}
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'h2') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -102,7 +68,7 @@ jobs:
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
|
||||
semantic-search-tests-mariadb:
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (inputs.suite == '' || inputs.suite == 'mariadb') }}
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'mariadb') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -167,7 +133,7 @@ jobs:
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
|
||||
semantic-search-tests-mysql:
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (inputs.suite == '' || inputs.suite == 'mysql') }}
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'mysql') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -239,7 +205,7 @@ jobs:
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
|
||||
semantic-search-tests-postgres:
|
||||
if: ${{ !inputs.skip && (inputs.suite == '' || inputs.suite == 'postgres') }}
|
||||
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'postgres') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -310,7 +276,7 @@ jobs:
|
||||
# MB_PGVECTOR_DB_URL is deliberately unset. The dedicated-harness tests gate themselves off; the
|
||||
# appdb-pgvector-mode round trip (and the URL-independent unit tests) run for real.
|
||||
semantic-search-tests-appdb-mode:
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (inputs.suite == '' || inputs.suite == 'appdb-mode') }}
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'appdb-mode') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
|
||||
65
.github/workflows/test.profile-backend.yml
vendored
Normal file
65
.github/workflows/test.profile-backend.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Profile Backend
|
||||
|
||||
# The only on-demand entry point for the backend test suites. Dispatching this workflow runs one
|
||||
# job of one suite against the ref you dispatch on, under clj-async-profiler, and attaches a
|
||||
# flamegraph to the job.
|
||||
#
|
||||
# The suite workflows below are called unchanged: services, matrix legs, test args and quarantine
|
||||
# gating are whatever the normal CI run uses, because they are the same jobs. They declare nothing
|
||||
# for this feature -- they read `suite`, `db` and `tests` off `github.event.inputs`, which inside a
|
||||
# called workflow is this dispatch's payload.
|
||||
#
|
||||
# `suite` picks the workflow, `db` picks the leg within it. `db` is free text so that adding a
|
||||
# driver or an app-DB leg needs no edit here; an unknown driver fails the drivers run through
|
||||
# mage's `--only-driver`, and an unknown app-DB or semantic-search leg matches nothing and runs no
|
||||
# jobs.
|
||||
#
|
||||
# Renaming an input here means updating every reader -- `grep -rn github.event.inputs .github/`.
|
||||
# Nothing enforces that, deliberately: a stale name reads as empty, which runs every leg of the
|
||||
# suite unprofiled, on the run you are watching. It cannot affect a normal CI run, where those
|
||||
# references are empty either way.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
suite:
|
||||
description: 'Which suite to profile.'
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- backend
|
||||
- app-db
|
||||
- drivers
|
||||
- semantic-search
|
||||
db:
|
||||
description: 'Which leg of the suite: app-db takes mariadb, mysql or postgres; drivers takes a driver name, e.g. bigquery or mongo-ssl; semantic-search takes h2, mariadb, mysql, postgres or appdb-mode. Ignored by backend. Blank runs the whole suite -- every leg for app-db and semantic-search, mage''s usual decisions for drivers.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
tests:
|
||||
description: 'Optional: profile only these tests, comma- or space-separated, e.g. metabase.query-processor-test/some-test. Bare namespaces work too. Blank profiles the leg''s usual set.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
# These jobs gate on `inputs.suite` -- this workflow's own input. Only the called workflows reach
|
||||
# for `github.event.inputs.*`, because `inputs` there is their own `workflow_call` signature.
|
||||
jobs:
|
||||
backend:
|
||||
if: ${{ inputs.suite == 'backend' }}
|
||||
uses: ./.github/workflows/backend.yml
|
||||
secrets: inherit
|
||||
|
||||
app-db:
|
||||
if: ${{ inputs.suite == 'app-db' }}
|
||||
uses: ./.github/workflows/app-db.yml
|
||||
secrets: inherit
|
||||
|
||||
drivers:
|
||||
if: ${{ inputs.suite == 'drivers' }}
|
||||
uses: ./.github/workflows/drivers.yml
|
||||
secrets: inherit
|
||||
|
||||
semantic-search:
|
||||
if: ${{ inputs.suite == 'semantic-search' }}
|
||||
uses: ./.github/workflows/semantic-search.yml
|
||||
secrets: inherit
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
@@ -58,6 +58,11 @@ const config: StorybookConfig = {
|
||||
name: "@storybook/react-webpack5",
|
||||
options: {},
|
||||
},
|
||||
build: {
|
||||
test: {
|
||||
disableTreeShaking: false,
|
||||
},
|
||||
},
|
||||
typescript: {
|
||||
reactDocgen: "react-docgen-typescript",
|
||||
reactDocgenTypescriptOptions: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"extends": ["stylelint-config-standard", "stylelint-config-css-modules"],
|
||||
"plugins": ["./frontend/lint/stylelint-rules/no-base-colors.js"],
|
||||
"plugins": ["./frontend/lint/stylelint-rules/no-base-colors.mjs"],
|
||||
"rules": {
|
||||
"alpha-value-notation": null,
|
||||
"color-function-notation": null,
|
||||
|
||||
106
bun.lock
106
bun.lock
@@ -20,6 +20,8 @@
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.11",
|
||||
"@dagrejs/dagre": "^1.1.5",
|
||||
"@dicebear/core": "^10.7.0",
|
||||
"@dicebear/styles": "^10.6.0",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
@@ -42,7 +44,7 @@
|
||||
"@modelcontextprotocol/ext-apps": "^1.3.2",
|
||||
"@react-oauth/google": "^0.11.1",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"@snowplow/browser-tracker": "3.2.0",
|
||||
"@snowplow/browser-tracker": "4.10.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
"@tiptap/core": "^3.31.3",
|
||||
@@ -809,6 +811,10 @@
|
||||
|
||||
"@dependents/detective-less": ["@dependents/detective-less@5.0.1", "", { "dependencies": { "gonzales-pe": "^4.3.0", "node-source-walk": "^7.0.1" } }, "sha512-Y6+WUMsTFWE5jb20IFP4YGa5IrGY/+a/FbOSjDF/wz9gepU2hwCYSXRHP/vPwBvwcY3SVMASt4yXxbXNXigmZQ=="],
|
||||
|
||||
"@dicebear/core": ["@dicebear/core@10.7.0", "", {}, "sha512-JUOzKOd8RUByJJo8ZL+C7YE/pvEsWewfpbWUmR8OIQZWv/PvUQHnohdevySC3icyyxe6EIU/8B/rQgA42us1QA=="],
|
||||
|
||||
"@dicebear/styles": ["@dicebear/styles@10.6.0", "", {}, "sha512-KhxbEVI+z8TaenX58+ZhIRFBt7adggu/F+8g6ZYLu43H2/lgLuWA3Kg8vHBrCVWdfTTTt1p2wUhQomIkkypTuQ=="],
|
||||
|
||||
"@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="],
|
||||
|
||||
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.0", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ=="],
|
||||
@@ -1461,11 +1467,11 @@
|
||||
|
||||
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@13.0.5", "", { "dependencies": { "@sinonjs/commons": "^3.0.1" } }, "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw=="],
|
||||
|
||||
"@snowplow/browser-tracker": ["@snowplow/browser-tracker@3.2.0", "", { "dependencies": { "@snowplow/browser-tracker-core": "3.2.0", "@snowplow/tracker-core": "3.2.0", "tslib": "^2.3.0" } }, "sha512-k9oabjXpM7qIYM5CnMu1EZ/CbbffBKBK1TJTMJaf2k1F3Xplbyqx9DssN1NzgX+lH9Gc22n+yow+czRyppZOCg=="],
|
||||
"@snowplow/browser-tracker": ["@snowplow/browser-tracker@4.10.2", "", { "dependencies": { "@snowplow/browser-tracker-core": "4.10.2", "@snowplow/tracker-core": "4.10.2", "tslib": "^2.3.1" } }, "sha512-nDX0I2n7AUeTL9H1aI3FDBUkFo8DZIJ7Bp1g8ne7KBCcstrkxADpo7COz9iBOIXNDnr2scEy0ia/eKmRkaZFsA=="],
|
||||
|
||||
"@snowplow/browser-tracker-core": ["@snowplow/browser-tracker-core@3.2.0", "", { "dependencies": { "@snowplow/tracker-core": "3.2.0", "sha1": "^1.1.1", "tslib": "^2.3.0", "uuid": "^3.4.0" } }, "sha512-20mrp4OtyzXJMrvDjDmiddKnDUjccg3jOzmK/sNIv8jwPNleHct/MTV1dBo8aTkzxR2jcwL55AqnrD4XIQYDVg=="],
|
||||
"@snowplow/browser-tracker-core": ["@snowplow/browser-tracker-core@4.10.2", "", { "dependencies": { "@snowplow/tracker-core": "4.10.2", "tslib": "^2.3.1", "uuid": "^11.1.1" } }, "sha512-KX5Zu+IzslpQW7qVFKzTXupHoHgiz4UWA8A5KujcULrSm2vmDhYJGOtN0SVAOqc0TAB+xLmT9eoeDhSd+oSkKg=="],
|
||||
|
||||
"@snowplow/tracker-core": ["@snowplow/tracker-core@3.2.0", "", { "dependencies": { "tslib": "^2.3.0", "uuid": "^3.4.0" } }, "sha512-EsOs8HlWPp3SIFn3TSG5Eo8l/QEU0ZyHVIlvmoQT3fOdsWZVrTK9IlrWi/sA0S43tVxpHKLuiEKgmQIrQXuVyw=="],
|
||||
"@snowplow/tracker-core": ["@snowplow/tracker-core@4.10.2", "", { "dependencies": { "tslib": "^2.3.1", "uuid": "^11.1.1" } }, "sha512-L49i7OGAC2qQydnWXmKJnypTyVMqCWwfhKjC8Dk9mDsQn1+yzcHs+B4BxZB+vF0pCOrjPfGMWFmQkND4cWsrGA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
@@ -1845,8 +1851,6 @@
|
||||
|
||||
"@types/node": ["@types/node@22.1.0", "", { "dependencies": { "undici-types": "~6.13.0" } }, "sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw=="],
|
||||
|
||||
"@types/node-forge": ["@types/node-forge@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ=="],
|
||||
|
||||
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
|
||||
|
||||
"@types/parse-json": ["@types/parse-json@4.0.0", "", {}, "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="],
|
||||
@@ -1925,7 +1929,7 @@
|
||||
|
||||
"@types/yoga-layout": ["@types/yoga-layout@1.9.2", "", {}, "sha512-S9q47ByT2pPvD65IvrWp7qppVMpk9WGMbVq9wbWZOHg6tnXSD4vyhao6nOSBwwfDdV2p3Kx9evA9vI+XWTfDvw=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="],
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/type-utils": "8.60.0", "@typescript-eslint/utils": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.60.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg=="],
|
||||
|
||||
@@ -1935,7 +1939,7 @@
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.60.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA=="],
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="],
|
||||
|
||||
@@ -1943,7 +1947,7 @@
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.54.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
|
||||
|
||||
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||
|
||||
@@ -4023,8 +4027,6 @@
|
||||
|
||||
"node-fetch-h2": ["node-fetch-h2@2.3.0", "", { "dependencies": { "http2-client": "^1.2.5" } }, "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg=="],
|
||||
|
||||
"node-forge": ["node-forge@1.3.0", "", {}, "sha512-08ARB91bUi6zNKzVmaj3QO7cr397uiDT2nJ63cHjyNtCTWIgvS47j3eT0WfzUwS9+6Z5YshRaoasFkXCKrIYbA=="],
|
||||
|
||||
"node-int64": ["node-int64@0.4.0", "", {}, "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs="],
|
||||
|
||||
"node-notifier": ["node-notifier@9.0.1", "", { "dependencies": { "growly": "^1.3.0", "is-wsl": "^2.2.0", "semver": "^7.3.2", "shellwords": "^0.1.1", "uuid": "^8.3.0", "which": "^2.0.2" } }, "sha512-fPNFIp2hF/Dq7qLDzSg4vZ0J4e9v60gJR+Qx7RbjbWqzPDdEqeVpEx5CFeDAELIl+A/woaaNn1fQ5nEVerMxJg=="],
|
||||
@@ -4713,8 +4715,6 @@
|
||||
|
||||
"secure-keys": ["secure-keys@1.0.0", "", {}, "sha1-8MgtmKOxOah3aogIBQuCRDEIf8o="],
|
||||
|
||||
"selfsigned": ["selfsigned@2.4.1", "", { "dependencies": { "@types/node-forge": "^1.3.0", "node-forge": "^1" } }, "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q=="],
|
||||
|
||||
"semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
@@ -4745,8 +4745,6 @@
|
||||
|
||||
"sha.js": ["sha.js@2.4.12", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" } }, "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w=="],
|
||||
|
||||
"sha1": ["sha1@1.1.1", "", { "dependencies": { "charenc": ">= 0.0.1", "crypt": ">= 0.0.1" } }, "sha1-rdqnqTFo85PxnrKxUJFhjicA+Eg="],
|
||||
|
||||
"shadow-cljs": ["shadow-cljs@3.3.6", "", { "dependencies": { "buffer": "^6.0.3", "process": "^0.11.10", "readline-sync": "^1.4.10", "shadow-cljs-jar": "1.3.4", "source-map-support": "^0.5.21", "which": "^5.0.0", "ws": "^8.18.1" }, "bin": { "shadow-cljs": "cli/runner.js" } }, "sha512-t5TBJmy77abwlkAY7UOXa1s9H/28nTHeDVckgzEVS8ONYtDvbNCHEuBYfS7wwV9wVZVybRNb7dILWNXISJL1Nw=="],
|
||||
|
||||
"shadow-cljs-jar": ["shadow-cljs-jar@1.3.4", "", {}, "sha512-cZB2pzVXBnhpJ6PQdsjO+j/MksR28mv4QD/hP/2y1fsIa9Z9RutYgh3N34FZ8Ktl4puAXaIGlct+gMCJ5BmwmA=="],
|
||||
@@ -5065,7 +5063,7 @@
|
||||
|
||||
"ts-algebra": ["ts-algebra@1.2.2", "", {}, "sha512-kloPhf1hq3JbCPOTYoOWDKxebWjNb2o/LKnNfkWhxVVisFFmMJPPdJeGoGmM+iRLyoXAR61e08Pb+vUXINg8aA=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
|
||||
|
||||
@@ -5595,11 +5593,11 @@
|
||||
|
||||
"@snowplow/browser-tracker-core/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@snowplow/browser-tracker-core/uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="],
|
||||
"@snowplow/browser-tracker-core/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
|
||||
|
||||
"@snowplow/tracker-core/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@snowplow/tracker-core/uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="],
|
||||
"@snowplow/tracker-core/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
|
||||
|
||||
"@storybook/addon-docs/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
@@ -5683,38 +5681,32 @@
|
||||
|
||||
"@types/leaflet/@types/geojson": ["@types/geojson@7946.0.8", "", {}, "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
|
||||
|
||||
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
"@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.54.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.54.0", "@typescript-eslint/tsconfig-utils": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA=="],
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.54.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.54.0", "@typescript-eslint/tsconfig-utils": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||
|
||||
"@visx/axis/classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||
@@ -6803,8 +6795,6 @@
|
||||
|
||||
"typedoc/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/type-utils": "8.60.0", "@typescript-eslint/utils": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA=="],
|
||||
|
||||
"unbox-primitive/which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
@@ -7101,28 +7091,32 @@
|
||||
|
||||
"@types/jest/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="],
|
||||
"@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.54.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.54.0", "@typescript-eslint/types": "^8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g=="],
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.54.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="],
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.54.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.54.0", "@typescript-eslint/types": "^8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.54.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||
|
||||
"@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
|
||||
|
||||
"@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
||||
@@ -7301,8 +7295,6 @@
|
||||
|
||||
"detective-typescript/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"detective-typescript/@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"es-get-iterator/get-intrinsic/has-proto": ["has-proto@1.0.1", "", {}, "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg=="],
|
||||
|
||||
"es-get-iterator/get-intrinsic/hasown": ["hasown@2.0.1", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA=="],
|
||||
@@ -7809,14 +7801,6 @@
|
||||
|
||||
"typed-array-length/is-typed-array/which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
@@ -7993,11 +7977,17 @@
|
||||
|
||||
"@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||
|
||||
@@ -8395,9 +8385,7 @@
|
||||
|
||||
"test-exclude/glob/path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
|
||||
"webpack-dev-middleware/schema-utils/ajv-formats/ajv": ["ajv@8.10.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-bzqAEZOjkrUMl2afH8dknrq5KEk2SrwdBROR+vH1EKVQTqaUbJVPdc/gEdggTMM0Se+s+Ja4ju4TlNcStKl2Hw=="],
|
||||
|
||||
@@ -8495,7 +8483,9 @@
|
||||
|
||||
"@storybook/builder-webpack5/terser-webpack-plugin/schema-utils/ajv-formats/ajv": ["ajv@8.10.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-bzqAEZOjkrUMl2afH8dknrq5KEk2SrwdBROR+vH1EKVQTqaUbJVPdc/gEdggTMM0Se+s+Ja4ju4TlNcStKl2Hw=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
@@ -8677,9 +8667,9 @@
|
||||
|
||||
"test-exclude/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.2", "", { "dependencies": { "jackspeak": "^4.2.3" } }, "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"webpack-dev-middleware/schema-utils/ajv-formats/ajv/uri-js": ["uri-js@4.4.0", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g=="],
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Upload a custom icon. SVG or PNG with a transparent background gives the cleanes
|
||||
|
||||

|
||||
|
||||
Once you've uploaded a custom icon, this toggle appears and controls whether the default decorative illustrations show in the Metabot chat sidebar and on the AI exploration page.
|
||||
This toggle controls whether the default decorative illustrations show in the Metabot chat sidebar.
|
||||
|
||||
## Further reading
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ On self-hosted Metabases, this option determines whether or not you allow [anony
|
||||
|
||||
{% include plans-blockquote.html feature="Collecting user data" %}
|
||||
|
||||
You can switch on logging of IP addresses, user agents, embed path, query parameters, and Metabot conversation metadata for people using your Metabase, both for people logging in directly to Metabase, and for people who view an embedded Metabase component in your app. If enabled, you can find this information in your [usage analytics](../usage-and-performance-tools/usage-analytics.md).
|
||||
You can switch on logging of IP addresses, user agents, embed path, query parameters, the values people submit when they run an action, and Metabot conversation metadata for people using your Metabase, both for people logging in directly to Metabase, and for people who view an embedded Metabase component in your app. If enabled, you can find this information in your [usage analytics](../usage-and-performance-tools/usage-analytics.md).
|
||||
|
||||
By default, collection of user data is turned **off**.
|
||||
|
||||
|
||||
@@ -53,6 +53,12 @@ There are multiple ways to run actions:
|
||||
- From a [public form](./custom.md#make-public) of an action.
|
||||
- From a [button on dashboard](../../../dashboards/actions.md).
|
||||
|
||||
## Auditing actions
|
||||
|
||||
{% include plans-blockquote.html feature="Usage analytics" %}
|
||||
|
||||
Metabase records every action run in the [Query log](../../../usage-and-performance-tools/usage-analytics-reference.md#query-log) model in your Usage analytics collection.
|
||||
|
||||
## Actions change data in tables, which affect models
|
||||
|
||||
Just something to clarify here: actions, even though they are added to models, make their changes to the underlying table that a model queries. Which means that anyone who has access to the underlying table, or to questions or other models based on that table, will be able to see the effects of an action. Tools other than Metabase that are connected to that database will also pick up these changes.
|
||||
|
||||
@@ -153,19 +153,14 @@ export function codeMirrorHelpers<T extends object>(testId: string, extra: T) {
|
||||
return helpers.get().get("[role='textbox']");
|
||||
},
|
||||
value() {
|
||||
// The editor mounts with an empty document and fills it in when the first
|
||||
// format finishes. It can also mount late, because its chunk loads on
|
||||
// demand. Wait for two identical reads before reporting the value, or a
|
||||
// read that lands in either window reports an empty editor.
|
||||
let previous: string | null = null;
|
||||
cy.get(`[data-testid=${testId}] .cm-content`).should(($content) => {
|
||||
const current = textOf($content);
|
||||
const isStable = previous !== null && current === previous;
|
||||
previous = current;
|
||||
expect(isStable, "the editor text is stable").to.be.true;
|
||||
});
|
||||
|
||||
return helpers.get().then(textOf);
|
||||
// `invoke` chains are queries, so a chained assertion re-reads the text on every retry
|
||||
return helpers
|
||||
.get()
|
||||
.invoke("find", ".cm-line")
|
||||
.invoke("toArray")
|
||||
.invoke("filter", (line) => !line.querySelector(".cm-placeholder"))
|
||||
.invoke("map", (line) => line.textContent ?? "")
|
||||
.invoke("join", "\n");
|
||||
},
|
||||
completions() {
|
||||
return cy.get(".cm-tooltip-autocomplete").should("be.visible");
|
||||
@@ -236,18 +231,3 @@ export function codeMirrorValue() {
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of every line in a CodeMirror editor, skipping the placeholder line
|
||||
* that stands in for an empty document.
|
||||
*/
|
||||
function textOf($content: JQuery<HTMLElement>) {
|
||||
const text: string[] = [];
|
||||
$content.find(".cm-line").each((_, line) => {
|
||||
if (line.querySelector(".cm-placeholder")) {
|
||||
return;
|
||||
}
|
||||
text.push(line.textContent ?? "");
|
||||
});
|
||||
return text.join("\n");
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ export const shortcutModal = () =>
|
||||
cy.findByRole("dialog", { name: "Shortcuts" });
|
||||
|
||||
export const openCommandPalette = () => cy.get("body").type("{ctrl+k}{cmd+k}");
|
||||
export const openShortcutModal = () => cy.get("body").type("{shift+?}");
|
||||
|
||||
export const commandPaletteButton = () =>
|
||||
cy
|
||||
.findByTestId("app-bar")
|
||||
|
||||
@@ -164,9 +164,6 @@ export const wrapSyncedCollection = (alias = "syncedCollection", n = 0) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getSyncStatusIndicators = () =>
|
||||
navigationSidebar().findAllByTestId("remote-sync-status");
|
||||
|
||||
export const updateRemoteQuestion = (
|
||||
updateFn: (val: Record<string, any>) => Record<string, any>,
|
||||
assertionsFn?: (val: Record<string, any>) => void,
|
||||
@@ -211,8 +208,6 @@ export const moveCollectionItemToSyncedCollection = (
|
||||
cy.button("Move").click();
|
||||
});
|
||||
|
||||
getSyncStatusIndicators().should("have.length", 1);
|
||||
|
||||
navigationSidebar()
|
||||
.findByRole("treeitem", { name: new RegExp(targetCollection) })
|
||||
.click();
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("scenarios > embedding-sdk > analytics — per-mount component events",
|
||||
});
|
||||
|
||||
// Capture every POST to the proxy into a closure array so we can assert across
|
||||
// the several immediate (bufferSize: 1) posts a single mount produces.
|
||||
// the one or more posts a single mount produces.
|
||||
const interceptAnalyticsProxy = () => {
|
||||
const capturedEvents: SdkEventData[] = [];
|
||||
cy.intercept("POST", "/api/analytics-proxy", (request) => {
|
||||
@@ -108,10 +108,9 @@ describe("scenarios > embedding-sdk > analytics — per-mount component events",
|
||||
cy.findByText("Product ID").should("be.visible");
|
||||
});
|
||||
|
||||
// Beacon and component event are serialized by Snowplow's executingQueue:
|
||||
// event 2 is only sent after event 1's XHR response arrives, so they come
|
||||
// in two separate requests. Wait for both before asserting.
|
||||
cy.wait(["@analyticsProxy", "@analyticsProxy"]);
|
||||
// Snowplow v4 batches same-tick events into one POST, so both events may share a request.
|
||||
// Wait for the first request; the retrying assertions below cover the rest.
|
||||
cy.wait("@analyticsProxy");
|
||||
|
||||
cy.wrap(capturedEvents).should((events: SdkEventData[]) => {
|
||||
const componentEvent = findEventByComponent(events, "StaticQuestion");
|
||||
@@ -208,8 +207,8 @@ describe("scenarios > embedding-sdk > analytics — per-mount component events",
|
||||
});
|
||||
|
||||
// beacon already fired in test 1 (once per JS context);
|
||||
// InteractiveQuestion + CollectionBrowser = 2 component events
|
||||
cy.wait(["@analyticsProxy", "@analyticsProxy"]);
|
||||
// InteractiveQuestion + CollectionBrowser = 2 component events, possibly in one POST
|
||||
cy.wait("@analyticsProxy");
|
||||
|
||||
cy.wrap(capturedEvents).should((events: SdkEventData[]) => {
|
||||
const interactiveQuestionEvent = findEventByComponent(
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { InteractiveDashboard } from "@metabase/embedding-sdk-react";
|
||||
import {
|
||||
EditableDashboard,
|
||||
InteractiveDashboard,
|
||||
type MetabasePluginsConfig,
|
||||
} from "@metabase/embedding-sdk-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
@@ -111,7 +115,7 @@ describe("scenarios > embedding-sdk > plugins", () => {
|
||||
mountSdkContent(<InteractiveDashboard dashboardId={dashboardId} />, {
|
||||
sdkProviderProps: {
|
||||
pluginsConfig: {
|
||||
mapQuestionClickActions: (clickActions: ClickAction[]) => [
|
||||
mapQuestionClickActions: (clickActions) => [
|
||||
...clickActions,
|
||||
CUSTOM_ACTION_WITH_VIEW,
|
||||
],
|
||||
@@ -212,6 +216,102 @@ describe("scenarios > embedding-sdk > plugins", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("The `mapQuestionClickActions` plugin should work for `EditableDashboard`", () => {
|
||||
beforeEach(() => {
|
||||
setup(() => {
|
||||
createDashboardWithQuestions({
|
||||
dashboardName: "Orders in a dashboard",
|
||||
questions: [BASE_QUESTION],
|
||||
cards: [
|
||||
{
|
||||
size_x: 12,
|
||||
col: 0,
|
||||
},
|
||||
],
|
||||
}).then(({ dashboard }) => {
|
||||
cy.wrap(dashboard.id).as("dashboardId");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should open a click actions popover with a custom item from component plugins", () => {
|
||||
cy.get<string>("@dashboardId").then((dashboardId) => {
|
||||
mountSdkContent(
|
||||
<EditableDashboard
|
||||
dashboardId={dashboardId}
|
||||
plugins={{
|
||||
mapQuestionClickActions: (clickActions: ClickAction[]) => [
|
||||
...clickActions,
|
||||
CUSTOM_ACTION_WITH_VIEW,
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByText("Facebook").click();
|
||||
|
||||
cy.findByTestId("click-actions-popover").within(() => {
|
||||
cy.findByText("Custom element").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("click-actions-popover").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should open a click actions popover with a custom item from global plugins", () => {
|
||||
cy.get<string>("@dashboardId").then((dashboardId) => {
|
||||
mountSdkContent(<EditableDashboard dashboardId={dashboardId} />, {
|
||||
sdkProviderProps: {
|
||||
pluginsConfig: {
|
||||
mapQuestionClickActions: (clickActions) => [
|
||||
...clickActions,
|
||||
CUSTOM_ACTION_WITH_VIEW,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByText("Facebook").click();
|
||||
|
||||
cy.findByTestId("click-actions-popover").within(() => {
|
||||
cy.findByText("Custom element").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("click-actions-popover").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should open a click actions popover with a custom item when the same plugins are passed to both the provider and the component", () => {
|
||||
cy.get<string>("@dashboardId").then((dashboardId) => {
|
||||
const plugins: MetabasePluginsConfig = {
|
||||
mapQuestionClickActions: (clickActions) => [
|
||||
...clickActions,
|
||||
CUSTOM_ACTION_WITH_VIEW,
|
||||
],
|
||||
};
|
||||
|
||||
mountSdkContent(
|
||||
<EditableDashboard dashboardId={dashboardId} plugins={plugins} />,
|
||||
{ sdkProviderProps: { pluginsConfig: plugins } },
|
||||
);
|
||||
});
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByText("Facebook").click();
|
||||
|
||||
cy.findByTestId("click-actions-popover").within(() => {
|
||||
cy.findByText("Custom element").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("click-actions-popover").should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("The `mapQuestionClickActions` plugin should work for `InteractiveQuestion`", () => {
|
||||
beforeEach(() => {
|
||||
setup(() => {
|
||||
@@ -225,7 +325,7 @@ describe("scenarios > embedding-sdk > plugins", () => {
|
||||
it("should open a click actions popover with a custom item", () => {
|
||||
mountInteractiveQuestion({
|
||||
plugins: {
|
||||
mapQuestionClickActions: (clickActions: ClickAction[]) => [
|
||||
mapQuestionClickActions: (clickActions) => [
|
||||
...clickActions,
|
||||
CUSTOM_ACTION_WITH_VIEW,
|
||||
],
|
||||
|
||||
@@ -70,9 +70,6 @@ describe("Remote Sync", () => {
|
||||
});
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
// Ensure that status icon is present
|
||||
H.getSyncStatusIndicators().should("have.length.greaterThan", 0);
|
||||
H.navigationSidebar()
|
||||
.findByRole("link", { name: /Test Synced Collection/ })
|
||||
.click();
|
||||
@@ -91,11 +88,6 @@ describe("Remote Sync", () => {
|
||||
triggered_from: "app-bar",
|
||||
});
|
||||
|
||||
H.navigationSidebar()
|
||||
.findByRole("link", { name: /Test Synced Collection/ })
|
||||
.findByTestId("remote-sync-status")
|
||||
.should("not.exist");
|
||||
|
||||
H.updateRemoteQuestion(
|
||||
(doc) => {
|
||||
doc.name = UPDATED_REMOTE_QUESTION_NAME;
|
||||
@@ -129,8 +121,6 @@ describe("Remote Sync", () => {
|
||||
|
||||
cy.visit("/collection/root");
|
||||
|
||||
H.getSyncStatusIndicators().should("have.length", 0);
|
||||
|
||||
const expectedError = "Uses content that is not remote synced.";
|
||||
|
||||
cy.log("Test moving via 'Move' modal");
|
||||
@@ -167,8 +157,6 @@ describe("Remote Sync", () => {
|
||||
H.entityPickerModalItem(1, "Test Synced Collection").click();
|
||||
cy.button("Move").click();
|
||||
});
|
||||
|
||||
H.getSyncStatusIndicators().should("have.length", 1);
|
||||
});
|
||||
|
||||
it("should show a warning modal when you try to push but are out of date", () => {
|
||||
@@ -1056,57 +1044,12 @@ describe("Remote Sync", () => {
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
// Verify sync status indicator appears
|
||||
H.getSyncStatusIndicators().should("have.length.greaterThan", 0);
|
||||
|
||||
// Push changes
|
||||
H.clickPushOption();
|
||||
H.modal()
|
||||
.button(/Push changes/)
|
||||
.click();
|
||||
H.waitForTask({ taskName: "export" });
|
||||
|
||||
// Verify changes were pushed (status indicator should clear)
|
||||
H.navigationSidebar()
|
||||
.findByRole("link", { name: /Syncable Tenant Collection/ })
|
||||
.findByTestId("remote-sync-status")
|
||||
.should("not.exist");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("shows sync status badge on synced tenant collections in sidebar", () => {
|
||||
H.configureGitAndPullChanges("read-write");
|
||||
|
||||
// Create a tenant collection
|
||||
H.createSharedTenantCollection("Badge Test Collection").then(
|
||||
(response) => {
|
||||
const collectionId = response.body.id;
|
||||
|
||||
// Enable sync
|
||||
cy.visit("/admin/settings/remote-sync");
|
||||
// Mantine Switch has a hidden input (0x0 pixels), so we need force: true
|
||||
cy.findByTestId("admin-layout-content")
|
||||
.findByRole("switch", { name: "Sync Badge Test Collection" })
|
||||
.click({ force: true });
|
||||
cy.findByTestId("admin-layout-content")
|
||||
.button("Save changes")
|
||||
.click();
|
||||
|
||||
// Create content to trigger dirty state
|
||||
H.createQuestion({
|
||||
name: "Status Badge Test Question",
|
||||
query: { "source-table": PRODUCTS_ID },
|
||||
collection_id: collectionId,
|
||||
});
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
// Verify the sync status badge appears on the tenant collection
|
||||
H.navigationSidebar()
|
||||
.findByRole("treeitem", { name: /Badge Test Collection/ })
|
||||
.findByTestId("remote-sync-status")
|
||||
.should("exist");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
const { H } = cy;
|
||||
import { USER_GROUPS, WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { FIRST_COLLECTION_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import { FIXTURE_PATH, VALID_CSV_FILES } from "e2e/support/helpers";
|
||||
|
||||
const { NOSQL_GROUP, ALL_USERS_GROUP } = USER_GROUPS;
|
||||
|
||||
describe("CSV Uploading", { tags: ["@external", "@actions"] }, () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
@@ -260,82 +258,6 @@ describe("CSV Uploading", { tags: ["@external", "@actions"] }, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissions", { tags: "@external" }, () => {
|
||||
it("should not show you upload buttons if you are a sandboxed user", () => {
|
||||
H.restore("postgres-12");
|
||||
cy.signInAsAdmin();
|
||||
|
||||
H.activateToken("pro-self-hosted");
|
||||
H.enableUploads("postgres");
|
||||
|
||||
//Deny access for all users to writable DB
|
||||
cy.updatePermissionsGraph({
|
||||
1: {
|
||||
[WRITABLE_DB_ID]: {
|
||||
"view-data": "blocked",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
cy.request("GET", `/api/database/${WRITABLE_DB_ID}/schema/public`).then(
|
||||
({ body: tables }) => {
|
||||
cy.request("GET", `/api/database/${WRITABLE_DB_ID}/fields`).then(
|
||||
({ body: fields }) => {
|
||||
// Sandbox a table so that the sandboxed user will have read access to a table
|
||||
cy.sandboxTable({
|
||||
table_id: tables[0].id,
|
||||
attribute_remappings: {
|
||||
attr_uid: ["dimension", ["field", fields[0].id, null]],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
cy.signInAsSandboxedUser();
|
||||
cy.visit("/collection/root");
|
||||
// No upload icon should appear for the sandboxed user
|
||||
cy.findByTestId("collection-menu").within(() => {
|
||||
cy.get(".Icon-calendar").should("exist");
|
||||
cy.findByLabelText("Upload data").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show you upload buttons if you have unrestricted access to the upload schema", () => {
|
||||
H.restore("postgres-12");
|
||||
cy.signInAsAdmin();
|
||||
|
||||
H.activateToken("pro-self-hosted");
|
||||
H.enableUploads("postgres");
|
||||
|
||||
cy.updatePermissionsGraph({
|
||||
[ALL_USERS_GROUP]: {
|
||||
[WRITABLE_DB_ID]: {
|
||||
"view-data": "blocked",
|
||||
},
|
||||
},
|
||||
[NOSQL_GROUP]: {
|
||||
[WRITABLE_DB_ID]: {
|
||||
"view-data": "unrestricted",
|
||||
"create-queries": "query-builder",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
cy.updateCollectionGraph({
|
||||
[NOSQL_GROUP]: { root: "write" },
|
||||
});
|
||||
|
||||
cy.signIn("nosql");
|
||||
cy.visit("/collection/root");
|
||||
cy.findByTestId("collection-menu").within(() => {
|
||||
cy.findByLabelText("Upload data").should("exist");
|
||||
cy.findByRole("img", { name: /upload/i }).should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Upload Table Cleanup/Management", { tags: "@external" }, () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept("GET", "/api/ee/upload-management/tables").as(
|
||||
|
||||
@@ -6,35 +6,6 @@ import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
|
||||
const { PRODUCTS, PRODUCTS_ID, ORDERS, ORDERS_ID } = SAMPLE_DATABASE;
|
||||
|
||||
describe("issue 54638", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsNormalUser();
|
||||
H.openOrdersTable({ mode: "notebook" });
|
||||
H.addCustomColumn();
|
||||
});
|
||||
|
||||
it("should be possible to click documentation links in the expression editor help text popover (metabase#54638)", () => {
|
||||
H.CustomExpressionEditor.type("case(");
|
||||
H.CustomExpressionEditor.helpText().within(() => {
|
||||
cy.findByText("Learn more")
|
||||
.scrollIntoView()
|
||||
.should("be.visible")
|
||||
.then(($a) => {
|
||||
expect($a).to.have.attr("target", "_blank");
|
||||
// Update attr to open in same tab, since Cypress does not support
|
||||
// testing in multiple tabs.
|
||||
$a.attr("target", "_self");
|
||||
})
|
||||
.click();
|
||||
cy.url().should(
|
||||
"equal",
|
||||
"https://www.metabase.com/docs/latest/questions/query-builder/expressions/case.html",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue #54722", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
|
||||
@@ -170,7 +170,7 @@ describe("scenarios > data studio > library", () => {
|
||||
});
|
||||
|
||||
describe("Library collection management", () => {
|
||||
it("should create a new library collection from the New button", () => {
|
||||
it("should create a top-level library folder from the New button", () => {
|
||||
H.createLibrary();
|
||||
H.createCollection({ name: "Outside Library" });
|
||||
H.DataStudio.Library.visit();
|
||||
@@ -178,11 +178,15 @@ describe("scenarios > data studio > library", () => {
|
||||
cy.intercept("POST", "/api/collection").as("createCollection");
|
||||
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("Collection").click();
|
||||
H.popover().findByText("Folder").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").type("New Library Collection");
|
||||
cy.findByTestId("collection-picker-button").should("contain", "Data");
|
||||
cy.findByLabelText("Name").type("New Library Folder");
|
||||
cy.log("a new folder goes to the top level of the Library by default");
|
||||
cy.findByTestId("collection-picker-button").should(
|
||||
"contain",
|
||||
"Library",
|
||||
);
|
||||
cy.findByTestId("collection-picker-button").click();
|
||||
});
|
||||
|
||||
@@ -197,13 +201,46 @@ describe("scenarios > data studio > library", () => {
|
||||
H.modal().button("Create").click();
|
||||
cy.wait("@createCollection").then(({ response }) => {
|
||||
expect(response?.statusCode).to.equal(200);
|
||||
expect(response?.body.type).to.equal("library");
|
||||
});
|
||||
|
||||
H.DataStudio.Library.collectionItem("New Library Collection").should(
|
||||
H.DataStudio.Library.collectionItem("New Library Folder").should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
|
||||
it("should create a subfolder from a library section's row menu and give it a custom icon", () => {
|
||||
H.createLibrary();
|
||||
H.DataStudio.Library.visit();
|
||||
|
||||
cy.intercept("POST", "/api/collection").as("createCollection");
|
||||
|
||||
cy.log(
|
||||
"the seeded Data section offers New folder but not rename/archive",
|
||||
);
|
||||
H.DataStudio.Library.collectionItem("Data")
|
||||
.findByLabelText("Collection options")
|
||||
.click();
|
||||
H.popover().findByText("Edit collection details").should("not.exist");
|
||||
H.popover().findByText("Archive").should("not.exist");
|
||||
H.popover().findByText("New folder").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").type("Warehouse");
|
||||
cy.findByLabelText("Icon").click();
|
||||
});
|
||||
H.popover().findByLabelText("gem").click();
|
||||
H.modal().button("Create").click();
|
||||
|
||||
cy.wait("@createCollection").then(({ request, response }) => {
|
||||
expect(request.body.icon).to.equal("gem");
|
||||
expect(response?.statusCode).to.equal(200);
|
||||
expect(response?.body.type).to.equal("library-data");
|
||||
});
|
||||
|
||||
H.DataStudio.Library.collectionItem("Warehouse").should("be.visible");
|
||||
});
|
||||
|
||||
it("should edit a library collection name and description", () => {
|
||||
H.createLibrary();
|
||||
getLibraryRootCollections().then(({ dataCollection }) => {
|
||||
|
||||
@@ -258,7 +258,7 @@ describe("scenarios > data studio > snippets", () => {
|
||||
H.DataStudio.Library.visit();
|
||||
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("Collection").click();
|
||||
H.popover().findByText("Folder").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").type("Test Folder");
|
||||
|
||||
@@ -234,7 +234,7 @@ describe("scenarios > embedding > dashboard parameters", () => {
|
||||
getDashboardFilter("Name").click();
|
||||
H.toggleRequiredParameter();
|
||||
H.sidebar().findByText("Default value").next().click();
|
||||
addWidgetStringFilter("Ferne Rosenbaum", {
|
||||
setRequiredParameterDefaultValue("Ferne Rosenbaum", {
|
||||
buttonLabel: "Update filter",
|
||||
});
|
||||
H.saveDashboard();
|
||||
@@ -1485,6 +1485,53 @@ function getDashboardFilter(name) {
|
||||
.findByText(name);
|
||||
}
|
||||
|
||||
// The default-value picker for a field-based parameter can render as either
|
||||
// a search combobox or a checkbox list, depending on the field's values
|
||||
// configuration; handle both so the test isn't sensitive to which one shows.
|
||||
function setRequiredParameterDefaultValue(value, { buttonLabel } = {}) {
|
||||
// The widget can still be loading (neither mode rendered yet) right after
|
||||
// the popover opens; wait until one of them actually shows up before
|
||||
// deciding which branch to take.
|
||||
H.popover()
|
||||
.first()
|
||||
.should(($popover) => {
|
||||
const hasList =
|
||||
$popover.find('[placeholder="Search the list"]').length > 0;
|
||||
const hasCombobox = $popover.find("input").length > 0;
|
||||
expect(hasList || hasCombobox, "default value widget to be ready").to.be
|
||||
.true;
|
||||
});
|
||||
|
||||
cy.get("body").then(($body) => {
|
||||
if ($body.find('[placeholder="Search the list"]').length > 0) {
|
||||
H.popover()
|
||||
.first()
|
||||
.within(() => {
|
||||
// The list can still be settling (e.g. a background values fetch
|
||||
// replacing the initial options) right as we click, so retry the
|
||||
// click itself until the checkbox actually ends up checked.
|
||||
checkFilterValueCheckbox(value);
|
||||
cy.button(buttonLabel).click();
|
||||
});
|
||||
} else {
|
||||
addWidgetStringFilter(value, { buttonLabel });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkFilterValueCheckbox(value, attempt = 0) {
|
||||
cy.findByTestId(`${value}-filter-value`).then(($checkbox) => {
|
||||
if ($checkbox.prop("checked")) {
|
||||
return;
|
||||
}
|
||||
if (attempt > 5) {
|
||||
throw new Error(`Could not check the "${value}" filter value checkbox`);
|
||||
}
|
||||
cy.wrap($checkbox).click({ force: true });
|
||||
checkFilterValueCheckbox(value, attempt + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function assertRequiredEnabledForName({ name, enabled }) {
|
||||
getDashboardFilter(name).click();
|
||||
H.getRequiredToggle().should(enabled ? "be.enabled" : "not.be.enabled");
|
||||
|
||||
@@ -133,67 +133,4 @@ describe("scenarios > sidecar > tenant users", () => {
|
||||
|
||||
H.entityPickerModal().findByText("Our data").should("be.visible");
|
||||
});
|
||||
|
||||
it("tenant users should not see the synced collection icons", () => {
|
||||
cy.signInAsAdmin();
|
||||
|
||||
// Setup git sync
|
||||
H.setupGitSync();
|
||||
H.copySyncedCollectionFixture();
|
||||
H.commitToRepo();
|
||||
|
||||
// Make shared tenant collections synced
|
||||
cy.get<number>("@sharedCollection1Id").then((id1) => {
|
||||
cy.get<number>("@sharedCollection2Id").then((id2) => {
|
||||
const collectionsConfig = {
|
||||
[id1]: true,
|
||||
[id2]: true,
|
||||
};
|
||||
|
||||
cy.request("PUT", "/api/ee/remote-sync/settings", {
|
||||
collections: collectionsConfig,
|
||||
"remote-sync-branch": "main",
|
||||
"remote-sync-type": "read-write",
|
||||
"remote-sync-url": H.LOCAL_GIT_PATH + "/.git",
|
||||
"remote-sync-enabled": true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cy.signOut();
|
||||
|
||||
loginWithJWT(GIZMO_USER);
|
||||
|
||||
// Check sidebar
|
||||
H.navigationSidebar().within(() => {
|
||||
// Synced collections should show a regular folder icon for tenant users
|
||||
cy.findByText("Shared tenant collection 1")
|
||||
.closest("li")
|
||||
.icon("folder")
|
||||
.should("be.visible");
|
||||
|
||||
cy.findByText("Shared tenant collection 1")
|
||||
.closest("li")
|
||||
.icon("synced_collection")
|
||||
.should("not.exist");
|
||||
});
|
||||
|
||||
// Check picker
|
||||
H.newButton().click();
|
||||
H.popover().findByText("Question").click();
|
||||
H.popover().findByText("Orders").click();
|
||||
H.main().findByText("Save").click();
|
||||
H.modal()
|
||||
.findByLabelText(/Where do you want to save this/)
|
||||
.click();
|
||||
|
||||
H.entityPickerModal().within(() => {
|
||||
cy.findByText("Shared collections").click();
|
||||
|
||||
cy.findByText("Shared tenant collection 1")
|
||||
.closest("a")
|
||||
.icon("folder")
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,7 +137,7 @@ describe("AI Controls > Metabot access and customization", () => {
|
||||
cy.findByLabelText("AI agent's name").should("have.value", "HAL 9000");
|
||||
});
|
||||
|
||||
it("should upload a custom Metabot icon and show the illustrations section", () => {
|
||||
it("should upload a custom Metabot icon", () => {
|
||||
cy.intercept("PUT", "/api/setting/metabot-icon").as("saveIcon");
|
||||
|
||||
cy.visit("/admin/metabot/customization");
|
||||
@@ -147,9 +147,6 @@ describe("AI Controls > Metabot access and customization", () => {
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
// The illustrations section is hidden until a custom icon is set
|
||||
H.main().findByText("Metabot illustrations").should("not.exist");
|
||||
|
||||
// Upload a tiny PNG via the hidden file input
|
||||
cy.get('input[type="file"]').selectFile(
|
||||
{
|
||||
@@ -165,20 +162,12 @@ describe("AI Controls > Metabot access and customization", () => {
|
||||
|
||||
cy.wait("@saveIcon").its("response.statusCode").should("eq", 204);
|
||||
|
||||
// The illustrations section should now appear
|
||||
H.main().findByText("Metabot illustrations").should("be.visible");
|
||||
cy.findByRole("switch", { name: /Show Metabot illustrations/ })
|
||||
.parent()
|
||||
.should("be.visible");
|
||||
|
||||
// The "Remove custom icon" button should be visible
|
||||
cy.findByLabelText("Remove custom icon").should("be.visible");
|
||||
});
|
||||
|
||||
it("should hide Metabot illustrations when the toggle is switched off", () => {
|
||||
// Set a custom icon via API so the illustrations toggle is visible
|
||||
H.updateEnterpriseSettings({
|
||||
"metabot-icon": TINY_PNG_DATA_URI,
|
||||
"metabot-show-illustrations": true,
|
||||
});
|
||||
H.updateSetting("metabot-enabled?", true);
|
||||
@@ -209,10 +198,7 @@ describe("AI Controls > Metabot access and customization", () => {
|
||||
|
||||
// Navigate to the home page and open Metabot chat to verify illustrations are hidden
|
||||
cy.visit("/");
|
||||
// Can't use H.openMetabotViaSearchButton() because custom icon replaces .Icon-metabot
|
||||
H.appBar()
|
||||
.findByRole("button", { name: /Chat with/ })
|
||||
.click();
|
||||
H.openMetabotViaSearchButton();
|
||||
|
||||
cy.findByTestId("metabot-empty-chat-info").should("be.visible");
|
||||
// The SVG illustration should NOT be rendered when showIllustrations=false
|
||||
|
||||
@@ -87,8 +87,7 @@ describe("scenarios > metrics > metric page", () => {
|
||||
H.expectNoBadSnowplowEvents();
|
||||
});
|
||||
|
||||
it("should display scalar metric, edit name and description, explore link, and more menu actions", () => {
|
||||
cy.intercept("PUT", "/api/card/*").as("updateCard");
|
||||
it("should display scalar metric with read-only name and description, explore link, and more menu actions", () => {
|
||||
cy.intercept("POST", "/api/card").as("createCard");
|
||||
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: metric }) => {
|
||||
@@ -121,34 +120,25 @@ describe("scenarios > metrics > metric page", () => {
|
||||
.should("have.attr", "href")
|
||||
.and("include", "/explore");
|
||||
|
||||
cy.log("edit description");
|
||||
H.MetricPage.aboutPageDescriptionSidebar().within(() => {
|
||||
cy.findByText("Total number of orders").click();
|
||||
cy.log("name and description are read-only in the main app");
|
||||
H.MetricPage.aboutPage().within(() => {
|
||||
cy.findByTestId("metric-header")
|
||||
.findByText("Orders count")
|
||||
.should("be.visible");
|
||||
cy.findByDisplayValue("Orders count").should("not.exist");
|
||||
});
|
||||
cy.focused().clear().type("Updated description").blur();
|
||||
cy.wait("@updateCard");
|
||||
H.MetricPage.aboutPageDescriptionSidebar()
|
||||
.findByText("Updated description")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("edit name inline");
|
||||
H.MetricPage.aboutPage()
|
||||
.findByDisplayValue("Orders count")
|
||||
.clear()
|
||||
.type("Renamed metric{enter}");
|
||||
cy.wait("@updateCard");
|
||||
H.MetricPage.aboutPage()
|
||||
.findByDisplayValue("Renamed metric")
|
||||
.should("be.visible");
|
||||
.findByTestId("editable-text")
|
||||
.should("not.exist");
|
||||
|
||||
cy.log("duplicate via more menu");
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByText("Duplicate").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name")
|
||||
.should("have.value", "Renamed metric - Duplicate")
|
||||
.should("have.value", "Orders count - Duplicate")
|
||||
.clear()
|
||||
.type("Renamed metric copy");
|
||||
.type("Orders count copy");
|
||||
cy.button("Duplicate").click();
|
||||
});
|
||||
cy.wait("@createCard");
|
||||
|
||||
@@ -170,6 +170,8 @@ describe("issue 14636", () => {
|
||||
const filteredTask = createMockTask({ task });
|
||||
|
||||
// Keep this test independent of background tasks created asynchronously by H.restore().
|
||||
// The task picker only shows a value that is in the unique task list.
|
||||
cy.intercept("GET", "/api/task/unique-tasks", [task]);
|
||||
// Register the less-specific routes first because Cypress matches intercepts in reverse order.
|
||||
stubFilteredResponse({
|
||||
status: "success",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { H } = cy;
|
||||
import { SAMPLE_DB_ID, USERS } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DB_ID, USERS, USER_GROUPS } from "e2e/support/cypress_data";
|
||||
import {
|
||||
ADMIN_PERSONAL_COLLECTION_ID,
|
||||
ORDERS_BY_YEAR_QUESTION_ID,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "metabase-types/api/mocks";
|
||||
|
||||
const { admin } = USERS;
|
||||
const { ALL_USERS_GROUP } = USER_GROUPS;
|
||||
|
||||
const TAB_1 = {
|
||||
id: 1,
|
||||
@@ -67,6 +68,14 @@ describe("command palette", () => {
|
||||
description: "The best question",
|
||||
});
|
||||
|
||||
// Create a document so that it appears in the recents list. It goes first so
|
||||
// the dashboard below stays the most recent, and therefore selected, entry.
|
||||
cy.request(
|
||||
"POST",
|
||||
"/api/document",
|
||||
createMockDocument({ collection_id: ADMIN_PERSONAL_COLLECTION_ID }),
|
||||
);
|
||||
|
||||
//Request to have an item in the recents list
|
||||
cy.request(`/api/dashboard/${ORDERS_DASHBOARD_ID}`);
|
||||
|
||||
@@ -79,21 +88,20 @@ describe("command palette", () => {
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
|
||||
// UXW-1786
|
||||
cy.findByRole("option", { name: "Test Document" }).should(
|
||||
"contain.text",
|
||||
"Bobby Tables's Personal Collection",
|
||||
);
|
||||
|
||||
H.closeCommandPalette();
|
||||
H.commandPalette().should("not.exist");
|
||||
|
||||
cy.log("open the command palette with keybinding");
|
||||
H.openCommandPalette();
|
||||
H.commandPalette().within(() => {
|
||||
H.commandPaletteInput().should("exist");
|
||||
|
||||
cy.log("does not show actions if there is no search query");
|
||||
cy.findByText("New question").should("not.exist");
|
||||
cy.findByText("New SQL query").should("not.exist");
|
||||
cy.findByText("New dashboard").should("not.exist");
|
||||
cy.findByText("New collection").should("not.exist");
|
||||
cy.findByText("New model").should("not.exist");
|
||||
cy.findByText("New metric").should("not.exist");
|
||||
H.commandPaletteInput().should("be.visible");
|
||||
|
||||
cy.log("Should show recent items");
|
||||
cy.findByRole("option", { name: "Orders in a dashboard" }).should(
|
||||
@@ -115,35 +123,43 @@ describe("command palette", () => {
|
||||
// Since the command palette list is virtualized, we will search for a few
|
||||
// to ensure they're reachable
|
||||
H.commandPaletteInput().clear().type("People");
|
||||
cy.findByRole("option", { name: "People" }).should("exist");
|
||||
cy.findByRole("option", { name: "People" }).should("be.visible");
|
||||
|
||||
H.commandPaletteInput().clear().type("Uploads");
|
||||
cy.findByRole("option", { name: "Settings - Uploads" }).should("exist");
|
||||
|
||||
// When entering a query, if there are results that come before search results, highlight
|
||||
// the first action, otherwise, highlight the first search result
|
||||
H.commandPaletteInput().clear().type("Form");
|
||||
cy.findByRole("option", { name: "Performance" }).should(
|
||||
"have.attr",
|
||||
"aria-selected",
|
||||
"true",
|
||||
cy.findByRole("option", { name: "Settings - Uploads" }).should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.findByRole("option", { name: /View and filter/ }).should("exist");
|
||||
|
||||
// Check that we are not filtering search results by action name
|
||||
H.commandPaletteInput().clear().type("Company");
|
||||
cy.findByRole("option", { name: /View and filter/ }).should("exist");
|
||||
cy.findByRole("option", { name: /View and filter/ }).should("be.visible");
|
||||
cy.findByRole("option", { name: "Products" }).should(
|
||||
"have.attr",
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
cy.findByRole("option", { name: "People" }).should("exist");
|
||||
cy.findByRole("option", { name: "Reviews" }).should("exist");
|
||||
H.commandPaletteInput().clear();
|
||||
cy.findByRole("option", { name: "People" }).should("be.visible");
|
||||
cy.findByRole("option", { name: "Reviews" }).should("be.visible");
|
||||
|
||||
H.commandPaletteInput().clear().type("New met");
|
||||
cy.findByText("New metric").should("exist");
|
||||
cy.log("search results render in the order the API returned them");
|
||||
// Registered here, not at the top of the test: the alias yields the last
|
||||
// matching request, and the searches above would shadow this one.
|
||||
cy.intercept("/api/search?*").as("searchData");
|
||||
H.commandPaletteInput().clear().type("Cou");
|
||||
cy.wait("@searchData");
|
||||
cy.findByText("Loading...").should("not.exist");
|
||||
|
||||
cy.get("@searchData").then(({ response }) => {
|
||||
const results = response.body.data;
|
||||
|
||||
cy.findAllByRole("option")
|
||||
// filter out unrelated items, keep only options with data
|
||||
.invoke("slice", 3, -2)
|
||||
.should("have.length", results.length)
|
||||
.each(($option, index) => {
|
||||
cy.wrap($option).should("contain", results[index].name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cy.log("We can close the command palette using escape");
|
||||
@@ -162,8 +178,7 @@ describe("command palette", () => {
|
||||
.should("not.exist");
|
||||
H.commandPalette().findByText("No results for “New”").should("be.visible");
|
||||
|
||||
// Every "New …" action matches "New" equally, so the default order applies
|
||||
// and "New question" is first and selected by default.
|
||||
cy.log("the virtualized list is navigable by keyboard");
|
||||
H.commandPalette()
|
||||
.findByRole("option", { name: "New question" })
|
||||
.should("have.attr", "aria-selected", "true");
|
||||
@@ -200,172 +215,69 @@ describe("command palette", () => {
|
||||
.should("have.attr", "aria-selected", "true");
|
||||
});
|
||||
|
||||
it("should display search results in the order returned by the API", () => {
|
||||
cy.visit("/");
|
||||
it("should render admin links for non-admins that have specific privileges", () => {
|
||||
cy.log("setup permissions");
|
||||
|
||||
cy.findByRole("button", { name: /search/i }).click();
|
||||
cy.intercept("/api/search?*").as("searchData");
|
||||
H.activateToken("pro-self-hosted");
|
||||
|
||||
H.commandPalette().within(() => {
|
||||
H.commandPaletteInput().type("Cou");
|
||||
cy.wait("@searchData");
|
||||
cy.findByText("Loading...").should("not.exist");
|
||||
|
||||
cy.get("@searchData").then(({ response }) => {
|
||||
const results = response.body.data;
|
||||
|
||||
cy.findAllByRole("option")
|
||||
// filter out unrelated items, keep only options with data
|
||||
.invoke("slice", 3, -2)
|
||||
.should("have.length", results.length)
|
||||
.each(($option, index) => {
|
||||
cy.wrap($option).should("contain", results[index].name);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Making this a separate test for now because it requires the bleeding edge token, which
|
||||
// Enables a bunch of other stuff and messes up the "Renders a searchable command palette"
|
||||
// test. In the future, this can be integrated into the test above, or moved to a BE test
|
||||
it("should display collection names for documents in recents", () => {
|
||||
//Create a document so that it appears in the recents list
|
||||
cy.request(
|
||||
"POST",
|
||||
"/api/document",
|
||||
createMockDocument({ collection_id: ADMIN_PERSONAL_COLLECTION_ID }),
|
||||
);
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
cy.findByRole("button", { name: /search/i }).click();
|
||||
H.commandPalette().should("be.visible");
|
||||
|
||||
// UXW-1786
|
||||
cy.findByRole("option", { name: "Test Document" }).should(
|
||||
"contain.text",
|
||||
"Bobby Tables's Personal Collection",
|
||||
);
|
||||
});
|
||||
|
||||
describe("admin settings links", () => {
|
||||
it("should render links to all admin settings pages for admins", () => {
|
||||
cy.visit("/");
|
||||
cy.findByTestId("home-page")
|
||||
.findByText(/see what metabase can do/i)
|
||||
.should("exist");
|
||||
|
||||
H.openCommandPalette();
|
||||
H.commandPalette().within(() => {
|
||||
H.commandPaletteInput().type("Settings -");
|
||||
cy.log("check admin sees all settings links");
|
||||
H.commandPaletteAction("Settings - General").should("be.visible");
|
||||
H.commandPaletteAction("Settings - Email").should("be.visible");
|
||||
H.commandPaletteInput().clear();
|
||||
|
||||
cy.log("should see admin links");
|
||||
H.commandPaletteInput().type("Performance");
|
||||
H.commandPaletteAction("Performance").should("be.visible");
|
||||
});
|
||||
H.updateAdvancedPermissionsGraph({
|
||||
[ALL_USERS_GROUP]: { setting: "yes", monitoring: "yes" },
|
||||
});
|
||||
|
||||
it("should not render any links to settings or admin pages for non-admins without privledged access", () => {
|
||||
cy.signInAsNormalUser();
|
||||
cy.visit("/");
|
||||
cy.findByTestId("home-page")
|
||||
.findByText(/see what metabase can do/i)
|
||||
.should("exist");
|
||||
|
||||
H.openCommandPalette();
|
||||
H.commandPalette().within(() => {
|
||||
cy.log("check normal user does not see any setting links");
|
||||
H.commandPaletteInput().type("Settings -");
|
||||
H.commandPaletteAction("Settings - Setup").should("not.exist");
|
||||
H.commandPaletteAction("Settings - General").should("not.exist");
|
||||
H.commandPaletteInput().clear();
|
||||
|
||||
cy.log("should not see admin links");
|
||||
H.commandPaletteInput().type("Performance");
|
||||
H.commandPaletteAction("Performance").should("not.exist");
|
||||
H.commandPaletteInput().clear();
|
||||
|
||||
// Monitor tools live outside the Admin command-palette links
|
||||
H.commandPaletteInput().clear().type("tool");
|
||||
H.commandPaletteAction("Tools").should("not.exist");
|
||||
H.commandPaletteInput().clear();
|
||||
|
||||
//Database and table metadata
|
||||
|
||||
H.commandPaletteInput().type("data");
|
||||
H.commandPaletteAction("Databases").should("not.exist");
|
||||
H.commandPaletteInput().clear().type("tabl");
|
||||
H.commandPaletteAction("Table Metadata").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with advanced permissions", () => {
|
||||
it("should render links for non-admins that have specific privileges", () => {
|
||||
// setup
|
||||
cy.log("setup permissions");
|
||||
|
||||
H.activateToken("pro-self-hosted");
|
||||
cy.visit("/admin/permissions/application");
|
||||
|
||||
const SETTINGS_INDEX = 0;
|
||||
const MONITORING_INDEX = 1;
|
||||
H.modifyPermission("All Users", SETTINGS_INDEX, "Yes");
|
||||
H.modifyPermission("All Users", MONITORING_INDEX, "Yes");
|
||||
|
||||
H.saveChangesToPermissions();
|
||||
|
||||
cy.findByRole("tab", { name: "Data" }).click();
|
||||
cy.findByRole("menuitem", { name: "All Users" }).click();
|
||||
|
||||
const TABLE_METADATA_INDEX = 3;
|
||||
const DATABASE_INDEX = 4;
|
||||
|
||||
H.modifyPermission("Sample Database", TABLE_METADATA_INDEX, "Yes");
|
||||
H.modifyPermission("Sample Database", DATABASE_INDEX, "Yes");
|
||||
|
||||
H.saveChangesToPermissions();
|
||||
|
||||
cy.signInAsNormalUser();
|
||||
|
||||
// test
|
||||
cy.visit("/");
|
||||
cy.findByTestId("home-page")
|
||||
.findByText(/see what metabase can do/i)
|
||||
.should("exist");
|
||||
|
||||
H.openCommandPalette();
|
||||
H.commandPalette().within(() => {
|
||||
// Settings Pages
|
||||
H.commandPaletteInput().type("Settings -");
|
||||
cy.log(
|
||||
"check user with settings permissions see non-admin restricted settings links",
|
||||
);
|
||||
H.commandPaletteAction("Settings - Setup").should("not.exist");
|
||||
H.commandPaletteAction("Settings - General").should("exist");
|
||||
H.commandPaletteInput().clear();
|
||||
|
||||
// Monitor tools live outside the Admin command-palette links
|
||||
H.commandPaletteInput().clear().type("tool");
|
||||
H.commandPaletteAction("Tools").should("not.exist");
|
||||
H.commandPaletteInput().clear();
|
||||
|
||||
//Database and table metadata
|
||||
|
||||
H.commandPaletteInput().type("data");
|
||||
H.commandPaletteAction("Databases").should("exist");
|
||||
H.commandPaletteInput().clear().type("tabl");
|
||||
H.commandPaletteAction("Table Metadata").should("exist");
|
||||
H.commandPaletteInput().clear();
|
||||
|
||||
cy.log("should not see other admin links");
|
||||
H.commandPaletteInput().type("Performance");
|
||||
H.commandPaletteAction("Performance").should("not.exist");
|
||||
cy.request("GET", "/api/permissions/graph").then(
|
||||
({ body: { groups, revision } }) => {
|
||||
cy.request("PUT", "/api/permissions/graph", {
|
||||
revision,
|
||||
groups: {
|
||||
...groups,
|
||||
[ALL_USERS_GROUP]: {
|
||||
...groups[ALL_USERS_GROUP],
|
||||
[SAMPLE_DB_ID]: {
|
||||
...groups[ALL_USERS_GROUP]?.[SAMPLE_DB_ID],
|
||||
// "Manage table metadata" and "Manage database"
|
||||
"data-model": { schemas: "all" },
|
||||
details: "yes",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
cy.signInAsNormalUser();
|
||||
|
||||
cy.visit("/");
|
||||
cy.findByTestId("home-page")
|
||||
.findByText(/see what metabase can do/i)
|
||||
.should("exist");
|
||||
|
||||
H.openCommandPalette();
|
||||
H.commandPalette().within(() => {
|
||||
// Settings Pages
|
||||
H.commandPaletteInput().type("Settings -");
|
||||
cy.log(
|
||||
"check user with settings permissions see non-admin restricted settings links",
|
||||
);
|
||||
H.commandPaletteAction("Settings - General").should("be.visible");
|
||||
H.commandPaletteAction("Settings - Setup").should("not.exist");
|
||||
H.commandPaletteInput().clear();
|
||||
|
||||
// Monitor tools live outside the Admin command-palette links
|
||||
H.commandPaletteInput().clear().type("tool");
|
||||
H.commandPaletteAction("Tools").should("not.exist");
|
||||
H.commandPaletteInput().clear();
|
||||
|
||||
//Database and table metadata
|
||||
|
||||
H.commandPaletteInput().type("data");
|
||||
H.commandPaletteAction("Databases").should("be.visible");
|
||||
H.commandPaletteInput().clear().type("tabl");
|
||||
H.commandPaletteAction("Table Metadata").should("be.visible");
|
||||
H.commandPaletteInput().clear();
|
||||
|
||||
cy.log("should not see other admin links");
|
||||
H.commandPaletteInput().type("Performance");
|
||||
H.commandPaletteAction("Performance").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -378,7 +290,7 @@ describe("command palette", () => {
|
||||
},
|
||||
});
|
||||
|
||||
cy.findByPlaceholderText("Search…").click();
|
||||
cy.findByPlaceholderText("Search…").should("be.visible").click();
|
||||
cy.findByRole("button", { name: / \+ K/ }).should("not.exist");
|
||||
|
||||
cy.get("body").type("{esc}");
|
||||
@@ -405,245 +317,125 @@ describe("command palette", () => {
|
||||
cy.findByLabelText(/Email address/).type(admin.email);
|
||||
cy.findByLabelText("Password").type(admin.password);
|
||||
cy.button("Sign in").click();
|
||||
cy.findByTestId("greeting-message");
|
||||
cy.findByTestId("greeting-message").should("be.visible");
|
||||
|
||||
// The palette stays disabled until its own effect sees the logged-in user,
|
||||
// and the greeting renders independently of the app bar, so wait for the
|
||||
// shell that hosts the palette before reaching for the keybinding.
|
||||
H.commandPaletteButton().should("be.visible");
|
||||
H.openCommandPalette();
|
||||
H.commandPalette().should("exist");
|
||||
});
|
||||
|
||||
it("The Search button should resize when on mobile", () => {
|
||||
cy.viewport("iphone-x");
|
||||
cy.visit("/");
|
||||
H.commandPaletteButton().should("not.contain.text", "search");
|
||||
});
|
||||
|
||||
it("Should have a new metric item", () => {
|
||||
cy.visit("/");
|
||||
cy.findByRole("button", { name: /search/i }).click();
|
||||
|
||||
H.commandPalette().within(() => {
|
||||
H.commandPaletteInput().should("exist").type("Me");
|
||||
cy.findByText("New metric").should("be.visible").click();
|
||||
|
||||
cy.location("pathname").should("eq", "/metric/new");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show the 'Download diagnostics' command palette item", () => {
|
||||
cy.visit("/");
|
||||
cy.findByRole("button", { name: /search/i }).click();
|
||||
|
||||
H.commandPalette().within(() => {
|
||||
H.commandPaletteInput().should("exist").type("Issue");
|
||||
cy.findByText("Download diagnostics").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow searching personal collections if no results and user is admin", () => {
|
||||
cy.visit("/");
|
||||
cy.findByRole("button", { name: /search/i }).click();
|
||||
cy.realType("asdf");
|
||||
H.commandPalette()
|
||||
.get("#search-results-metadata")
|
||||
.should("contain", "Search everything");
|
||||
});
|
||||
|
||||
it("should show the 'New embed' command palette item", () => {
|
||||
cy.visit("/");
|
||||
cy.findByRole("button", { name: /search/i }).click();
|
||||
|
||||
H.commandPalette().within(() => {
|
||||
H.commandPaletteInput().should("exist").type("new embed");
|
||||
cy.findByText("New embed").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ee", () => {
|
||||
beforeEach(() => {
|
||||
H.activateToken("pro-self-hosted");
|
||||
});
|
||||
|
||||
it("should have a 'New document' item", () => {
|
||||
cy.visit("/");
|
||||
cy.findByRole("button", { name: /search/i }).click();
|
||||
H.commandPalette().within(() => {
|
||||
H.commandPaletteInput().should("be.visible").type("new document");
|
||||
cy.findByText("New document").should("be.visible").click();
|
||||
cy.location("pathname").should("eq", "/document/new");
|
||||
});
|
||||
});
|
||||
H.commandPalette().should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortcuts", { tags: ["@actions"] }, () => {
|
||||
describe("shortcuts", () => {
|
||||
beforeEach(() => {
|
||||
H.resetSnowplow();
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.enableTracking();
|
||||
});
|
||||
|
||||
it("should render a shortcuts modal, and global shortcuts should be available", () => {
|
||||
H.setActionsEnabledForDB(SAMPLE_DB_ID);
|
||||
it("should navigate to seeded destinations and report to snowplow", () => {
|
||||
H.resetSnowplow();
|
||||
H.enableTracking();
|
||||
|
||||
cy.visit("/");
|
||||
cy.findByTestId("home-page")
|
||||
.findByTestId("loading-indicator")
|
||||
.should("not.exist");
|
||||
H.openShortcutModal();
|
||||
|
||||
H.shortcutModal().within(() => {
|
||||
cy.findByRole("tab", { name: "General" }).should("exist");
|
||||
cy.findByRole("tab", { name: "Dashboards" }).should("exist");
|
||||
});
|
||||
cy.realPress("Escape");
|
||||
H.shortcutModal().should("not.exist");
|
||||
H.openShortcutModal();
|
||||
cy.realPress("?");
|
||||
H.shortcutModal().should("not.exist");
|
||||
|
||||
cy.log("the shortcuts modal is reachable from the help menu");
|
||||
H.getProfileLink().click();
|
||||
H.popover().findByText("Help").click();
|
||||
H.getHelpSubmenu().findByText("Keyboard shortcuts").click();
|
||||
H.shortcutModal().should("exist");
|
||||
H.shortcutModal().should("be.visible");
|
||||
cy.realPress("Escape");
|
||||
H.shortcutModal().should("not.exist");
|
||||
|
||||
// Test a few global shortcuts
|
||||
cy.realPress("c").realPress("f");
|
||||
cy.findByRole("dialog", { name: /collection/i }).should("exist");
|
||||
cy.realPress("Escape");
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "keyboard_shortcut_performed",
|
||||
event_detail: "create-new-collection",
|
||||
});
|
||||
H.openCommandPalette();
|
||||
H.commandPaletteInput().should("be.visible").type("new dashboard");
|
||||
H.commandPalette().findByRole("option", { name: "New dashboard" }).click();
|
||||
cy.findByRole("dialog", { name: /dashboard/i }).should("exist");
|
||||
cy.realPress("Escape");
|
||||
|
||||
// Using a command palette action registered as a shortcut should only
|
||||
// emit snowplow events when using keyboard shortcuts, not command palette
|
||||
H.expectUnstructuredSnowplowEvent(
|
||||
{
|
||||
event: "keyboard_shortcut_performed",
|
||||
event_detail: "create-new-dashboard",
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
cy.realPress("c").realPress("d");
|
||||
cy.findByRole("dialog", { name: /dashboard/i }).should("exist");
|
||||
cy.realPress("Escape");
|
||||
H.expectUnstructuredSnowplowEvent(
|
||||
{
|
||||
event: "keyboard_shortcut_performed",
|
||||
event_detail: "create-new-dashboard",
|
||||
},
|
||||
1,
|
||||
);
|
||||
|
||||
cy.realPress("g").realPress("d");
|
||||
cy.location("pathname").should("contain", "browse/databases");
|
||||
|
||||
cy.realPress(["Meta", "["]);
|
||||
H.navigationSidebar().should("be.visible");
|
||||
|
||||
cy.realPress("[");
|
||||
H.navigationSidebar().should("not.be.visible");
|
||||
cy.realPress("[");
|
||||
H.navigationSidebar().should("be.visible");
|
||||
H.expectUnstructuredSnowplowEvent(
|
||||
{
|
||||
event: "keyboard_shortcut_performed",
|
||||
event_detail: "toggle-navbar",
|
||||
},
|
||||
2,
|
||||
);
|
||||
|
||||
// Each destination is anchored on rendered content, not just the URL: the
|
||||
// pathname changes when the route transition starts, and a chord pressed
|
||||
// while the next page is still mounting is dropped.
|
||||
cy.realPress("g").realPress("p");
|
||||
cy.location("pathname").should(
|
||||
"equal",
|
||||
`/collection/${ADMIN_PERSONAL_COLLECTION_ID}`,
|
||||
);
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "keyboard_shortcut_performed",
|
||||
event_detail: "navigate-personal-collection",
|
||||
});
|
||||
cy.findByTestId("collection-name-heading").should(
|
||||
"contain.text",
|
||||
"Bobby Tables's Personal Collection",
|
||||
);
|
||||
|
||||
cy.realPress("g").realPress("t");
|
||||
cy.location("pathname").should("equal", "/trash");
|
||||
cy.findByTestId("collection-name-heading").should("contain.text", "Trash");
|
||||
|
||||
cy.realPress("g").realPress("s");
|
||||
cy.location("pathname").should("match", /^\/data-studio/);
|
||||
H.DataStudio.nav().should("be.visible");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "keyboard_shortcut_performed",
|
||||
event_detail: "navigate-trash",
|
||||
});
|
||||
|
||||
cy.realPress("g").realPress("s");
|
||||
cy.location("pathname").should("match", /^\/data-studio/);
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "keyboard_shortcut_performed",
|
||||
event_detail: "navigate-data-studio",
|
||||
});
|
||||
|
||||
cy.realPress("g").realPress("m");
|
||||
|
||||
cy.log("shortcuts should not be enabled when working in a modal (ADM 658)");
|
||||
|
||||
H.navigationSidebar().should("be.visible");
|
||||
// Mantine Modals
|
||||
H.startNewCollectionFromSidebar();
|
||||
|
||||
cy.findByTestId("new-collection-modal")
|
||||
.findByLabelText(/collection it's saved in/i)
|
||||
.click();
|
||||
|
||||
// Remove focus
|
||||
H.entityPickerModal().findByRole("heading").click();
|
||||
|
||||
cy.realPress("[");
|
||||
H.navigationSidebar().should("be.visible");
|
||||
cy.realPress("Escape");
|
||||
cy.realPress("[");
|
||||
H.navigationSidebar().should("be.visible");
|
||||
cy.realPress("Escape");
|
||||
// Legacy Modals
|
||||
|
||||
H.startNewAction();
|
||||
|
||||
// Remove focus
|
||||
H.modal()
|
||||
.findByText(/Build custom forms/)
|
||||
.click();
|
||||
cy.realPress("[");
|
||||
H.navigationSidebar().should("be.visible");
|
||||
cy.realPress("Escape");
|
||||
cy.realPress("[");
|
||||
H.navigationSidebar().should("not.visible");
|
||||
|
||||
H.goToAdmin();
|
||||
|
||||
cy.findByTestId("site-name-setting").should("exist");
|
||||
cy.location("pathname").should("contain", "/admin/settings");
|
||||
cy.realPress("5");
|
||||
cy.location("pathname").should("contain", "/admin/datamodel");
|
||||
cy.realPress("9");
|
||||
cy.location("pathname").should("contain", "/admin/help");
|
||||
});
|
||||
|
||||
it("should not navigate to data studio via shortcut for non-admin users", () => {
|
||||
cy.signInAsNormalUser();
|
||||
cy.visit("/");
|
||||
cy.findByTestId("home-page")
|
||||
.findByText(/see what metabase can do/i)
|
||||
.should("exist");
|
||||
it(
|
||||
"should not fire shortcuts while a modal holds focus (ADM 658)",
|
||||
{ tags: ["@actions"] },
|
||||
() => {
|
||||
H.setActionsEnabledForDB(SAMPLE_DB_ID);
|
||||
|
||||
cy.realPress("g").realPress("s");
|
||||
cy.location("pathname").should("equal", "/");
|
||||
});
|
||||
cy.visit("/");
|
||||
cy.findByTestId("home-page")
|
||||
.findByTestId("loading-indicator")
|
||||
.should("not.exist");
|
||||
H.navigationSidebar().should("be.visible");
|
||||
|
||||
cy.log("Mantine modals");
|
||||
H.startNewCollectionFromSidebar();
|
||||
cy.findByTestId("new-collection-modal")
|
||||
.should("be.visible")
|
||||
.findByLabelText(/collection it's saved in/i)
|
||||
.click();
|
||||
|
||||
// Remove focus
|
||||
H.entityPickerModal().findByRole("heading").click();
|
||||
|
||||
cy.realPress("[");
|
||||
H.navigationSidebar().should("be.visible");
|
||||
cy.realPress("Escape");
|
||||
H.entityPickerModal().should("not.exist");
|
||||
|
||||
cy.realPress("[");
|
||||
H.navigationSidebar().should("be.visible");
|
||||
cy.realPress("Escape");
|
||||
cy.findByTestId("new-collection-modal").should("not.exist");
|
||||
|
||||
cy.log("Legacy modals");
|
||||
H.startNewAction();
|
||||
|
||||
// Remove focus
|
||||
H.modal()
|
||||
.findByText(/Build custom forms/)
|
||||
.click();
|
||||
|
||||
cy.realPress("[");
|
||||
H.navigationSidebar().should("be.visible");
|
||||
cy.realPress("Escape");
|
||||
cy.findByTestId("action-creator").should("not.exist");
|
||||
|
||||
cy.log("the shortcut works again once no modal holds focus");
|
||||
cy.realPress("[");
|
||||
H.navigationSidebar().should("not.be.visible");
|
||||
},
|
||||
);
|
||||
|
||||
it("should support dashboard shortcuts", () => {
|
||||
cy.intercept("POST", "/api/bookmark/dashboard/*").as("bookmarkDashboard");
|
||||
cy.intercept("DELETE", "/api/bookmark/dashboard/*").as(
|
||||
"unbookmarkDashboard",
|
||||
);
|
||||
|
||||
H.createDashboardWithTabs({
|
||||
tabs: [TAB_1, TAB_2, TAB_3, TAB_4],
|
||||
dashcards: [
|
||||
@@ -670,16 +462,25 @@ describe("shortcuts", { tags: ["@actions"] }, () => {
|
||||
],
|
||||
}).then((dashboard) => H.visitDashboard(dashboard.id));
|
||||
|
||||
H.openShortcutModal();
|
||||
H.shortcutModal().should("exist");
|
||||
cy.realPress("Escape");
|
||||
// The o shortcut is registered by the header's bookmark toggle, and
|
||||
// visitDashboard only waits for the dashcard queries, so the keystroke can
|
||||
// land before that component has mounted and be dropped.
|
||||
H.dashboardHeader()
|
||||
.findByRole("button", { name: "Bookmark" })
|
||||
.should("be.visible");
|
||||
|
||||
// The second press reads whether the dashboard is bookmarked from the
|
||||
// refetched bookmark list, so each toggle waits for its write to land and
|
||||
// for the sidebar to catch up before the next press.
|
||||
cy.realPress("o");
|
||||
cy.wait("@bookmarkDashboard");
|
||||
H.openNavigationSidebar();
|
||||
H.navigationSidebar()
|
||||
.findByRole("section", { name: "Bookmarks" })
|
||||
.should("contain.text", "Test Dashboard");
|
||||
|
||||
cy.realPress("o");
|
||||
cy.wait("@unbookmarkDashboard");
|
||||
H.navigationSidebar()
|
||||
.findByRole("section", { name: "Bookmarks" })
|
||||
.should("not.exist");
|
||||
|
||||
@@ -241,82 +241,6 @@ describe("converting question to SQL (metabase#12651, metabase#21615, metabase#3
|
||||
});
|
||||
});
|
||||
|
||||
describe(
|
||||
"converting question to a native query (metabase#15946, metabase#32121, metabase#38181, metabase#40557)",
|
||||
{ tags: "@mongo" },
|
||||
() => {
|
||||
const MONGO_DB_NAME = "QA Mongo";
|
||||
|
||||
beforeEach(() => {
|
||||
H.restore("mongo-5");
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("should work for both simple and nested questions based on previously converted GUI query", () => {
|
||||
H.startNewQuestion();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText(MONGO_DB_NAME).click();
|
||||
cy.findByText("Products").click();
|
||||
});
|
||||
|
||||
cy.log("Simple question");
|
||||
openSidebar("native");
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
cy.findByTestId("native-query-preview-sidebar").within(() => {
|
||||
cy.findByText("Native query for this question").should("exist");
|
||||
H.NativeEditor.get()
|
||||
.should("be.visible")
|
||||
.and("contain", "$project")
|
||||
.and("not.contain", "$limit");
|
||||
|
||||
cy.button("Convert this question to a native query").click();
|
||||
});
|
||||
cy.wait("@dataset");
|
||||
|
||||
cy.log("Database and table should be pre-selected (metabase#15946)");
|
||||
cy.findByTestId("selected-database").should("have.text", MONGO_DB_NAME);
|
||||
cy.findByTestId("selected-table").should("have.text", "Products");
|
||||
cy.get("[data-testid=cell-data]").should("contain", "Small Marble Shoes");
|
||||
|
||||
cy.log("Nested question");
|
||||
cy.log(
|
||||
"should be possible to save a question and `Explore results` (metabase#32121)",
|
||||
);
|
||||
H.saveQuestion("foo", undefined, {
|
||||
path: ["Our analytics"],
|
||||
});
|
||||
cy.intercept("POST", "/api/dataset").as("exploreDataset");
|
||||
cy.findByTestId("qb-header").findByText("Explore results").click();
|
||||
cy.wait("@exploreDataset");
|
||||
cy.get("[data-testid=cell-data]").should("contain", "Small Marble Shoes");
|
||||
|
||||
cy.log("The generated query should be valid (metabase#38181)");
|
||||
H.openNotebook();
|
||||
openSidebar("native");
|
||||
cy.intercept("POST", "/api/dataset").as("dataset2");
|
||||
cy.findByTestId("native-query-preview-sidebar").within(() => {
|
||||
cy.findByText("Native query for this question").should("exist");
|
||||
H.NativeEditor.get()
|
||||
.should("be.visible")
|
||||
.and("contain", "$project")
|
||||
.and("not.contain", "$limit")
|
||||
.and("not.contain", "BsonString")
|
||||
.and("not.contain", "BsonInt32");
|
||||
|
||||
cy.button("Convert this question to a native query").click();
|
||||
});
|
||||
cy.wait("@dataset2");
|
||||
|
||||
cy.log(
|
||||
"Database and table should be pre-selected (metabase#15946 and/or metabase#40557)",
|
||||
);
|
||||
cy.findByTestId("selected-database").should("have.text", MONGO_DB_NAME);
|
||||
cy.findByTestId("selected-table").should("have.text", "Products");
|
||||
cy.get("[data-testid=cell-data]").should("contain", "Small Marble Shoes");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("scenarios > notebook > native query preview sidebar tracking events", () => {
|
||||
beforeEach(() => {
|
||||
H.resetSnowplow();
|
||||
@@ -399,12 +323,10 @@ function resizeSidebar(amountX: number, cb: ResizeSidebarCallback) {
|
||||
});
|
||||
}
|
||||
|
||||
function openSidebar(variant: "sql" | "native" = "sql") {
|
||||
const label = variant === "sql" ? "View SQL" : "View native query";
|
||||
cy.findByLabelText(label).should("be.visible").click();
|
||||
function openSidebar() {
|
||||
cy.findByLabelText("View SQL").should("be.visible").click();
|
||||
}
|
||||
|
||||
function closeSidebar(variant: "sql" | "native" = "sql") {
|
||||
const label = variant === "sql" ? "Hide SQL" : "Hide native query";
|
||||
cy.findByLabelText(label).should("be.visible").click();
|
||||
function closeSidebar() {
|
||||
cy.findByLabelText("Hide SQL").should("be.visible").click();
|
||||
}
|
||||
|
||||
@@ -663,43 +663,6 @@ describe("scenarios > question > notebook", { tags: "@slow" }, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should not crash notebook when metric is used as an aggregation and breakout is applied (metabase#40553)", () => {
|
||||
H.createQuestion(
|
||||
{
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.SUBTOTAL, null]]],
|
||||
},
|
||||
type: "metric",
|
||||
name: "Revenue",
|
||||
},
|
||||
{
|
||||
wrapId: true,
|
||||
idAlias: "metricId",
|
||||
},
|
||||
);
|
||||
|
||||
cy.get("@metricId").then((metricId) => {
|
||||
const questionDetails = {
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
breakout: [
|
||||
["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
|
||||
],
|
||||
aggregation: ["metric", metricId],
|
||||
},
|
||||
};
|
||||
|
||||
H.createQuestion(questionDetails, { visitQuestion: true });
|
||||
|
||||
H.openNotebook();
|
||||
|
||||
H.getNotebookStep("summarize").contains("Revenue").click();
|
||||
|
||||
H.CustomExpressionEditor.value().should("equal", "[Revenue]");
|
||||
});
|
||||
});
|
||||
|
||||
it("should open only one bucketing popover at a time (metabase#45036)", () => {
|
||||
H.visitQuestionAdhoc(
|
||||
{
|
||||
|
||||
@@ -3,7 +3,6 @@ import { chunk } from "underscore";
|
||||
|
||||
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { ADMIN_USER_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
|
||||
const { ORDERS, ORDERS_ID, PEOPLE, PEOPLE_ID, PRODUCTS, PRODUCTS_ID } =
|
||||
SAMPLE_DATABASE;
|
||||
@@ -267,49 +266,6 @@ describe("#22206 adding and removing columns doesn't duplicate columns", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 23076", () => {
|
||||
const questionDetails = {
|
||||
name: "Orders, Distinct values of ID, Grouped by Product → Title and Created At (month) and User → ID",
|
||||
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["distinct", ["field", ORDERS.ID, null]]],
|
||||
breakout: [
|
||||
["field", PRODUCTS.TITLE, { "source-field": ORDERS.PRODUCT_ID }],
|
||||
["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
|
||||
["field", PEOPLE.ID, { "source-field": ORDERS.USER_ID }],
|
||||
],
|
||||
},
|
||||
display: "pivot",
|
||||
visualization_settings: {
|
||||
"pivot_table.column_split": {
|
||||
rows: ["TITLE", "CREATED_AT", "ID"],
|
||||
columns: [],
|
||||
values: ["distinct"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
|
||||
cy.request("PUT", `/api/user/${ADMIN_USER_ID}`, {
|
||||
locale: "en-ZZ",
|
||||
});
|
||||
|
||||
H.createQuestion(questionDetails, { visitQuestion: true });
|
||||
});
|
||||
|
||||
it("should correctly translate dates (metabase#23076)", () => {
|
||||
cy.findAllByText(/^\[zz\] Totals for/)
|
||||
.should("be.visible")
|
||||
.eq(1)
|
||||
.invoke("text")
|
||||
.should("eq", "[zz] Totals for May 2026");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 28304", () => {
|
||||
const questionDetails = {
|
||||
name: "28304",
|
||||
|
||||
@@ -171,8 +171,8 @@
|
||||
body :- ::publish-table-selectors]
|
||||
(api/check-data-analyst)
|
||||
(let [target-collection (api/check-404 (data-studio.db/collection (:collection_id body)))
|
||||
_ (api/check-400 (= (:type target-collection) collection/library-data-collection-type)
|
||||
(tru "Tables can only be published to Library/Data collections."))
|
||||
_ (api/check-400 (collection/can-contain-published-tables? target-collection)
|
||||
(tru "Tables can only be published to Library/Data collections or Library folders."))
|
||||
selectors (body->table-selectors body)
|
||||
upstream-ids (all-upstream-table-ids selectors)
|
||||
;; Don't move already-published upstream tables; only publish unpublished ones. Get table IDs before update
|
||||
|
||||
@@ -34,10 +34,11 @@
|
||||
:archive-operation-id nil})]
|
||||
:order-by [[:%lower.name :asc]]}))
|
||||
|
||||
(mu/defn collection-type
|
||||
"The type of the Collection with `collection-id`."
|
||||
(mu/defn collection-type-and-entity-id
|
||||
"The `:type` and `:entity_id` of the Collection with `collection-id` — enough to tell a library folder from one of
|
||||
the seeded Library roots via [[metabase.collections.models.collection/library-root-collection?]]."
|
||||
[collection-id :- ::lib.schema.id/collection]
|
||||
(t2/select-one-fn :type [:model/Collection :type] :id collection-id))
|
||||
(t2/select-one [:model/Collection :type :entity_id] :id collection-id))
|
||||
|
||||
(mu/defn unarchived-card-collection-types-reducible
|
||||
"Reducible distinct Collection ID and Card type pairs of the unarchived Cards."
|
||||
|
||||
@@ -6,20 +6,31 @@
|
||||
[metabase.premium-features.core :refer [defenterprise]]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def ^:private library-collection-content-specs
|
||||
"A map from the `:type` of the parent collection to a spec for what contents it permits."
|
||||
{collection/library-collection-type
|
||||
{:allowed-content-types #{collection/library-data-collection-type
|
||||
collection/library-metrics-collection-type}
|
||||
:error-message "Cannot add anything to the Library collection"}
|
||||
(defn- library-content-spec
|
||||
"A spec for what contents `parent` permits, or `nil` if `parent` is not a library collection at all.
|
||||
|
||||
collection/library-data-collection-type
|
||||
{:allowed-content-types #{:table collection/library-data-collection-type}
|
||||
:error-message "Can only add tables to the 'Data' collection"}
|
||||
The Library root only holds folders; a generic (user-created) library folder holds published tables, metrics, and
|
||||
more folders; the seeded 'Data' and 'Metrics' trees stay single-purpose."
|
||||
[{parent-type :type :as parent}]
|
||||
(condp = parent-type
|
||||
collection/library-collection-type
|
||||
(if (collection/library-root-collection? parent)
|
||||
{:allowed-content-types #{collection/library-collection-type
|
||||
collection/library-data-collection-type
|
||||
collection/library-metrics-collection-type}
|
||||
:error-message "Only folders can be added to the Library"}
|
||||
{:allowed-content-types #{:table :metric collection/library-collection-type}
|
||||
:error-message "Can only add tables, metrics, and folders to a Library folder"})
|
||||
|
||||
collection/library-metrics-collection-type
|
||||
{:allowed-content-types #{:metric collection/library-metrics-collection-type}
|
||||
:error-message "Can only add metrics to the 'Metrics' collection"}})
|
||||
collection/library-data-collection-type
|
||||
{:allowed-content-types #{:table collection/library-data-collection-type}
|
||||
:error-message "Can only add tables to the 'Data' collection"}
|
||||
|
||||
collection/library-metrics-collection-type
|
||||
{:allowed-content-types #{:metric collection/library-metrics-collection-type}
|
||||
:error-message "Can only add metrics to the 'Metrics' collection"}
|
||||
|
||||
nil))
|
||||
|
||||
(defenterprise check-allowed-content
|
||||
"Check if the collection's content matches the allowed content.
|
||||
@@ -27,13 +38,13 @@
|
||||
:feature :library
|
||||
[content-type collection-id]
|
||||
(when collection-id
|
||||
(let [collection-type (library.db/collection-type collection-id)]
|
||||
(when-let [{:keys [allowed-content-types error-message]} (some-> collection-type
|
||||
library-collection-content-specs)]
|
||||
(let [parent (library.db/collection-type-and-entity-id collection-id)]
|
||||
(when-let [{:keys [allowed-content-types error-message]} (library-content-spec parent)]
|
||||
(when-not (allowed-content-types content-type)
|
||||
(throw (ex-info error-message {:status-code 400}))))
|
||||
(when (and (= content-type :table) (not= collection-type collection/library-data-collection-type))
|
||||
(throw (ex-info "Tables can only be added to 'Data' collections" {:status-code 400})))))
|
||||
(when (and (= content-type :table)
|
||||
(not (collection/can-contain-published-tables? parent)))
|
||||
(throw (ex-info "Tables can only be added to Library folders" {:status-code 400})))))
|
||||
true)
|
||||
|
||||
(defenterprise check-library-update
|
||||
@@ -49,6 +60,6 @@
|
||||
(when (and (collection/is-library-collection? (:id collection))
|
||||
(contains? change-keys :location)
|
||||
(when-let [parent-id (collection/location-path->parent-id (:location collection))]
|
||||
(not= (:type collection) (library.db/collection-type parent-id))))
|
||||
(not= (:type collection) (:type (library.db/collection-type-and-entity-id parent-id)))))
|
||||
(throw (ex-info "Cannot move a Library collection outside the Library" {}))))
|
||||
true)
|
||||
|
||||
@@ -61,10 +61,12 @@
|
||||
;; the branch the client believes is currently active; rejected if it disagrees with the
|
||||
;; remote-sync-branch setting (a pull/switch from a stale tab). `branch` is the operational
|
||||
;; target (it differs from this on a branch switch); `expected_branch` is only the assertion.
|
||||
[:expected_branch ms/NonBlankString]]]
|
||||
;; Optional because headless clients (the `mb` CLI) hold no cached view that can go stale.
|
||||
[:expected_branch {:optional true} ms/NonBlankString]]]
|
||||
(api/check-superuser)
|
||||
(api/check-400 (settings/remote-sync-enabled) "Remote sync is not configured.")
|
||||
(check-branch-matches-setting! expected_branch)
|
||||
(when expected_branch
|
||||
(check-branch-matches-setting! expected_branch))
|
||||
(let [branch-name (or branch (settings/remote-sync-branch))
|
||||
user-id api/*current-user-id*
|
||||
{task-id :id}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
additional logic, so no other namespace in the module runs a query itself."
|
||||
(:require
|
||||
[metabase-enterprise.remote-sync.schema :as remote-sync.schema]
|
||||
[metabase.collections.core :as collections]
|
||||
[metabase.collections.schema :as collections.schema]
|
||||
[metabase.lib.schema.id :as lib.schema.id]
|
||||
[metabase.util.malli :as mu]
|
||||
@@ -164,6 +163,12 @@
|
||||
ids :- [:sequential ms/PositiveInt]]
|
||||
(t2/select [model :id :name] :id [:in ids]))
|
||||
|
||||
(mu/defn archived-by-id
|
||||
"A map of ID to the `:archived` flag of the instances of `model` with `ids`."
|
||||
[model :- :keyword
|
||||
ids :- [:sequential ms/PositiveInt]]
|
||||
(t2/select-pk->fn :archived model :id [:in ids]))
|
||||
|
||||
(mu/defn instances-in-collections
|
||||
"The instances of `model` in the Collections with `collection-ids`, excluding those archived under the optional
|
||||
`archived-key` column."
|
||||
@@ -321,8 +326,9 @@
|
||||
"Matches `collections` and all of their descendants."
|
||||
[collections]
|
||||
(into [:or [:in :id (map :id collections)]]
|
||||
(for [collection collections]
|
||||
[:like :location (str (collections/location-path collection) "%")])))
|
||||
(for [{:keys [id location]} collections]
|
||||
;; the location its children have, so a nested collection's descendants match too
|
||||
[:like :location (str location id "/%")])))
|
||||
|
||||
(mu/defn collections
|
||||
"The Collections with `collection-ids`."
|
||||
@@ -533,6 +539,13 @@
|
||||
[collection-ids :- [:set ::lib.schema.id/collection]]
|
||||
(t2/select [:model/RemoteSyncObject :id :status] {:where (contents-rso-expr collection-ids)}))
|
||||
|
||||
(mu/defn content-rsos
|
||||
"The `:id`, `:model_type`, `:model_id`, `:model_collection_id`, and `:status` of the RemoteSyncObjects of the
|
||||
Collections with `collection-ids` and of their contents."
|
||||
[collection-ids :- [:set ::lib.schema.id/collection]]
|
||||
(t2/select [:model/RemoteSyncObject :id :model_type :model_id :model_collection_id :status]
|
||||
{:where (contents-rso-expr collection-ids)}))
|
||||
|
||||
(mu/defn removed-content-rso-ids
|
||||
"The IDs of the RemoteSyncObjects pending removal among those of the Collections with `collection-ids` and their
|
||||
contents."
|
||||
|
||||
@@ -295,6 +295,37 @@
|
||||
(log/info "Library collection is no longer remote-synced, disabling Library content sync tracking")
|
||||
(disable-library-tracking!)))))
|
||||
|
||||
(defn- cascade-archived-state!
|
||||
"Brings the RemoteSyncObject rows of `collection`'s subtree, other than its own row, in line with the archived state
|
||||
of each entity and of the collection it is in: rows of entities that are archived or in an archived collection
|
||||
become 'delete', and 'delete' rows of existing entities that are neither become 'update'."
|
||||
[collection]
|
||||
;; Archiving a collection archives its subtree in bulk SQL, which publishes no event per descendant. Transforms
|
||||
;; have no archived column, so only their collection shows that they were archived.
|
||||
(let [collection-ids (remote-sync.db/subtree-collection-ids [collection])
|
||||
collection-archived? (remote-sync.db/archived-by-id :model/Collection (vec collection-ids))
|
||||
rows (->> (remote-sync.db/content-rsos collection-ids)
|
||||
(remove #(and (= "Collection" (:model_type %)) (= (:id collection) (:model_id %)))))
|
||||
now (t/offset-date-time)]
|
||||
(doseq [[model-type type-rows] (group-by :model_type rows)
|
||||
:let [{:keys [model-key archived-key]} (spec/spec-for-model-type model-type)]
|
||||
:when (= :archived archived-key)
|
||||
:let [entity-archived? (remote-sync.db/archived-by-id model-key (mapv :model_id type-rows))
|
||||
archived? (fn [{:keys [model_id model_collection_id]}]
|
||||
(or (true? (entity-archived? model_id))
|
||||
(true? (collection-archived? model_collection_id))))
|
||||
deleted (filter #(and (archived? %)
|
||||
(not (contains? #{"delete" "removed"} (:status %))))
|
||||
type-rows)
|
||||
restored (filter #(and (contains? entity-archived? (:model_id %))
|
||||
(not (archived? %))
|
||||
(= "delete" (:status %)))
|
||||
type-rows)]]
|
||||
(when (seq deleted)
|
||||
(remote-sync.db/set-rsos-status! (map :id deleted) "delete" now))
|
||||
(when (seq restored)
|
||||
(remote-sync.db/set-rsos-status! (map :id restored) "update" now)))))
|
||||
|
||||
(methodical/defmethod events/publish-event! ::collection-change-event
|
||||
[topic event]
|
||||
(let [{:keys [object]} event
|
||||
@@ -313,7 +344,10 @@
|
||||
should-sync?
|
||||
(do
|
||||
(log/infof "Creating remote sync object entry for collection %s (status: %s)" (:id object) status)
|
||||
(create-or-update-remote-sync-object-entry! "Collection" (:id object) status hydrate-collection-details))
|
||||
(create-or-update-remote-sync-object-entry! "Collection" (:id object) status hydrate-collection-details)
|
||||
(when (and (= topic :event/collection-update)
|
||||
(or (:archived object) (= "delete" (:status existing-entry))))
|
||||
(cascade-archived-state! object)))
|
||||
(and existing-entry (not should-sync?))
|
||||
(do
|
||||
(log/infof "Collection %s no longer needs syncing, marking as removed" (:id object))
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
(java.nio.charset StandardCharsets)
|
||||
(java.sql Connection SQLException)
|
||||
(java.util.concurrent Executor)
|
||||
(javax.sql DataSource)
|
||||
(org.postgresql PGProperty)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -93,7 +94,11 @@
|
||||
(into #{} (map #(.getName ^PGProperty %)) (PGProperty/values)))
|
||||
|
||||
(defn- url-decode ^String [^String s]
|
||||
(URLDecoder/decode s StandardCharsets/UTF_8))
|
||||
(try
|
||||
(URLDecoder/decode s StandardCharsets/UTF_8)
|
||||
(catch IllegalArgumentException _
|
||||
;; the decoder's own message quotes part of the value, which may be a password
|
||||
(throw (ex-info "Malformed %-escape in MB_PGVECTOR_DB_URL" {})))))
|
||||
|
||||
(defn- split-query
|
||||
"Split a JDBC URL into [base pairs], where pairs is a seq of raw [key value] strings.
|
||||
@@ -107,17 +112,63 @@
|
||||
:let [[k v] (str/split pair #"=" 2)]]
|
||||
[k (or v "")])]))
|
||||
|
||||
(defn- split-userinfo
|
||||
"Split `user:password@` out of a JDBC URL's host part, returning [url credentials].
|
||||
The credentials are a map of the decoded :user and :password, empty when the URL has none."
|
||||
[^String base]
|
||||
;; The host part runs from `//` to the next `/`.
|
||||
;; Split it at its last `@`, not the first as libpq does, so an unencoded `@` in the password can't leave
|
||||
;; part of it on the URL.
|
||||
;; Any scheme matches, so a misspelt one, which DriverManager quotes, can't carry the credentials either.
|
||||
(if-let [[_ scheme userinfo more] (re-matches #"([^/]*//)([^/]*)@(.*)" base)]
|
||||
;; decode %-escapes only, as libpq does: a `+` here is a literal plus, not a space as in a query string
|
||||
(let [decode #(url-decode (str/replace % "+" "%2B"))
|
||||
[user password] (str/split userinfo #":" 2)
|
||||
credentials (cond-> {:user (decode user)}
|
||||
password (assoc :password (decode password)))]
|
||||
[(str scheme more) credentials])
|
||||
[base {}]))
|
||||
|
||||
(defn- check-no-stray-at!
|
||||
"Throw if an `@` is left in `url` or `pairs` once the credentials are off, without quoting either."
|
||||
[^String url pairs]
|
||||
;; An `@` left on `url` means a password with an unencoded `/`, or an `@` that pgjdbc requires encoded anyway.
|
||||
;; An unencoded `?` in a password ends the host part early, before the `/` pgjdbc requires after a host, and
|
||||
;; moves the `@` into `pairs`.
|
||||
;; Any other `@` in `pairs` is a plain value that pgjdbc accepts, such as Azure's `user=name@server`.
|
||||
;; Refuse a stray `@` before an error or the URL can quote the part of the password it may hold.
|
||||
(when (or (str/includes? url "@")
|
||||
(and (re-matches #"[^/]*//[^/]+" url)
|
||||
(some (fn [[k v]] (str/includes? (str k v) "@")) pairs)))
|
||||
(throw (ex-info (str "MB_PGVECTOR_DB_URL has an unencoded @ outside its credentials. "
|
||||
"Percent-encode reserved characters, e.g. @ as %40, / as %2F and ? as %3F.")
|
||||
{}))))
|
||||
|
||||
(defn- parse-db-url
|
||||
"Parse a pgvector JDBC URL into {:jdbc-url ... :pool-props ...}, or throw if it carries an unrecognized
|
||||
parameter."
|
||||
"Parse a pgvector JDBC URL into {:jdbc-url ... :credentials ... :pool-props ...}, or throw if it is
|
||||
malformed or carries an unrecognized parameter.
|
||||
Credentials come off the URL whether they're written as params or before the host."
|
||||
[^String url]
|
||||
(let [[base pairs] (split-query url)
|
||||
default-pool (merge fixed-pool-props
|
||||
(update-vals tunable-pool-props first))
|
||||
{:keys [pool conn]}
|
||||
(let [[base pairs] (split-query url)
|
||||
[base-url host-credentials] (split-userinfo base)
|
||||
_ (check-no-stray-at! base-url pairs)
|
||||
default-pool (merge fixed-pool-props
|
||||
(update-vals tunable-pool-props first))
|
||||
{:keys [pool conn credentials]}
|
||||
(reduce
|
||||
(fn [acc [k raw-v]]
|
||||
(cond
|
||||
;; also set before the host: Postgres clients disagree on which wins, so refuse to pick one
|
||||
(contains? host-credentials (keyword k))
|
||||
(throw (ex-info (format "The pgvector URL sets %s both before the host and as a parameter" k)
|
||||
{:param k}))
|
||||
|
||||
;; credentials: passed to pgjdbc as connection properties, off the URL, because the URL gets
|
||||
;; printed -- pgjdbc logs it at DEBUG on every connection, and DriverManager quotes it in its
|
||||
;; "No suitable driver" error
|
||||
(contains? #{"user" "password" "sslpassword"} k)
|
||||
(assoc-in acc [:credentials (keyword k)] (url-decode raw-v))
|
||||
|
||||
;; a c3p0 knob: coerce and apply to the pool (pgjdbc can't read these off the URL)
|
||||
(contains? tunable-pool-props k)
|
||||
(let [parse (second (tunable-pool-props k))
|
||||
@@ -137,13 +188,14 @@
|
||||
"(%s) or a Postgres connection property.")
|
||||
k (str/join ", " (sort (keys tunable-pool-props))))
|
||||
{:param k}))))
|
||||
{:pool default-pool, :conn []}
|
||||
{:pool default-pool, :conn [], :credentials host-credentials}
|
||||
pairs)]
|
||||
{:jdbc-url (cond-> base (seq conn) (str "?" (str/join "&" conn)))
|
||||
:pool-props pool}))
|
||||
{:jdbc-url (cond-> base-url (seq conn) (str "?" (str/join "&" conn)))
|
||||
:credentials credentials
|
||||
:pool-props pool}))
|
||||
|
||||
(defn- parsed-db-config
|
||||
"Parse [[db-url]] into {:jdbc-url ... :pool-props ...}, or throw if it is unset."
|
||||
"Parse [[db-url]] into {:jdbc-url ... :credentials ... :pool-props ...}, or throw if it is unset."
|
||||
[]
|
||||
(if db-url
|
||||
(parse-db-url db-url)
|
||||
@@ -165,16 +217,29 @@
|
||||
(delay (.addShutdownHook (Runtime/getRuntime)
|
||||
(Thread. ^Runnable shutdown-db! "semantic-search-pool-shutdown"))))
|
||||
|
||||
(defn- redacted-data-source
|
||||
"Wrap `ds` so it prints without its JDBC URL, which can carry the database credentials.
|
||||
c3p0 prints the data source it pools in its own string form, and that reaches the logs through exception
|
||||
messages such as \"... has been closed() -- you can no longer use it\"."
|
||||
^DataSource [^DataSource ds]
|
||||
(reify DataSource
|
||||
(getConnection [_] (.getConnection ds))
|
||||
(getConnection [_ user password] (.getConnection ds user password))
|
||||
(getLoginTimeout [_] (.getLoginTimeout ds))
|
||||
(setLoginTimeout [_ seconds] (.setLoginTimeout ds seconds))
|
||||
Object
|
||||
(toString [_] "pgvector JDBC data source (URL redacted)")))
|
||||
|
||||
(defn init-db!
|
||||
"Initialize c3p0 connection pool for semantic search database.
|
||||
Requires MB_PGVECTOR_DB_URL environment variable."
|
||||
[]
|
||||
(locking data-source
|
||||
(or @data-source
|
||||
(let [{:keys [jdbc-url pool-props]} (parsed-db-config)
|
||||
unpooled-ds (jdbc/get-datasource {:jdbcUrl jdbc-url})
|
||||
(let [{:keys [jdbc-url credentials pool-props]} (parsed-db-config)
|
||||
unpooled-ds (redacted-data-source (jdbc/get-datasource (assoc credentials :jdbcUrl jdbc-url)))
|
||||
pooled-ds (DataSources/pooledDataSource
|
||||
^javax.sql.DataSource unpooled-ds
|
||||
unpooled-ds
|
||||
(connection-pool/map->properties pool-props))]
|
||||
(log/info "Initializing semantic search connection pool with properties:" pool-props)
|
||||
(force shutdown-hook)
|
||||
@@ -222,7 +287,7 @@
|
||||
Always a one-shot connection: a pooled one carries the pool's timeouts, and waiting on a saturated pool
|
||||
would read as an unreachable store."
|
||||
[]
|
||||
(jdbc/execute-one! (jdbc/get-datasource {:jdbcUrl (probe-jdbc-url)})
|
||||
(jdbc/execute-one! (jdbc/get-datasource (assoc (:credentials (parsed-db-config)) :jdbcUrl (probe-jdbc-url)))
|
||||
["SELECT 1 AS test"]
|
||||
{:timeout probe-query-timeout-seconds}))
|
||||
|
||||
|
||||
@@ -101,9 +101,9 @@
|
||||
(testing "returns 400 when collection_id is missing"
|
||||
(mt/user-http-request :crowberto :post 400 "ee/data-studio/table/publish-tables"
|
||||
{:table_ids [(mt/id :users)]}))
|
||||
(testing "returns 400 when target collection is not a library-data collection"
|
||||
(testing "returns 400 when target collection cannot hold published tables"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {}]
|
||||
(is (= "Tables can only be published to Library/Data collections."
|
||||
(is (= "Tables can only be published to Library/Data collections or Library folders."
|
||||
(mt/user-http-request :crowberto :post 400 "ee/data-studio/table/publish-tables"
|
||||
{:table_ids [(mt/id :users)]
|
||||
:collection_id collection-id})))))
|
||||
|
||||
@@ -182,7 +182,8 @@
|
||||
(mt/with-premium-features #{:library}
|
||||
(let [search-term (random-name)]
|
||||
(mt/with-temp
|
||||
[:model/Collection {lib-id :id} {:name "Library" :type collection/library-collection-type :location "/"}
|
||||
[:model/Collection {lib-id :id} {:name "Library" :type collection/library-collection-type :location "/"
|
||||
:entity_id collection/library-entity-id}
|
||||
:model/Collection {data-coll :id} {:name "Data"
|
||||
:type collection/library-data-collection-type
|
||||
:location (collection/location-path lib-id)}
|
||||
|
||||
@@ -14,18 +14,65 @@
|
||||
(is (some? (t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :metric, :collection_id (:id regular-collection)}))))
|
||||
(is (some? (t2/insert! :model/Dashboard (merge (mt/with-temp-defaults :model/Dashboard) {:collection_id (:id regular-collection)}))))))))
|
||||
|
||||
(deftest library-completely-locked-down
|
||||
(defn- library-root-defaults
|
||||
"Temp-collection properties that make a collection look like the seeded Library root: the hard-coded
|
||||
`entity_id` is what [[collection/library-root-collection?]] keys off."
|
||||
[]
|
||||
{:name "Test Library"
|
||||
:type collection/library-collection-type
|
||||
:entity_id collection/library-entity-id})
|
||||
|
||||
(defn- collection-under
|
||||
"Fresh `:model/Collection` insert properties for a child of `parent`, with `:type` set to `folder-type`."
|
||||
[parent folder-type]
|
||||
(merge (mt/with-temp-defaults :model/Collection)
|
||||
{:location (str (:location parent) (:id parent) "/")
|
||||
:type folder-type}))
|
||||
|
||||
(deftest library-root-holds-only-folders
|
||||
(mt/with-premium-features #{:library}
|
||||
(mt/with-temp [:model/Collection no-allowed-content {:name "Test No Content" :type collection/library-collection-type}]
|
||||
(testing "Cannot add anything to library collections"
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Cannot add anything to the Library collection"
|
||||
(t2/insert! :model/Collection (merge (mt/with-temp-defaults :model/Collection) {:location (str "/" (:id no-allowed-content) "/")}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Cannot add anything to the Library collection"
|
||||
(t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :model, :collection_id (:id no-allowed-content)}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Cannot add anything to the Library collection"
|
||||
(t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :metric :collection_id (:id no-allowed-content)}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Cannot add anything to the Library collection"
|
||||
(t2/insert! :model/Dashboard (merge (mt/with-temp-defaults :model/Dashboard) {:collection_id (:id no-allowed-content)}))))))))
|
||||
(mt/with-temp [:model/Collection library (library-root-defaults)]
|
||||
(testing "folders of every library type can be created at the top level of the Library"
|
||||
(doseq [folder-type [collection/library-collection-type
|
||||
collection/library-data-collection-type
|
||||
collection/library-metrics-collection-type]]
|
||||
(testing (str "type " folder-type)
|
||||
(is (= 1 (t2/insert! :model/Collection (collection-under library folder-type)))))))
|
||||
(testing "the Library root rejects leaf content and untyped collections"
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Only folders can be added to the Library"
|
||||
(t2/insert! :model/Table (merge (mt/with-temp-defaults :model/Table)
|
||||
{:collection_id (:id library)
|
||||
:is_published true}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Only folders can be added to the Library"
|
||||
(t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :metric :collection_id (:id library)}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Only folders can be added to the Library"
|
||||
(t2/insert! :model/Dashboard (merge (mt/with-temp-defaults :model/Dashboard) {:collection_id (:id library)}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Only folders can be added to the Library"
|
||||
(t2/insert! :model/Collection (collection-under library nil))))))))
|
||||
|
||||
(deftest library-folder-holds-tables-metrics-and-folders
|
||||
(mt/with-premium-features #{:library}
|
||||
(mt/with-temp [:model/Collection library (library-root-defaults)
|
||||
:model/Collection folder {:name "My Folder"
|
||||
:type collection/library-collection-type
|
||||
:location (str "/" (:id library) "/")}]
|
||||
(testing "a Library folder holds published tables"
|
||||
(mt/with-temp [:model/Table table {:collection_id (:id folder)
|
||||
:is_published true}]
|
||||
(is (some? table))))
|
||||
(testing "a Library folder holds metrics"
|
||||
(mt/with-temp [:model/Card metric {:collection_id (:id folder)
|
||||
:type :metric}]
|
||||
(is (some? metric))))
|
||||
(testing "a Library folder holds nested Library folders"
|
||||
(is (= 1 (t2/insert! :model/Collection (collection-under folder collection/library-collection-type)))))
|
||||
(testing "a Library folder rejects everything else"
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Can only add tables, metrics, and folders to a Library folder"
|
||||
(t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :model :collection_id (:id folder)}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Can only add tables, metrics, and folders to a Library folder"
|
||||
(t2/insert! :model/Dashboard (merge (mt/with-temp-defaults :model/Dashboard) {:collection_id (:id folder)}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Can only add tables, metrics, and folders to a Library folder"
|
||||
(t2/insert! :model/Collection (collection-under folder nil))))))))
|
||||
|
||||
(deftest check-allowed-content-table
|
||||
(mt/with-premium-features #{:library}
|
||||
@@ -185,7 +232,7 @@
|
||||
|
||||
(deftest cannot-move-library-collections-to-vanilla-collection-test
|
||||
(mt/with-premium-features #{:library}
|
||||
(mt/with-temp [:model/Collection library-root {:name "Library" :type collection/library-collection-type}
|
||||
(mt/with-temp [:model/Collection library-root (library-root-defaults)
|
||||
:model/Collection data-root {:name "Data" :type collection/library-data-collection-type
|
||||
:location (str "/" (:id library-root) "/")}
|
||||
:model/Collection metrics-root {:name "Metrics" :type collection/library-metrics-collection-type
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
[metabase.mcp.v2.message :as message]
|
||||
[metabase.mcp.v2.redaction :as redaction]
|
||||
[metabase.mcp.v2.registry :as registry]
|
||||
[metabase.mcp.v2.test-util :as v2.tu]
|
||||
[metabase.mcp.v2.tools.content :as tools.content]
|
||||
[metabase.metabot.metadata-perms :as metadata-perms]
|
||||
[metabase.test :as mt]
|
||||
@@ -57,7 +58,7 @@
|
||||
:include ["fields"]})]
|
||||
(when error
|
||||
(throw (ex-info (str "get_content rejected: " (message/render (:message error))) {:error error})))
|
||||
(first (:results (json/decode+kw (-> result :content first :text))))))
|
||||
(first (:results (json/decode+kw (v2.tu/strip-data-boundary (-> result :content first :text)))))))
|
||||
|
||||
(deftest sandboxed-caller-does-not-receive-fingerprints-test
|
||||
(testing "a fingerprint is computed over every row of its table — `:min`/`:max` are individual cell
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.test :as met]
|
||||
[metabase.mcp.v2.test-util :as v2.tu]
|
||||
[metabase.mcp.v2.tools.browse :as tools.browse]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
@@ -19,7 +20,7 @@
|
||||
|
||||
(defn- get-fields
|
||||
[args]
|
||||
(let [text (-> (tools.browse/browse-data args {}) :content first :text)]
|
||||
(let [text (v2.tu/strip-data-boundary (-> (tools.browse/browse-data args {}) :content first :text))]
|
||||
(json/decode+kw (first (str/split-lines text)))))
|
||||
|
||||
(defn- field-named
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.mcp.v2.message :as message]
|
||||
[metabase.mcp.v2.registry :as registry]
|
||||
[metabase.mcp.v2.test-util :as v2.tu]
|
||||
;; registers the run_saved_question tool for the call-tool seam below
|
||||
[metabase.mcp.v2.tools.query]
|
||||
[metabase.test :as mt]
|
||||
@@ -48,7 +49,7 @@
|
||||
(defn- response-text
|
||||
"A dispatch outcome's text block, or a registry-level rejection's message."
|
||||
[{:keys [result error]}]
|
||||
(if error (message/render (:message error)) (-> result :content first :text)))
|
||||
(if error (message/render (:message error)) (-> result :content first :text v2.tu/strip-data-boundary)))
|
||||
|
||||
(defn- dispatch-error?
|
||||
"Whether a dispatch outcome is an error, at either layer: a registry-level rejection
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
(ns metabase-enterprise.remote-sync.api-test
|
||||
{:clj-kondo/config '{:linters {:deprecated-var {:exclude {metabase.test.data/mbql-query {:namespaces [metabase-enterprise.remote-sync.api-test]}}}}}}
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[diehard.core :as dh]
|
||||
[java-time.api :as t]
|
||||
@@ -13,10 +14,12 @@
|
||||
[metabase-enterprise.remote-sync.source.git :as source.git]
|
||||
[metabase-enterprise.remote-sync.source.protocol :as source.p]
|
||||
[metabase-enterprise.remote-sync.test-helpers :as test-helpers]
|
||||
[metabase.driver.settings :as driver.settings]
|
||||
[metabase.settings.core :as setting]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.quick-task :as quick-task]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -206,6 +209,79 @@
|
||||
(is (=? {:status "success" :task_id int?} resp))
|
||||
(is (remote-sync.task/successful? completed-task))))))))
|
||||
|
||||
(defn- gui-model-yaml
|
||||
"YAML for a GUI model over `db-name`'s PUBLIC.VENUES, in the shape an export writes: the result metadata
|
||||
carries only the model overrides, with no `base_type` and no field `id`."
|
||||
[entity-id collection-id db-name]
|
||||
(-> (test-helpers/generate-card-yaml entity-id "Venues Model" collection-id "model")
|
||||
(str/replace "database_id: test-data (h2)" (str "database_id: " db-name))
|
||||
(str/replace "dataset_query: {}"
|
||||
(format (str "dataset_query:\n"
|
||||
" database: %s\n"
|
||||
" type: query\n"
|
||||
" query:\n"
|
||||
" source-table:\n"
|
||||
" - %s\n"
|
||||
" - PUBLIC\n"
|
||||
" - VENUES")
|
||||
db-name db-name))
|
||||
(str/replace "result_metadata: null"
|
||||
(str "result_metadata:\n"
|
||||
(str/join (for [[col-name display-name] [["ID" "ID"]
|
||||
["NAME" "Venue Name"]
|
||||
["CATEGORY_ID" "Category ID"]
|
||||
["LATITUDE" "Latitude"]
|
||||
["LONGITUDE" "Longitude"]
|
||||
["PRICE" "Price"]]]
|
||||
(format "- display_name: %s\n name: %s\n visibility_type: normal\n"
|
||||
display-name col-name)))))))
|
||||
|
||||
(deftest import-gui-model-before-schema-sync-test
|
||||
(testing (str "GHY-4213: a GUI model imported before the target has synced its table stores untyped columns. "
|
||||
"Once the schema sync runs, the model's columns must get their types and field ids, so that "
|
||||
"the query builder offers typed filters on them.")
|
||||
(let [details (:details (mt/db))
|
||||
db-name (mt/random-name)
|
||||
collection-id "ghy4213collectionxxxx"
|
||||
card-eid "ghy4213modelxxxxxxxxx"]
|
||||
;; Keep the new database from being synced when it is created, so its tables do not exist yet at import.
|
||||
(mt/with-temporary-setting-values [disable-auto-sync true
|
||||
remote-sync-url "https://github.com/test/repo.git"
|
||||
remote-sync-token "test-token"
|
||||
remote-sync-branch "main"]
|
||||
(test-helpers/commit-with-temp
|
||||
(fn []
|
||||
(mt/with-temp [:model/Database {db-id :id} {:engine "h2" :details details :name db-name}]
|
||||
(let [source (test-helpers/create-mock-source
|
||||
:initial-files {"main" {(format "collections/%s_ghy/%s_ghy.yaml" collection-id collection-id)
|
||||
(test-helpers/generate-collection-yaml collection-id "GHY 4213" :is-remote-synced true)
|
||||
(format "collections/%s_ghy/cards/%s_venues_model.yaml" collection-id card-eid)
|
||||
(gui-model-yaml card-eid collection-id db-name)}})]
|
||||
(try
|
||||
(mt/with-dynamic-fn-redefs [source/source-from-settings (constantly source)]
|
||||
(let [{:keys [task_id]} (mt/user-http-request :crowberto :post 200 "ee/remote-sync/import"
|
||||
{:force true :expected_branch "main"})]
|
||||
(is (remote-sync.task/successful? (wait-for-task-completion task_id)))))
|
||||
(mt/with-dynamic-fn-redefs [quick-task/submit-task! (fn [f]
|
||||
(binding [driver.settings/*allow-testing-h2-connections* true]
|
||||
(f)))]
|
||||
(mt/user-http-request :crowberto :post 200 (format "database/%d/sync_schema" db-id)))
|
||||
(let [card-id (t2/select-one-pk :model/Card :entity_id card-eid)
|
||||
table-id (t2/select-one-pk :model/Table :db_id db-id :name "VENUES")
|
||||
field-id (fn [field-name]
|
||||
(t2/select-one-pk :model/Field :table_id table-id :name field-name))]
|
||||
(is (=? [{:name "ID" :base_type "type/BigInteger" :id (field-id "ID")}
|
||||
{:name "NAME" :base_type "type/Text" :id (field-id "NAME")
|
||||
:display_name "Venue Name"}
|
||||
{:name "CATEGORY_ID" :base_type "type/Integer" :id (field-id "CATEGORY_ID")}
|
||||
{:name "LATITUDE" :base_type "type/Float" :id (field-id "LATITUDE")}
|
||||
{:name "LONGITUDE" :base_type "type/Float" :id (field-id "LONGITUDE")}
|
||||
{:name "PRICE" :base_type "type/Integer" :id (field-id "PRICE")}]
|
||||
(:result_metadata (mt/user-http-request :crowberto :get 200 (str "card/" card-id))))))
|
||||
(finally
|
||||
(t2/delete! :model/Card :entity_id card-eid)
|
||||
(t2/delete! :model/Collection :entity_id collection-id)))))))))))
|
||||
|
||||
(deftest import-with-specific-branch-test
|
||||
(testing "POST /api/ee/remote-sync/import succeeds with specific branch"
|
||||
(let [mock-develop (test-helpers/create-mock-source :branch "develop")]
|
||||
@@ -239,6 +315,19 @@
|
||||
(is (some (comp #{"Local Metric"} :name) (:dirty_objects resp))
|
||||
"the response lists the un-pushed local metric"))))))))
|
||||
|
||||
(deftest import-without-expected-branch-succeeds-test
|
||||
(testing "GHY-4636: `mb git-sync import` sends only `branch`; the import must run without `expected_branch`"
|
||||
(let [mock-main (test-helpers/create-mock-source)]
|
||||
(mt/with-temporary-setting-values [remote-sync-url "https://github.com/test/repo.git"
|
||||
remote-sync-token "test-token"
|
||||
remote-sync-branch "main"]
|
||||
(mt/with-dynamic-fn-redefs [source/source-from-settings (constantly mock-main)]
|
||||
(let [{:keys [task_id] :as resp} (mt/user-http-request :crowberto :post 200 "ee/remote-sync/import"
|
||||
{:branch "main"})
|
||||
completed-task (wait-for-task-completion task_id)]
|
||||
(is (=? {:status "success" :task_id int?} resp))
|
||||
(is (remote-sync.task/successful? completed-task))))))))
|
||||
|
||||
(deftest import-rejects-expected-branch-mismatch-test
|
||||
(testing "POST /api/ee/remote-sync/import rejects when expected_branch disagrees with the configured setting"
|
||||
(let [mock-main (test-helpers/create-mock-source)]
|
||||
|
||||
@@ -3,9 +3,16 @@
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[diehard.core :as dh]
|
||||
[metabase-enterprise.remote-sync.settings :as settings]
|
||||
[metabase-enterprise.remote-sync.source :as source]
|
||||
[metabase-enterprise.remote-sync.source.protocol :as source.p]
|
||||
[metabase-enterprise.remote-sync.test-helpers :as test-helpers]
|
||||
[metabase.collections.models.collection :as collection]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.yaml :as yaml]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :each
|
||||
@@ -97,3 +104,96 @@
|
||||
(is (false? (:is_remote_synced response)))
|
||||
(is (nil? (t2/select-one-fn :parent_id :model/Collection :id (:id child-collection))))
|
||||
(is (false? (t2/select-one-fn :is_remote_synced :model/Collection :id (:id child-collection)))))))))
|
||||
|
||||
(defn- push-via-api!
|
||||
"Pushes through `POST /api/ee/remote-sync/export` and waits for the export task to finish."
|
||||
[]
|
||||
(let [{:keys [task_id]} (mt/user-http-request :crowberto :post 200 "ee/remote-sync/export" {:branch "main"})]
|
||||
(dh/with-retry {:max-retries 20 :delay-ms 250}
|
||||
(u/prog1 (t2/select-one :model/RemoteSyncTask :id task_id)
|
||||
(when (nil? (:ended_at <>))
|
||||
(throw (ex-info "Not finished" {:task-id task_id})))))))
|
||||
|
||||
(defn- exported-entity-ids
|
||||
"The entity_ids that have their own file in the current snapshot of `src`."
|
||||
[src]
|
||||
(let [snapshot (source.p/snapshot src)]
|
||||
(into #{}
|
||||
(keep (fn [path]
|
||||
(try (:entity_id (yaml/parse-string (source.p/read-file snapshot path)))
|
||||
(catch Exception _ nil))))
|
||||
(source.p/list-files snapshot))))
|
||||
|
||||
(deftest archive-collection-removes-nested-content-from-remote-test
|
||||
(testing "GHY-4399: archiving a collection inside a synced collection removes its whole subtree from the remote
|
||||
on the next push, not only the collection's own file"
|
||||
(test-helpers/clean-remote-sync-state
|
||||
(fn []
|
||||
(test-helpers/commit-with-temp
|
||||
(fn []
|
||||
(mt/with-premium-features #{:remote-sync}
|
||||
(mt/with-model-cleanup [:model/Card :model/Collection]
|
||||
(mt/with-temp [:model/Collection {synced-id :id} {:name "Synced" :is_remote_synced true :location "/"}]
|
||||
(let [src (test-helpers/versioned-source :current "v-remote" :trees {"v-remote" {}})]
|
||||
(mt/with-temporary-setting-values [remote-sync-url "https://github.com/test/repo.git"
|
||||
remote-sync-token "test-token"
|
||||
remote-sync-branch "main"]
|
||||
(mt/with-dynamic-fn-redefs [source/source-from-settings (constantly src)]
|
||||
(let [folder (mt/user-http-request :crowberto :post 200 "collection"
|
||||
{:name "Bug Repro" :parent_id synced-id})
|
||||
sub-folder (mt/user-http-request :crowberto :post 200 "collection"
|
||||
{:name "Sub Folder" :parent_id (:id folder)})
|
||||
new-card (fn [card-name coll-id]
|
||||
(mt/user-http-request :crowberto :post 200 "card"
|
||||
{:name card-name
|
||||
:display "table"
|
||||
:dataset_query (mt/mbql-query venues)
|
||||
:visualization_settings {}
|
||||
:collection_id coll-id}))
|
||||
card (new-card "Card In Folder" (:id folder))
|
||||
sub-card (new-card "Card In Sub Folder" (:id sub-folder))
|
||||
subtree (set (map :entity_id [folder sub-folder card sub-card]))]
|
||||
(is (=? {:outcome {:kind "pushed"}} (push-via-api!)) "first push pushes")
|
||||
(is (= subtree (into #{} (filter subtree) (exported-entity-ids src)))
|
||||
"precondition: the folder, its sub-folder, and both cards are on the remote")
|
||||
(mt/user-http-request :crowberto :put 200 (str "collection/" (:id folder)) {:archived true})
|
||||
(is (=? {:outcome {:kind "pushed"}} (push-via-api!)) "second push pushes")
|
||||
(is (= #{} (into #{} (filter subtree) (exported-entity-ids src)))
|
||||
"no file from the archived subtree stays on the remote"))))))))))))))
|
||||
|
||||
(deftest archive-transforms-folder-removes-its-transforms-from-remote-test
|
||||
(testing "GHY-4399: archiving a Transforms folder removes the transforms in it from the remote on the next push.
|
||||
Transforms have no archived column, so only their folder being archived says they left."
|
||||
(test-helpers/clean-remote-sync-state
|
||||
(fn []
|
||||
(test-helpers/commit-with-temp
|
||||
(fn []
|
||||
(mt/with-premium-features #{:remote-sync :transforms-basic}
|
||||
(mt/with-model-cleanup [:model/Transform :model/Collection]
|
||||
(let [src (test-helpers/versioned-source :current "v-remote" :trees {"v-remote" {}})]
|
||||
(mt/with-temporary-setting-values [remote-sync-url "https://github.com/test/repo.git"
|
||||
remote-sync-token "test-token"
|
||||
remote-sync-branch "main"
|
||||
remote-sync-transforms true]
|
||||
(mt/with-dynamic-fn-redefs [source/source-from-settings (constantly src)]
|
||||
(let [folder (mt/user-http-request :crowberto :post 200 "collection"
|
||||
{:name "Bug Repro" :namespace "transforms"})
|
||||
;; H2 cannot be a transform target, so POST /api/transform refuses it; insert the transform
|
||||
;; and publish the event that endpoint publishes
|
||||
transform-id (t2/insert-returning-pk! :model/Transform
|
||||
{:name "Transform In Folder"
|
||||
:collection_id (:id folder)
|
||||
:source {:type "query"
|
||||
:query (mt/native-query {:query "SELECT 1"})}
|
||||
:target {:type "table" :schema "PUBLIC" :name "ghy_4399"}})
|
||||
transform (t2/select-one :model/Transform :id transform-id)
|
||||
_ (events/publish-event! :event/transform-create
|
||||
{:object transform :user-id (mt/user->id :crowberto)})
|
||||
subtree (set (map :entity_id [folder transform]))]
|
||||
(is (=? {:outcome {:kind "pushed"}} (push-via-api!)) "first push pushes")
|
||||
(is (= subtree (into #{} (filter subtree) (exported-entity-ids src)))
|
||||
"precondition: the folder and its transform are on the remote")
|
||||
(mt/user-http-request :crowberto :put 200 (str "collection/" (:id folder)) {:archived true})
|
||||
(is (=? {:outcome {:kind "pushed"}} (push-via-api!)) "second push pushes")
|
||||
(is (= #{} (into #{} (filter subtree) (exported-entity-ids src)))
|
||||
"no file from the archived folder stays on the remote")))))))))))))
|
||||
|
||||
@@ -416,6 +416,98 @@
|
||||
:status "update"}
|
||||
(first entries)))))))
|
||||
|
||||
(defn- rso-statuses
|
||||
"`{[model_type model_id] status}` for every RemoteSyncObject."
|
||||
[]
|
||||
(into {} (map (juxt (juxt :model_type :model_id) :status)) (t2/select :model/RemoteSyncObject)))
|
||||
|
||||
(defn- with-synced-nested-subtree!
|
||||
"Calls `f` with `{:root :folder :sub-folder :card :sub-card :sibling-card}` ids: a remote-synced root holding a
|
||||
sibling card and a folder, which holds a card and a sub-folder with its own card. Every entity except the root
|
||||
has a synced RemoteSyncObject row."
|
||||
[f]
|
||||
(mt/with-temp [:model/Collection {root :id} {:name "Root" :is_remote_synced true :location "/"}
|
||||
:model/Collection {folder :id} {:name "Folder" :is_remote_synced true :location (str "/" root "/")}
|
||||
:model/Collection {sub-folder :id} {:name "Sub" :is_remote_synced true :location (str "/" root "/" folder "/")}
|
||||
:model/Card {card :id} {:name "Card" :collection_id folder}
|
||||
:model/Card {sub-card :id} {:name "Sub Card" :collection_id sub-folder}
|
||||
:model/Card {sibling-card :id} {:name "Sibling Card" :collection_id root}]
|
||||
(t2/delete! :model/RemoteSyncObject)
|
||||
(t2/insert! :model/RemoteSyncObject
|
||||
(for [[model-type model-id coll-id] [["Collection" folder folder]
|
||||
["Collection" sub-folder sub-folder]
|
||||
["Card" card folder]
|
||||
["Card" sub-card sub-folder]
|
||||
["Card" sibling-card root]]]
|
||||
{:model_type model-type :model_id model-id :model_collection_id coll-id
|
||||
:model_name "x" :status "synced" :status_changed_at (t/offset-date-time)}))
|
||||
(f {:root root :folder folder :sub-folder sub-folder :card card :sub-card sub-card :sibling-card sibling-card})))
|
||||
|
||||
(defn- set-subtree-archived!
|
||||
"Sets `archived` on `folder` and its contents, as archiving and unarchiving a collection do in bulk SQL."
|
||||
[{:keys [folder sub-folder card sub-card]} archived]
|
||||
(t2/update! :model/Collection :id [:in [folder sub-folder]] {:archived archived})
|
||||
(t2/update! :model/Card :id [:in [card sub-card]] {:archived archived}))
|
||||
|
||||
(deftest collection-update-event-archived-cascades-delete-to-subtree-test
|
||||
(testing "GHY-4399: archiving a nested collection marks its descendant collections and all contents of its subtree
|
||||
'delete', and leaves content outside the subtree alone"
|
||||
(with-synced-nested-subtree!
|
||||
(fn [{:keys [folder sub-folder card sub-card sibling-card] :as ids}]
|
||||
(set-subtree-archived! ids true)
|
||||
(events/publish-event! :event/collection-update
|
||||
{:object (t2/select-one :model/Collection :id folder) :user-id (mt/user->id :rasta)})
|
||||
(is (= {["Collection" folder] "delete"
|
||||
["Collection" sub-folder] "delete"
|
||||
["Card" card] "delete"
|
||||
["Card" sub-card] "delete"
|
||||
["Card" sibling-card] "synced"}
|
||||
(rso-statuses)))))))
|
||||
|
||||
(deftest collection-update-event-unarchived-restores-subtree-test
|
||||
(testing "GHY-4399: unarchiving a collection before the next push marks its unarchived contents 'update' again, so
|
||||
the push does not delete their files; content still archived on its own stays 'delete'"
|
||||
(with-synced-nested-subtree!
|
||||
(fn [{:keys [folder sub-folder card sub-card sibling-card] :as ids}]
|
||||
(set-subtree-archived! ids true)
|
||||
(events/publish-event! :event/collection-update
|
||||
{:object (t2/select-one :model/Collection :id folder) :user-id (mt/user->id :rasta)})
|
||||
(set-subtree-archived! ids false)
|
||||
(t2/update! :model/Card :id sub-card {:archived true})
|
||||
(events/publish-event! :event/collection-update
|
||||
{:object (t2/select-one :model/Collection :id folder) :user-id (mt/user->id :rasta)})
|
||||
(is (= {["Collection" folder] "update"
|
||||
["Collection" sub-folder] "update"
|
||||
["Card" card] "update"
|
||||
["Card" sub-card] "delete"
|
||||
["Card" sibling-card] "synced"}
|
||||
(rso-statuses)))))))
|
||||
|
||||
(deftest collection-update-event-archive-cascades-to-transforms-test
|
||||
(testing "GHY-4399: archiving a Transforms folder marks the transforms in it 'delete', and unarchiving it marks them
|
||||
'update' again; transforms have no archived column, so their folder's archived state decides"
|
||||
(mt/with-temporary-setting-values [remote-sync-transforms true]
|
||||
(mt/with-temp [:model/Collection {folder :id} {:name "Folder" :namespace "transforms" :location "/"}
|
||||
:model/Transform {transform :id} {:name "Transform" :collection_id folder}]
|
||||
(t2/delete! :model/RemoteSyncObject)
|
||||
(t2/insert! :model/RemoteSyncObject
|
||||
(for [[model-type model-id] [["Collection" folder] ["Transform" transform]]]
|
||||
{:model_type model-type :model_id model-id :model_collection_id folder
|
||||
:model_name "x" :status "synced" :status_changed_at (t/offset-date-time)}))
|
||||
(let [set-archived! (fn [archived]
|
||||
(t2/update! :model/Collection :id folder {:archived archived})
|
||||
(events/publish-event! :event/collection-update
|
||||
{:object (t2/select-one :model/Collection :id folder)
|
||||
:user-id (mt/user->id :rasta)}))]
|
||||
(set-archived! true)
|
||||
(is (= {["Collection" folder] "delete"
|
||||
["Transform" transform] "delete"}
|
||||
(rso-statuses)))
|
||||
(set-archived! false)
|
||||
(is (= {["Collection" folder] "update"
|
||||
["Transform" transform] "update"}
|
||||
(rso-statuses))))))))
|
||||
|
||||
(deftest collection-update-event-no-entry-for-normal-collection-test
|
||||
(testing "collection-update event doesn't create entry for non-remote-synced collections"
|
||||
(mt/with-temp [:model/Collection normal-collection {:name "Normal"}]
|
||||
|
||||
@@ -68,3 +68,19 @@
|
||||
(is (= nil
|
||||
(:based_on_upload (get-card))
|
||||
(:based_on_upload (get-collection-item))))))))))))
|
||||
|
||||
(deftest can-upload-false-for-sandboxed-user-test
|
||||
(mt/test-drivers (mt/normal-drivers-with-feature :uploads)
|
||||
(mt/dataset dataset
|
||||
(met/with-gtaps-for-user! :rasta {:gtaps {:venues {}}}
|
||||
(upload-test/with-uploads-enabled!
|
||||
(testing "Sanity check: an unsandboxed admin can upload to the same database"
|
||||
(is (true? (:can_upload (mt/user-http-request :crowberto :get 200 (str "database/" (mt/id)))))))
|
||||
(testing "GET /api/database/:id"
|
||||
(is (false? (:can_upload (mt/user-http-request :rasta :get 200 (str "database/" (mt/id)))))))
|
||||
(testing "GET /api/database"
|
||||
(is (false? (->> (mt/user-http-request :rasta :get 200 "database")
|
||||
:data
|
||||
(filter #(= (mt/id) (:id %)))
|
||||
first
|
||||
:can_upload)))))))))
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
(ns metabase-enterprise.semantic-search.db-test
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.semantic-search.db.datasource :as semantic.db.datasource])
|
||||
(:import
|
||||
(com.mchange.v2.c3p0 PoolBackedDataSource)
|
||||
(com.mchange.v2.c3p0 PoolBackedDataSource PooledDataSource)
|
||||
(java.sql SQLException SQLTimeoutException)
|
||||
(java.util.logging Handler Level LogRecord Logger)
|
||||
(javax.sql DataSource)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -39,6 +41,48 @@
|
||||
#"MB_PGVECTOR_DB_URL environment variable is required"
|
||||
(semantic.db.datasource/init-db!))))))
|
||||
|
||||
(deftest pool-hides-credentials-test
|
||||
(testing "the pool's string form, which c3p0 puts in its exception messages, leaves out the URL credentials"
|
||||
;; db-url is a value, not a fn, so with-dynamic-fn-redefs can't bind it
|
||||
(with-redefs [semantic.db.datasource/db-url "jdbc:postgresql://localhost:5432/mb?user=mb&password=hunter2"
|
||||
semantic.db.datasource/data-source (atom nil)]
|
||||
(let [pool ^PooledDataSource (semantic.db.datasource/init-db!)]
|
||||
(.close pool)
|
||||
(let [e (is (thrown-with-msg? SQLException #"has been closed" (.getConnection pool)))]
|
||||
(is (not (str/includes? (str (ex-message e)) "hunter2")))
|
||||
(is (not (str/includes? (str pool) "hunter2"))))))))
|
||||
|
||||
(deftest unrecognized-url-hides-credentials-test
|
||||
(testing "the error for a URL no driver accepts, which quotes the URL, leaves out the credentials"
|
||||
;; The driver rejects the `jdbc:postgres:` spelling, so DriverManager reports it with the URL.
|
||||
;; Then next.jdbc retries with the right spelling, and only a failed retry lets that error through.
|
||||
(with-redefs [semantic.db.datasource/db-url "jdbc:postgres://pgvector.invalid/mb?user=mb&password=hunter2"]
|
||||
(let [e (is (thrown-with-msg? SQLException #"No suitable driver"
|
||||
(semantic.db.datasource/probe-dedicated-connection!)))]
|
||||
(is (not (str/includes? (str (ex-message e)) "hunter2")))))))
|
||||
|
||||
(deftest pgjdbc-debug-log-hides-credentials-test
|
||||
(testing "the URL the driver logs at DEBUG leaves out the credentials"
|
||||
(let [^Logger logger (Logger/getLogger "org.postgresql.Driver")
|
||||
level (.getLevel logger)
|
||||
logged (atom [])
|
||||
handler (proxy [Handler] []
|
||||
(publish [^LogRecord record]
|
||||
(swap! logged conj [(.getMessage record) (vec (.getParameters record))]))
|
||||
(flush [])
|
||||
(close []))]
|
||||
(.setLevel logger Level/FINE)
|
||||
(.addHandler logger handler)
|
||||
(try
|
||||
;; the .invalid domain never resolves, and the driver logs the URL before it tries to connect
|
||||
(with-redefs [semantic.db.datasource/db-url "jdbc:postgresql://pgvector.invalid/mb?user=mb&password=hunter2"]
|
||||
(is (thrown? SQLException (semantic.db.datasource/probe-dedicated-connection!))))
|
||||
(finally
|
||||
(.removeHandler logger handler)
|
||||
(.setLevel logger level)))
|
||||
(is (=? [["Connecting with URL: {0}" ["jdbc:postgresql://pgvector.invalid/mb?connectTimeout=5&socketTimeout=10"]]]
|
||||
(filter #(str/starts-with? (first %) "Connecting with URL") @logged))))))
|
||||
|
||||
(deftest test-connection-before-init-test
|
||||
(testing "test-connection! throws exception when pool not initialized"
|
||||
(let [orig-data-source @semantic.db.datasource/data-source]
|
||||
@@ -64,6 +108,11 @@
|
||||
|
||||
(def ^:private base-url "jdbc:postgresql://localhost:5432/mb_semantic_search")
|
||||
|
||||
(defn- userinfo-url
|
||||
"[[base-url]] with `userinfo` written before the host."
|
||||
[userinfo]
|
||||
(str "jdbc:postgresql://" userinfo "@localhost:5432/mb_semantic_search"))
|
||||
|
||||
(deftest probe-jdbc-url-test
|
||||
(testing "the probe URL fills in the fail-fast timeouts the one-shot datasource would otherwise lack"
|
||||
;; db-url is a value, not a fn, so with-dynamic-fn-redefs can't bind it
|
||||
@@ -76,28 +125,29 @@
|
||||
(is (= (str base-url "?connectTimeout=5&socketTimeout=10")
|
||||
(probe-jdbc-url)))))
|
||||
(testing "unrelated connection params pass through, in the order they were written"
|
||||
(with-redefs [semantic.db.datasource/db-url (str base-url "?user=mb&sslmode=require")]
|
||||
(is (= (str base-url "?user=mb&sslmode=require&connectTimeout=5&socketTimeout=10")
|
||||
(with-redefs [semantic.db.datasource/db-url (str base-url "?tcpKeepAlive=true&sslmode=require")]
|
||||
(is (= (str base-url "?tcpKeepAlive=true&sslmode=require&connectTimeout=5&socketTimeout=10")
|
||||
(probe-jdbc-url))))))
|
||||
|
||||
(deftest parse-db-url-defaults-test
|
||||
(testing "a URL with no params leaves the URL untouched and uses the default pool props"
|
||||
;; strict = (not =?) so an unexpected extra/missing prop also fails, not just a wrong value
|
||||
(is (= {:jdbc-url base-url
|
||||
:pool-props {;; tunable knobs at their defaults
|
||||
"maxPoolSize" 5
|
||||
"minPoolSize" 0
|
||||
"initialPoolSize" 0
|
||||
"checkoutTimeout" 10000
|
||||
"unreturnedConnectionTimeout" 0
|
||||
"debugUnreturnedConnectionStackTraces" false
|
||||
"testConnectionOnCheckout" false
|
||||
;; fixed props operators can't override
|
||||
"idleConnectionTestPeriod" 60
|
||||
"maxIdleTimeExcessConnections" 600
|
||||
"maxConnectionAge" 1800
|
||||
"acquireIncrement" 1
|
||||
"dataSourceName" "metabase-semantic-search-db"}}
|
||||
(is (= {:jdbc-url base-url
|
||||
:credentials {}
|
||||
:pool-props {;; tunable knobs at their defaults
|
||||
"maxPoolSize" 5
|
||||
"minPoolSize" 0
|
||||
"initialPoolSize" 0
|
||||
"checkoutTimeout" 10000
|
||||
"unreturnedConnectionTimeout" 0
|
||||
"debugUnreturnedConnectionStackTraces" false
|
||||
"testConnectionOnCheckout" false
|
||||
;; fixed props operators can't override
|
||||
"idleConnectionTestPeriod" 60
|
||||
"maxIdleTimeExcessConnections" 600
|
||||
"maxConnectionAge" 1800
|
||||
"acquireIncrement" 1
|
||||
"dataSourceName" "metabase-semantic-search-db"}}
|
||||
(parse-db-url base-url)))))
|
||||
|
||||
(deftest parse-db-url-pool-knob-test
|
||||
@@ -113,10 +163,43 @@
|
||||
(testing "a recognized Postgres connection param stays on the URL for pgjdbc"
|
||||
(is (=? {:jdbc-url (str base-url "?tcpKeepAlive=true")}
|
||||
(parse-db-url (str base-url "?tcpKeepAlive=true")))))
|
||||
(testing "pool knobs are stripped while connection params + credentials are retained, in order"
|
||||
(is (=? {:jdbc-url (str base-url "?user=postgres&tcpKeepAlive=true")
|
||||
(testing "pool knobs are stripped while connection params are retained, in order"
|
||||
(is (=? {:jdbc-url (str base-url "?sslmode=require&tcpKeepAlive=true")
|
||||
:pool-props {"maxPoolSize" 8}}
|
||||
(parse-db-url (str base-url "?user=postgres&maxPoolSize=8&tcpKeepAlive=true"))))))
|
||||
(parse-db-url (str base-url "?sslmode=require&maxPoolSize=8&tcpKeepAlive=true")))))
|
||||
(testing "credentials move off the URL, decoded, to be passed as connection properties"
|
||||
(is (= {:jdbc-url (str base-url "?tcpKeepAlive=true")
|
||||
:credentials {:user "postgres"
|
||||
:password "p&ss=word"
|
||||
:sslpassword "k3y@pass"}}
|
||||
(select-keys (parse-db-url (str base-url "?user=postgres&password=p%26ss%3Dword&tcpKeepAlive=true"
|
||||
"&sslpassword=k3y%40pass"))
|
||||
[:jdbc-url :credentials]))))
|
||||
(testing "credentials in the host part come off the URL too, with %-escapes decoded and a `+` kept as is"
|
||||
(is (= {:jdbc-url (str base-url "?tcpKeepAlive=true")
|
||||
:credentials {:user "postgres", :password "p@ss:w+rd"}}
|
||||
(select-keys (parse-db-url (str (userinfo-url "postgres:p%40ss:w+rd") "?tcpKeepAlive=true"))
|
||||
[:jdbc-url :credentials]))))
|
||||
(testing "an unencoded `@` in a host-part password still leaves the whole password off the URL"
|
||||
(is (=? {:jdbc-url base-url, :credentials {:user "postgres", :password "p@ss"}}
|
||||
(parse-db-url (userinfo-url "postgres:p@ss")))))
|
||||
(testing "host-part credentials come off whatever the scheme, even one no driver accepts"
|
||||
;; CI spells the scheme `jdbc:postgres:`, and `jdbc:postgresq:` is a typo DriverManager would quote
|
||||
(doseq [scheme ["jdbc:postgres://" "jdbc:postgresq://"]]
|
||||
(is (=? {:jdbc-url (str scheme "localhost:5432/mb_semantic_search")
|
||||
:credentials {:user "postgres", :password "secret"}}
|
||||
(parse-db-url (str scheme "postgres:secret@localhost:5432/mb_semantic_search"))))))
|
||||
(testing "a credential set both in the host part and in the query throws, without quoting either value"
|
||||
(let [e (is (thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"The pgvector URL sets password both before the host and as a parameter"
|
||||
(parse-db-url (str (userinfo-url "postgres:secret") "?password=other"))))]
|
||||
(is (not (re-find #"secret|other" (str (ex-message e) (ex-data e)))))))
|
||||
(testing "an unencoded `@` in a query value passes through, as pgjdbc accepts it"
|
||||
(doseq [url [(str base-url "?user=mylogin@srv&password=p@ss")
|
||||
"jdbc:postgresql://?service=pgvector&user=mylogin@srv&password=p@ss"]]
|
||||
(is (=? {:credentials {:user "mylogin@srv", :password "p@ss"}}
|
||||
(parse-db-url url))))))
|
||||
|
||||
(deftest parse-db-url-validation-test
|
||||
(testing "an unrecognized param throws rather than being silently ignored by pgjdbc"
|
||||
@@ -128,7 +211,23 @@
|
||||
(is (thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"Invalid value for pgvector pool parameter maxPoolSize"
|
||||
(parse-db-url (str base-url "?maxPoolSize=lots"))))))
|
||||
(parse-db-url (str base-url "?maxPoolSize=lots")))))
|
||||
(testing "an `@` outside the host part throws without quoting the URL"
|
||||
(doseq [url ["jdbc:postgresql://alice:se/cret@db/mb" ; `/` in the password
|
||||
"jdbc:postgresql://alice:se?cret@db/mb" ; `?` in the password
|
||||
"jdbc:postgresql://alice:se?service=cret@db/mb" ; `?`, putting the `@` in a param
|
||||
"jdbc:postgresql://alice:s@e?cret@db/mb" ; `@` and then `?` in the password
|
||||
"jdbc:postgresql://alice:secret@db/mb@elsewhere"]] ; `@` in the database name
|
||||
(let [e (is (thrown? clojure.lang.ExceptionInfo (parse-db-url url)))]
|
||||
(is (=? {:message (str "MB_PGVECTOR_DB_URL has an unencoded @ outside its credentials. "
|
||||
"Percent-encode reserved characters, e.g. @ as %40, / as %2F and ? as %3F.")
|
||||
:data {}}
|
||||
{:message (ex-message e), :data (ex-data e)}))
|
||||
(is (not (re-find #"cret|elsewhere" (str (ex-message e) (ex-data e))))))))
|
||||
(testing "a malformed %-escape in a credential throws without quoting any of the value"
|
||||
(let [e (is (thrown? clojure.lang.ExceptionInfo (parse-db-url (str base-url "?password=hunter%zz"))))]
|
||||
(is (=? {:message "Malformed %-escape in MB_PGVECTOR_DB_URL", :data {}, :cause nil}
|
||||
{:message (ex-message e), :data (ex-data e), :cause (ex-cause e)})))))
|
||||
|
||||
(deftest pool-props-applied-by-c3p0-test
|
||||
(testing "c3p0 actually understands and applies every pool property name we set"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
import { act, screen, waitFor } from "__support__/ui";
|
||||
import { ensureMetabaseProviderPropsStore } from "embedding-sdk-shared/lib/ensure-metabase-provider-props-store";
|
||||
import type { GeneratedCard } from "metabase/api/ai-streaming/schemas";
|
||||
import { metabotActions } from "metabase/metabot/state";
|
||||
import { getMetabotState, metabotActions } from "metabase/metabot/state";
|
||||
import {
|
||||
createTestMetabotState,
|
||||
lastReqBody,
|
||||
@@ -430,21 +430,25 @@ describe("useMetabot", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
store.getState().metabot?.conversations?.[conversationId]?.messages
|
||||
.length,
|
||||
getMetabotState(store.getState()).conversations[conversationId]
|
||||
?.messages.length,
|
||||
).toBe(0);
|
||||
expect(store.getState().metabot?.agents?.omnibot?.visible).toBe(false);
|
||||
expect(getMetabotState(store.getState()).agents.omnibot?.visible).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId("submit-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
store.getState().metabot?.conversations?.[conversationId]?.messages
|
||||
.length,
|
||||
getMetabotState(store.getState()).conversations[conversationId]
|
||||
?.messages.length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(store.getState().metabot?.agents?.omnibot?.visible).toBe(false);
|
||||
expect(getMetabotState(store.getState()).agents.omnibot?.visible).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves to undefined even though agent.submitInput returns an action", async () => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Collection, CollectionId } from "metabase-types/api";
|
||||
import type { CollectionId } from "metabase-types/api";
|
||||
|
||||
export const findCollectionById = (
|
||||
collections: Collection[],
|
||||
export const findCollectionById = <
|
||||
T extends { id: CollectionId; children?: T[] },
|
||||
>(
|
||||
collections: T[],
|
||||
collectionId: CollectionId,
|
||||
): Collection | null => {
|
||||
): T | null => {
|
||||
if (!collections || collections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,11 @@ import userEvent from "@testing-library/user-event";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import {
|
||||
setupPropertiesEndpoints,
|
||||
setupSettingsEndpoints,
|
||||
setupUpdateSettingEndpoint,
|
||||
setupStatefulSettingsEndpoints,
|
||||
} from "__support__/server-mocks";
|
||||
import { mockSettings } from "__support__/settings";
|
||||
import { renderWithProviders, screen } from "__support__/ui";
|
||||
import { renderWithProviders, screen, waitFor } from "__support__/ui";
|
||||
import { createMockSettings } from "metabase-types/api/mocks";
|
||||
|
||||
import { MetabotCustomizationPage } from "./MetabotCustomizationPage";
|
||||
@@ -27,9 +26,8 @@ function setup({
|
||||
"metabot-show-illustrations": showIllustrations,
|
||||
});
|
||||
|
||||
setupPropertiesEndpoints(settings);
|
||||
setupStatefulSettingsEndpoints(settings);
|
||||
setupSettingsEndpoints([]);
|
||||
setupUpdateSettingEndpoint();
|
||||
|
||||
// Seed the store state too: without it, the render harness seeds the
|
||||
// settings bootstrap with *defaults*, and assertions can run against the
|
||||
@@ -54,10 +52,9 @@ describe("MetabotCustomizationPage", () => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Remove custom icon/ }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Metabot illustrations")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByAltText("Metabot illustration preview"),
|
||||
).not.toBeInTheDocument();
|
||||
screen.getByRole("switch", { name: /Show Metabot illustrations/ }),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
it("shows the remove button and illustrations toggle when a custom icon is set", async () => {
|
||||
@@ -80,7 +77,7 @@ describe("MetabotCustomizationPage", () => {
|
||||
expect(preview).toHaveAttribute("src", "data:image/png;base64,abc123");
|
||||
});
|
||||
|
||||
it("shows the illustrations toggle when illustrations are off, even with the default icon", async () => {
|
||||
it("keeps the illustrations toggle after turning illustrations on with the default icon", async () => {
|
||||
setup({ showIllustrations: false });
|
||||
|
||||
const toggle = await screen.findByRole("switch", {
|
||||
@@ -93,5 +90,10 @@ describe("MetabotCustomizationPage", () => {
|
||||
{ method: "PUT" },
|
||||
);
|
||||
expect(await call?.request?.json()).toEqual({ value: true });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("switch", { name: /Show Metabot illustrations/ }),
|
||||
).toBeChecked(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,6 @@ export function MetabotIconField() {
|
||||
const [iconError, setIconError] = useState("");
|
||||
|
||||
const isDefaultIcon = !metabotIcon || metabotIcon === "metabot";
|
||||
const isIllustrationsSectionVisible = !isDefaultIcon || !showIllustrations;
|
||||
const iconPreviewSrc =
|
||||
!isDefaultIcon && typeof metabotIcon === "string" ? metabotIcon : null;
|
||||
|
||||
@@ -162,40 +161,38 @@ export function MetabotIconField() {
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
{isIllustrationsSectionVisible && (
|
||||
<Stack mt="xl" gap="sm">
|
||||
<Text fz="md" fw="bold">
|
||||
{t`Metabot illustrations`}
|
||||
</Text>
|
||||
<Group gap="xl" align="center" wrap="nowrap">
|
||||
<Flex align="center" gap="sm" flex="1" miw={0}>
|
||||
<Box
|
||||
component="img"
|
||||
src={EmptyDashboardBot}
|
||||
alt={t`Metabot illustration preview`}
|
||||
w="3rem"
|
||||
h="3rem"
|
||||
flex="0 0 auto"
|
||||
/>
|
||||
<Text fz="md" c="text-secondary" flex="1">
|
||||
{t`Show Metabot illustrations in chat sidebar and AI exploration page`}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Switch
|
||||
aria-label={t`Show Metabot illustrations`}
|
||||
checked={!!showIllustrations}
|
||||
onChange={(e) =>
|
||||
updateShowIllustrations({
|
||||
key: "metabot-show-illustrations",
|
||||
value: e.currentTarget.checked,
|
||||
toast: false,
|
||||
})
|
||||
}
|
||||
disabled={isLoadingIllustrations}
|
||||
<Stack mt="xl" gap="sm">
|
||||
<Text fz="md" fw="bold">
|
||||
{t`Metabot illustrations`}
|
||||
</Text>
|
||||
<Group gap="xl" align="center" wrap="nowrap">
|
||||
<Flex align="center" gap="sm" flex="1" miw={0}>
|
||||
<Box
|
||||
component="img"
|
||||
src={EmptyDashboardBot}
|
||||
alt={t`Metabot illustration preview`}
|
||||
w="3rem"
|
||||
h="3rem"
|
||||
flex="0 0 auto"
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
<Text fz="md" c="text-secondary" flex="1">
|
||||
{t`Show Metabot illustrations in the chat sidebar`}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Switch
|
||||
aria-label={t`Show Metabot illustrations`}
|
||||
checked={!!showIllustrations}
|
||||
onChange={(e) =>
|
||||
updateShowIllustrations({
|
||||
key: "metabot-show-illustrations",
|
||||
value: e.currentTarget.checked,
|
||||
toast: false,
|
||||
})
|
||||
}
|
||||
disabled={isLoadingIllustrations}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,11 +14,6 @@ export const REGULAR_COLLECTION: CollectionAuthorityLevelConfig = {
|
||||
icon: "folder",
|
||||
};
|
||||
|
||||
export const REMOTE_SYNC_COLLECTION: CollectionInstanceAnaltyicsConfig = {
|
||||
type: null,
|
||||
icon: "synced_collection",
|
||||
};
|
||||
|
||||
export const OFFICIAL_COLLECTION: CollectionAuthorityLevelConfig = {
|
||||
type: "official",
|
||||
get name() {
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
filterOutItemsFromInstanceAnalytics,
|
||||
getCollectionType,
|
||||
isRegularCollection,
|
||||
isSyncedCollection,
|
||||
useGetIcon,
|
||||
} from "./utils";
|
||||
|
||||
@@ -34,10 +33,6 @@ import {
|
||||
* Initialize collections plugin features that depend on hasPremiumFeature.
|
||||
*/
|
||||
export function initializePlugin() {
|
||||
if (hasPremiumFeature("remote_sync")) {
|
||||
PLUGIN_COLLECTIONS.isSyncedCollection = isSyncedCollection;
|
||||
}
|
||||
|
||||
if (hasPremiumFeature("official_collections")) {
|
||||
PLUGIN_COLLECTIONS.isRegularCollection = isRegularCollection;
|
||||
PLUGIN_COLLECTIONS.REGULAR_COLLECTION = REGULAR_COLLECTION;
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
INSTANCE_ANALYTICS_COLLECTION,
|
||||
OFFICIAL_COLLECTION,
|
||||
REGULAR_COLLECTION,
|
||||
REMOTE_SYNC_COLLECTION,
|
||||
} from "./constants";
|
||||
|
||||
export function isRegularCollection({
|
||||
@@ -52,20 +51,11 @@ export function isInstanceAnalyticsCollection(
|
||||
);
|
||||
}
|
||||
|
||||
export function isSyncedCollection(
|
||||
collection: Pick<Collection, "is_remote_synced">,
|
||||
): boolean {
|
||||
return collection.is_remote_synced === true;
|
||||
}
|
||||
|
||||
export const useGetIcon = () => {
|
||||
const getIconBase = useGetIconBase();
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
item: ObjectWithModel,
|
||||
{ isTenantUser = false }: { isTenantUser?: boolean } = {},
|
||||
): IconData => {
|
||||
(item: ObjectWithModel): IconData => {
|
||||
const collectionType = getCollectionType({
|
||||
// Unjustified type cast. FIXME
|
||||
type: (item.type as CollectionType) || item.collection_type,
|
||||
@@ -77,18 +67,11 @@ export const useGetIcon = () => {
|
||||
}
|
||||
|
||||
if (item.model === "collection") {
|
||||
// Library collections keep their special icon regardless of sync status
|
||||
// Library collections keep their special icon regardless of authority level
|
||||
if (PLUGIN_LIBRARY.isLibraryCollectionType(item.type)) {
|
||||
return getIconBase(item);
|
||||
}
|
||||
|
||||
// tenant users see the normal icon, they don't know what a synced collection is
|
||||
if (item.is_remote_synced && !isTenantUser) {
|
||||
return {
|
||||
name: REMOTE_SYNC_COLLECTION.icon,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
item.authority_level === "official" ||
|
||||
item.collection_authority_level === "official"
|
||||
|
||||
@@ -109,20 +109,6 @@ describe("Collections plugin utils", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should return the correct icon for a remote synced collection", () => {
|
||||
const getIcon = createGetIcon();
|
||||
expect(
|
||||
getIcon({ model: "collection", is_remote_synced: true }),
|
||||
).toEqual({ name: "synced_collection" });
|
||||
});
|
||||
|
||||
it("should return the correct icon for a remote synced entity", () => {
|
||||
const getIcon = createGetIcon();
|
||||
expect(getIcon({ model: "dashboard", is_remote_synced: true })).toEqual(
|
||||
{ name: "dashboard" },
|
||||
);
|
||||
});
|
||||
|
||||
it("official collection in search", () => {
|
||||
const collection = {
|
||||
id: 101,
|
||||
|
||||
@@ -62,8 +62,7 @@ function LibraryPageContent() {
|
||||
isChildrenLoading,
|
||||
isLoading,
|
||||
emptyMessage,
|
||||
refreshTableCollections,
|
||||
refreshMetricCollections,
|
||||
refreshCollections,
|
||||
} = useLibraryTreeTableInstance({
|
||||
collections,
|
||||
isLoadingCollections,
|
||||
@@ -102,16 +101,13 @@ function LibraryPageContent() {
|
||||
.otherwise(() => undefined);
|
||||
|
||||
const handleActionComplete = useCallback(
|
||||
(section: LibrarySection, affectedCollectionIds: CollectionId[]) => {
|
||||
if (section === "data") {
|
||||
refreshTableCollections(affectedCollectionIds);
|
||||
} else if (section === "metrics") {
|
||||
refreshMetricCollections(affectedCollectionIds);
|
||||
}
|
||||
// Snippet sections refetch via RTK tag invalidation.
|
||||
(_section: LibrarySection, affectedCollectionIds: CollectionId[]) => {
|
||||
// Snippet sections refetch via RTK tag invalidation; library rows are refreshed explicitly
|
||||
// because their children are held in a local lazy-load cache.
|
||||
refreshCollections(affectedCollectionIds);
|
||||
clearSelection();
|
||||
},
|
||||
[refreshTableCollections, refreshMetricCollections, clearSelection],
|
||||
[refreshCollections, clearSelection],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -145,6 +141,8 @@ function LibraryPageContent() {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<CreateMenu
|
||||
libraryCollectionId={libraryCollection?.id}
|
||||
canWriteToLibrary={!!libraryCollection?.can_write}
|
||||
metricCollectionId={writableMetricCollection?.id}
|
||||
canWriteToMetricCollection={!!writableMetricCollection}
|
||||
dataCollectionId={tableCollection?.id}
|
||||
|
||||
@@ -14,12 +14,11 @@ import { RootSnippetsCollectionMenu } from "./RootSnippetsCollectionMenu";
|
||||
|
||||
type ActionCellProps = {
|
||||
treeItem: TreeItem;
|
||||
refreshTableCollections: (collectionIds: CollectionId[]) => void;
|
||||
refreshMetricCollections: (collectionIds: CollectionId[]) => void;
|
||||
refreshCollections: (collectionIds: CollectionId[]) => void;
|
||||
};
|
||||
|
||||
export function ActionCell(props: ActionCellProps) {
|
||||
const { treeItem, refreshTableCollections, refreshMetricCollections } = props;
|
||||
const { treeItem, refreshCollections } = props;
|
||||
const { data, children } = treeItem;
|
||||
|
||||
if (isEmptyStateData(data)) {
|
||||
@@ -27,7 +26,7 @@ export function ActionCell(props: ActionCellProps) {
|
||||
}
|
||||
|
||||
if (isTableData(data)) {
|
||||
return <TableMoreMenu table={data} onMoved={refreshTableCollections} />;
|
||||
return <TableMoreMenu table={data} onMoved={refreshCollections} />;
|
||||
}
|
||||
|
||||
if (!isCollectionData(data) || data.model !== "collection") {
|
||||
@@ -44,17 +43,14 @@ export function ActionCell(props: ActionCellProps) {
|
||||
return <CollectionRowMenu collection={data} />;
|
||||
}
|
||||
|
||||
const isLibraryCollection =
|
||||
PLUGIN_LIBRARY.isLibrarySubCollectionType(data.type) &&
|
||||
!data.is_library_root;
|
||||
|
||||
if (isLibraryCollection) {
|
||||
// Includes the seeded Data/Metrics roots: they can't be renamed or archived, but they can still
|
||||
// take a new subfolder, a custom icon and permission changes.
|
||||
if (PLUGIN_LIBRARY.isLibraryCollectionType(data.type)) {
|
||||
return (
|
||||
<LibraryCollectionRowMenu
|
||||
childCount={children?.length ?? 0}
|
||||
collection={data}
|
||||
refreshMetricCollections={refreshMetricCollections}
|
||||
refreshTableCollections={refreshTableCollections}
|
||||
refreshCollections={refreshCollections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,11 +19,15 @@ import type { CollectionId, CollectionNamespace } from "metabase-types/api";
|
||||
import { PublishTableModal } from "./PublishTableModal";
|
||||
|
||||
export const CreateMenu = ({
|
||||
libraryCollectionId,
|
||||
canWriteToLibrary,
|
||||
metricCollectionId,
|
||||
canWriteToMetricCollection,
|
||||
dataCollectionId,
|
||||
canWriteToDataCollection,
|
||||
}: {
|
||||
libraryCollectionId?: CollectionId;
|
||||
canWriteToLibrary?: boolean;
|
||||
metricCollectionId?: CollectionId;
|
||||
canWriteToMetricCollection?: boolean;
|
||||
dataCollectionId?: CollectionId;
|
||||
@@ -47,7 +51,10 @@ export const CreateMenu = ({
|
||||
const canCreateMetric =
|
||||
hasDataAccess && metricCollectionId && canWriteToMetricCollection;
|
||||
|
||||
const canCreateLibraryFolder = !!(libraryCollectionId && canWriteToLibrary);
|
||||
|
||||
const canCreateCollection =
|
||||
canCreateLibraryFolder ||
|
||||
(dataCollectionId && canWriteToDataCollection) ||
|
||||
(metricCollectionId && canWriteToMetricCollection) ||
|
||||
(hasNativeWrite && PLUGIN_SNIPPET_FOLDERS.isEnabled);
|
||||
@@ -55,6 +62,7 @@ export const CreateMenu = ({
|
||||
const collectionNamespaces: CollectionNamespace[] = [];
|
||||
|
||||
if (
|
||||
canCreateLibraryFolder ||
|
||||
(dataCollectionId && canWriteToDataCollection) ||
|
||||
(metricCollectionId && canWriteToMetricCollection)
|
||||
) {
|
||||
@@ -65,7 +73,9 @@ export const CreateMenu = ({
|
||||
collectionNamespaces.push("snippets");
|
||||
}
|
||||
|
||||
// Default to a new top-level folder in the Library; the picker lets you nest it instead.
|
||||
const initialCollectionId =
|
||||
(canCreateLibraryFolder && libraryCollectionId) ||
|
||||
(dataCollectionId && canWriteToDataCollection && dataCollectionId) ||
|
||||
(metricCollectionId && canWriteToMetricCollection && metricCollectionId) ||
|
||||
null;
|
||||
@@ -105,23 +115,24 @@ export const CreateMenu = ({
|
||||
canCreateCollection && (
|
||||
<Menu.Item
|
||||
key="collection"
|
||||
leftSection={<FixedSizeIcon name="folder" />}
|
||||
leftSection={<FixedSizeIcon name="new_folder" />}
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
setOpenModalWithProps({
|
||||
id: "collection",
|
||||
props: {
|
||||
title: t`New folder`,
|
||||
initialCollectionId,
|
||||
namespaces: collectionNamespaces,
|
||||
pickerOptions: LIBRARY_COLLECTION_PICKER_OPTIONS,
|
||||
showAuthorityLevelPicker: false,
|
||||
inDataStudio: true,
|
||||
showIconPicker: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Collection`}
|
||||
{t`Folder`}
|
||||
</Menu.Item>
|
||||
),
|
||||
].filter(Boolean);
|
||||
|
||||
@@ -15,6 +15,8 @@ import { CreateMenu } from "./CreateMenu";
|
||||
|
||||
interface SetupOptions {
|
||||
user?: Partial<User>;
|
||||
libraryCollectionId?: number;
|
||||
canWriteToLibrary?: boolean;
|
||||
dataCollectionId?: number;
|
||||
canWriteToDataCollection?: boolean;
|
||||
canWriteToMetricCollection?: boolean;
|
||||
@@ -31,6 +33,8 @@ const fullPermissionsUser: Partial<User> = {
|
||||
|
||||
const setup = ({
|
||||
user,
|
||||
libraryCollectionId = 7,
|
||||
canWriteToLibrary = true,
|
||||
dataCollectionId = 2,
|
||||
canWriteToDataCollection = true,
|
||||
canWriteToMetricCollection = true,
|
||||
@@ -50,6 +54,8 @@ const setup = ({
|
||||
setupEnterprisePlugins();
|
||||
const utils = renderWithProviders(
|
||||
<CreateMenu
|
||||
libraryCollectionId={libraryCollectionId}
|
||||
canWriteToLibrary={canWriteToLibrary}
|
||||
metricCollectionId={1}
|
||||
dataCollectionId={dataCollectionId}
|
||||
canWriteToDataCollection={canWriteToDataCollection}
|
||||
@@ -71,17 +77,17 @@ describe("CreateMenu", () => {
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("menuitem").map((item) => item.textContent),
|
||||
).toEqual(["Published table", "Metric", "Snippet", "Collection"]);
|
||||
).toEqual(["Published table", "Metric", "Snippet", "Folder"]);
|
||||
});
|
||||
|
||||
it("renders publish and collection options for data analysts", async () => {
|
||||
it("renders publish and folder options for data analysts", async () => {
|
||||
setup({ user: { is_data_analyst: true } });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /New/ }));
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("menuitem").map((item) => item.textContent),
|
||||
).toEqual(["Published table", "Collection"]);
|
||||
).toEqual(["Published table", "Folder"]);
|
||||
});
|
||||
|
||||
it("renders publish and metric options if user only has query builder access", async () => {
|
||||
@@ -91,7 +97,7 @@ describe("CreateMenu", () => {
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("menuitem").map((item) => item.textContent),
|
||||
).toEqual(["Published table", "Metric", "Collection"]);
|
||||
).toEqual(["Published table", "Metric", "Folder"]);
|
||||
});
|
||||
|
||||
it("does not render Metric option when canWriteToMetricCollection is false", async () => {
|
||||
@@ -110,10 +116,10 @@ describe("CreateMenu", () => {
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("menuitem").map((item) => item.textContent),
|
||||
).toEqual(["Published table", "Snippet", "Collection"]);
|
||||
).toEqual(["Published table", "Snippet", "Folder"]);
|
||||
});
|
||||
|
||||
it("renders Collection option when only Data collection is writable", async () => {
|
||||
it("renders Folder option when only Data collection is writable", async () => {
|
||||
setup({
|
||||
user: { is_data_analyst: true },
|
||||
canWriteToDataCollection: true,
|
||||
@@ -124,12 +130,13 @@ describe("CreateMenu", () => {
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("menuitem").map((item) => item.textContent),
|
||||
).toEqual(["Published table", "Collection"]);
|
||||
).toEqual(["Published table", "Folder"]);
|
||||
});
|
||||
|
||||
it("does not render Collection option without writable Library collections or native write", async () => {
|
||||
it("does not render Folder option without writable Library collections or native write", async () => {
|
||||
setup({
|
||||
user: {},
|
||||
canWriteToLibrary: false,
|
||||
canWriteToDataCollection: false,
|
||||
canWriteToMetricCollection: false,
|
||||
});
|
||||
@@ -141,20 +148,21 @@ describe("CreateMenu", () => {
|
||||
).toEqual(["Published table"]);
|
||||
});
|
||||
|
||||
it("opens the collection modal with Library and snippets picker options", async () => {
|
||||
it("defaults a new folder to the top level of the Library", async () => {
|
||||
const { store } = setup({
|
||||
user: fullPermissionsUser,
|
||||
libraryCollectionId: 7,
|
||||
dataCollectionId: 42,
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /New/ }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /Collection/ }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /Folder/ }));
|
||||
|
||||
expect(store.getState().modal).toEqual({
|
||||
id: "collection",
|
||||
props: {
|
||||
inDataStudio: true,
|
||||
initialCollectionId: 42,
|
||||
title: "New folder",
|
||||
initialCollectionId: 7,
|
||||
namespaces: [null, "snippets"],
|
||||
pickerOptions: {
|
||||
hasLibrary: true,
|
||||
@@ -166,19 +174,36 @@ describe("CreateMenu", () => {
|
||||
canCreateCollections: false,
|
||||
},
|
||||
showAuthorityLevelPicker: false,
|
||||
showIconPicker: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the Data collection when the Library root is not writable", async () => {
|
||||
const { store } = setup({
|
||||
user: fullPermissionsUser,
|
||||
canWriteToLibrary: false,
|
||||
dataCollectionId: 42,
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /New/ }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /Folder/ }));
|
||||
|
||||
expect(store.getState().modal.props).toMatchObject({
|
||||
initialCollectionId: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the collection modal scoped to snippets when only native write is available", async () => {
|
||||
const { store } = setup({
|
||||
user: { permissions: { can_create_native_queries: true } },
|
||||
canWriteToLibrary: false,
|
||||
canWriteToDataCollection: false,
|
||||
canWriteToMetricCollection: false,
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /New/ }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /Collection/ }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /Folder/ }));
|
||||
|
||||
expect(store.getState().modal.props).toMatchObject({
|
||||
initialCollectionId: null,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useCallback } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { CollectionRowMenu } from "metabase/common/collections/components/CollectionRowMenu";
|
||||
import CreateCollectionModal from "metabase/common/collections/containers/CreateCollectionModal";
|
||||
import type { Collection, CollectionId } from "metabase-types/api";
|
||||
|
||||
import { getArchiveLibraryCollectionsMessage } from "../utils";
|
||||
@@ -9,31 +12,16 @@ import { getArchiveLibraryCollectionsMessage } from "../utils";
|
||||
type LibraryCollectionRowMenuProps = {
|
||||
childCount: number;
|
||||
collection: Collection;
|
||||
refreshMetricCollections: (collectionIds: CollectionId[]) => void;
|
||||
refreshTableCollections: (collectionIds: CollectionId[]) => void;
|
||||
refreshCollections: (collectionIds: CollectionId[]) => void;
|
||||
};
|
||||
|
||||
export function LibraryCollectionRowMenu(props: LibraryCollectionRowMenuProps) {
|
||||
const {
|
||||
childCount,
|
||||
collection,
|
||||
refreshMetricCollections,
|
||||
refreshTableCollections,
|
||||
} = props;
|
||||
const { childCount, collection, refreshCollections } = props;
|
||||
const [isNewFolderModalOpen, { open: openNewFolder, close: closeNewFolder }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const isLibraryDataCollection =
|
||||
collection.type === "library-data" && !collection.is_library_root;
|
||||
const refreshCollections = useCallback(
|
||||
(collectionIds: CollectionId[]) => {
|
||||
if (collection.type === "library-metrics") {
|
||||
refreshMetricCollections(collectionIds);
|
||||
}
|
||||
|
||||
if (collection.type === "library-data") {
|
||||
refreshTableCollections(collectionIds);
|
||||
}
|
||||
},
|
||||
[refreshMetricCollections, refreshTableCollections, collection.type],
|
||||
);
|
||||
|
||||
const onArchiveSuccess = useCallback(() => {
|
||||
const parentId = getParentCollectionId(collection);
|
||||
@@ -45,19 +33,38 @@ export function LibraryCollectionRowMenu(props: LibraryCollectionRowMenuProps) {
|
||||
refreshCollections([parentId]);
|
||||
}, [collection, refreshCollections]);
|
||||
|
||||
const onFolderCreated = useCallback(() => {
|
||||
closeNewFolder();
|
||||
refreshCollections([collection.id]);
|
||||
}, [closeNewFolder, collection.id, refreshCollections]);
|
||||
|
||||
return (
|
||||
<CollectionRowMenu
|
||||
collection={collection}
|
||||
onSave={(details) => {
|
||||
refreshCollections(getAffectedCollectionIds(details));
|
||||
}}
|
||||
customArchiveMessage={
|
||||
isLibraryDataCollection && childCount > 0
|
||||
? getArchiveLibraryCollectionsMessage(1)
|
||||
: undefined
|
||||
}
|
||||
onArchiveSuccess={onArchiveSuccess}
|
||||
/>
|
||||
<>
|
||||
<CollectionRowMenu
|
||||
collection={collection}
|
||||
onNewFolder={openNewFolder}
|
||||
onSave={(details) => {
|
||||
refreshCollections(getAffectedCollectionIds(details));
|
||||
}}
|
||||
customArchiveMessage={
|
||||
isLibraryDataCollection && childCount > 0
|
||||
? getArchiveLibraryCollectionsMessage(1)
|
||||
: undefined
|
||||
}
|
||||
onArchiveSuccess={onArchiveSuccess}
|
||||
/>
|
||||
{isNewFolderModalOpen && (
|
||||
<CreateCollectionModal
|
||||
title={t`New folder`}
|
||||
initialCollectionId={collection.id}
|
||||
showCollectionPicker={false}
|
||||
showAuthorityLevelPicker={false}
|
||||
showIconPicker
|
||||
onCreate={onFolderCreated}
|
||||
onClose={closeNewFolder}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { setupEnterpriseOnlyPlugin } from "__support__/enterprise";
|
||||
import {
|
||||
setupCollectionByIdEndpoint,
|
||||
setupCreateCollectionEndpoint,
|
||||
setupUpdateCollectionEndpoint,
|
||||
} from "__support__/server-mocks";
|
||||
import { mockSettings } from "__support__/settings";
|
||||
@@ -25,8 +27,7 @@ function setup({
|
||||
}),
|
||||
childCount = 0,
|
||||
}: Partial<Parameters<typeof LibraryCollectionRowMenu>[0]> = {}) {
|
||||
const refreshMetricCollections = jest.fn();
|
||||
const refreshTableCollections = jest.fn();
|
||||
const refreshCollections = jest.fn();
|
||||
const parentCollection = createMockCollection({
|
||||
id: 22,
|
||||
name: "Data",
|
||||
@@ -36,16 +37,19 @@ function setup({
|
||||
setupEnterpriseOnlyPlugin("library");
|
||||
setupEnterpriseOnlyPlugin("remote_sync");
|
||||
setupUpdateCollectionEndpoint(collection);
|
||||
setupCollectionByIdEndpoint({ collections: [parentCollection] });
|
||||
setupCreateCollectionEndpoint(
|
||||
createMockCollection({ id: 99, name: "Finance", type: "library-data" }),
|
||||
);
|
||||
setupCollectionByIdEndpoint({ collections: [parentCollection, collection] });
|
||||
|
||||
renderWithProviders(
|
||||
<LibraryCollectionRowMenu
|
||||
childCount={childCount}
|
||||
collection={collection}
|
||||
refreshMetricCollections={refreshMetricCollections}
|
||||
refreshTableCollections={refreshTableCollections}
|
||||
refreshCollections={refreshCollections}
|
||||
/>,
|
||||
{
|
||||
withRouter: true,
|
||||
storeInitialState: createMockState({
|
||||
currentUser: createMockUser({ is_superuser: true }),
|
||||
settings: mockSettings({
|
||||
@@ -58,16 +62,20 @@ function setup({
|
||||
},
|
||||
);
|
||||
|
||||
return { refreshMetricCollections, refreshTableCollections };
|
||||
return { refreshCollections };
|
||||
}
|
||||
|
||||
async function openMenu() {
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Collection options" }),
|
||||
);
|
||||
}
|
||||
|
||||
describe("LibraryCollectionRowMenu", () => {
|
||||
it("refreshes table collections after saving a Library Data collection", async () => {
|
||||
const { refreshMetricCollections, refreshTableCollections } = setup();
|
||||
it("refreshes the parent collection after saving", async () => {
|
||||
const { refreshCollections } = setup();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Collection options" }),
|
||||
);
|
||||
await openMenu();
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitem", { name: /Edit collection details/ }),
|
||||
);
|
||||
@@ -75,42 +83,36 @@ describe("LibraryCollectionRowMenu", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshTableCollections).toHaveBeenCalledWith([22]);
|
||||
expect(refreshCollections).toHaveBeenCalledWith([22]);
|
||||
});
|
||||
expect(refreshMetricCollections).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes metric collections after saving a Library Metrics collection", async () => {
|
||||
const { refreshMetricCollections, refreshTableCollections } = setup({
|
||||
collection: createMockCollection({
|
||||
id: 2,
|
||||
name: "Library Metrics Collection",
|
||||
type: "library-metrics",
|
||||
parent_id: 22,
|
||||
}),
|
||||
});
|
||||
it("creates a subfolder in the clicked collection and refreshes it", async () => {
|
||||
const { refreshCollections } = setup();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Collection options" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitem", { name: /Edit collection details/ }),
|
||||
);
|
||||
await userEvent.type(screen.getByLabelText("Name"), " Updated");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await openMenu();
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /New folder/ }));
|
||||
await userEvent.type(screen.getByLabelText("Name"), "Finance");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshMetricCollections).toHaveBeenCalledWith([22]);
|
||||
expect(fetchMock.callHistory.called("create-collection")).toBe(true);
|
||||
});
|
||||
const call = fetchMock.callHistory.calls("create-collection").at(-1);
|
||||
expect(JSON.parse(String(call?.options?.body))).toMatchObject({
|
||||
name: "Finance",
|
||||
parent_id: 1,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshCollections).toHaveBeenCalledWith([1]);
|
||||
});
|
||||
expect(refreshTableCollections).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a table unpublish warning when archiving a non-empty Library Data collection", async () => {
|
||||
setup({ childCount: 1 });
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Collection options" }),
|
||||
);
|
||||
await openMenu();
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /Archive/ }));
|
||||
|
||||
expect(
|
||||
@@ -119,4 +121,27 @@ describe("LibraryCollectionRowMenu", () => {
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not offer rename or archive on a seeded Library section, but still offers a new folder", async () => {
|
||||
setup({
|
||||
collection: createMockCollection({
|
||||
id: 22,
|
||||
name: "Data",
|
||||
type: "library-data",
|
||||
is_library_root: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await openMenu();
|
||||
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /New folder/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /Edit collection details/ }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /Archive/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
skipToken,
|
||||
useListCollectionItemsQuery,
|
||||
} from "metabase/api";
|
||||
import type { IconData, ObjectWithModel } from "metabase/common/utils/icon";
|
||||
import type {
|
||||
LibrarySectionType,
|
||||
TreeItem,
|
||||
@@ -23,24 +24,29 @@ import type {
|
||||
CollectionItem,
|
||||
} from "metabase-types/api";
|
||||
|
||||
const LIBRARY_ITEM_MODELS = ["metric", "table", "collection"] as const;
|
||||
|
||||
type GetIcon = (item: ObjectWithModel) => IconData;
|
||||
|
||||
/** Builds the whole Library subtree from the Library root collection. The root itself is not
|
||||
* rendered: its children — the seeded Data and Metrics folders plus any folders the user created —
|
||||
* become the top-level rows. Everything below them is loaded lazily when a row is expanded. */
|
||||
export function useLibraryCollectionTree(
|
||||
collection: Collection | undefined,
|
||||
sectionType: LibrarySectionType,
|
||||
metricCollectionId?: CollectionId,
|
||||
libraryCollection: Collection | undefined,
|
||||
) {
|
||||
const dispatch = useDispatch();
|
||||
const getIcon = useGetIcon();
|
||||
|
||||
// 1. Fetch top-level items
|
||||
// 1. Fetch the Library root's children
|
||||
const {
|
||||
data: topLevelItems,
|
||||
isLoading,
|
||||
error,
|
||||
} = useListCollectionItemsQuery(
|
||||
collection
|
||||
libraryCollection
|
||||
? {
|
||||
id: collection.id,
|
||||
models: ["metric", "table", "collection"],
|
||||
id: libraryCollection.id,
|
||||
models: [...LIBRARY_ITEM_MODELS],
|
||||
archived: false,
|
||||
}
|
||||
: skipToken,
|
||||
@@ -57,7 +63,7 @@ export function useLibraryCollectionTree(
|
||||
useEffect(() => {
|
||||
setLoadedCollections(new Map());
|
||||
loadingIds.current = new Set();
|
||||
}, [collection]);
|
||||
}, [libraryCollection]);
|
||||
|
||||
const loadCollectionItems = useCallback(
|
||||
async (collectionId: CollectionId) => {
|
||||
@@ -71,7 +77,7 @@ export function useLibraryCollectionTree(
|
||||
collectionApi.endpoints.listCollectionItems.initiate(
|
||||
{
|
||||
id: collectionId,
|
||||
models: ["metric", "table", "collection"],
|
||||
models: [...LIBRARY_ITEM_MODELS],
|
||||
archived: false,
|
||||
},
|
||||
{ forceRefetch: true },
|
||||
@@ -86,8 +92,7 @@ export function useLibraryCollectionTree(
|
||||
const refreshCollections = useCallback(
|
||||
async (collectionIds: CollectionId[]) => {
|
||||
for (const id of collectionIds) {
|
||||
const key = String(id);
|
||||
loadingIds.current.delete(key);
|
||||
loadingIds.current.delete(String(id));
|
||||
}
|
||||
await Promise.all(collectionIds.map(loadCollectionItems));
|
||||
},
|
||||
@@ -96,43 +101,24 @@ export function useLibraryCollectionTree(
|
||||
|
||||
// 3. Build tree
|
||||
const tree = useMemo((): TreeItem[] => {
|
||||
if (isLoading || !topLevelItems || !collection) {
|
||||
if (isLoading || !topLevelItems || !libraryCollection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const children = buildChildren(
|
||||
topLevelItems.data,
|
||||
loadedCollections,
|
||||
getIcon,
|
||||
return sortTopLevelRows(
|
||||
buildChildren(
|
||||
topLevelItems.data,
|
||||
loadedCollections,
|
||||
getIcon,
|
||||
isRemoteSyncReadOnly,
|
||||
),
|
||||
);
|
||||
const hasItems = children.length > 0;
|
||||
|
||||
return [
|
||||
{
|
||||
name: collection.name,
|
||||
id: `collection:${collection.id}`,
|
||||
icon: getIcon({ ...collection, model: "collection" }).name,
|
||||
data: { ...collection, model: "collection" as const },
|
||||
model: "collection",
|
||||
children: hasItems
|
||||
? children
|
||||
: [
|
||||
createEmptyStateItem(
|
||||
sectionType,
|
||||
metricCollectionId,
|
||||
isRemoteSyncReadOnly,
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [
|
||||
isLoading,
|
||||
topLevelItems,
|
||||
collection,
|
||||
libraryCollection,
|
||||
loadedCollections,
|
||||
getIcon,
|
||||
sectionType,
|
||||
metricCollectionId,
|
||||
isRemoteSyncReadOnly,
|
||||
]);
|
||||
|
||||
@@ -183,7 +169,8 @@ export function useLibraryCollectionTree(
|
||||
function buildChildren(
|
||||
items: CollectionItem[],
|
||||
loadedCollections: Map<CollectionId, CollectionItem[]>,
|
||||
getIcon: ReturnType<typeof useGetIcon>,
|
||||
getIcon: GetIcon,
|
||||
isRemoteSyncReadOnly: boolean,
|
||||
): TreeItem[] {
|
||||
const visibleItems = items.filter((i) => !i.archived);
|
||||
const collections = visibleItems.filter((i) => i.model === "collection");
|
||||
@@ -195,16 +182,31 @@ function buildChildren(
|
||||
let children: TreeItem[] | undefined;
|
||||
|
||||
if (childItems !== undefined) {
|
||||
const built = buildChildren(childItems, loadedCollections, getIcon);
|
||||
const built = buildChildren(
|
||||
childItems,
|
||||
loadedCollections,
|
||||
getIcon,
|
||||
isRemoteSyncReadOnly,
|
||||
);
|
||||
children = built.length > 0 ? built : undefined;
|
||||
} else if (hasContent(col)) {
|
||||
children = [];
|
||||
}
|
||||
|
||||
// `children === undefined` means "resolved and empty" — either the fetch came back empty or
|
||||
// `here`/`below` told us there is nothing to fetch. The seeded Data/Metrics sections show a
|
||||
// pitch + call to action in that state instead of collapsing to a bare row.
|
||||
const sectionType = getSeededSectionType(col);
|
||||
if (children === undefined && sectionType != null) {
|
||||
children = [
|
||||
createEmptyStateItem(sectionType, col.id, isRemoteSyncReadOnly),
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
name: col.name,
|
||||
id: `collection:${col.id}`,
|
||||
icon: "folder",
|
||||
icon: getIcon({ ...col, model: "collection" }).name,
|
||||
data: col,
|
||||
model: "collection",
|
||||
children,
|
||||
@@ -215,10 +217,7 @@ function buildChildren(
|
||||
];
|
||||
}
|
||||
|
||||
function buildItemNode(
|
||||
item: CollectionItem,
|
||||
getIcon: ReturnType<typeof useGetIcon>,
|
||||
): TreeItem {
|
||||
function buildItemNode(item: CollectionItem, getIcon: GetIcon): TreeItem {
|
||||
return {
|
||||
name: item.name,
|
||||
updatedAt: item["last-edit-info"]?.timestamp,
|
||||
@@ -235,3 +234,40 @@ function hasContent(item: CollectionItem): boolean {
|
||||
(item.below != null && item.below.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** The `LibrarySectionType` of a seeded Library section, or null for user-created folders. */
|
||||
function getSeededSectionType(item: CollectionItem): LibrarySectionType | null {
|
||||
if (!item.is_library_root) {
|
||||
return null;
|
||||
}
|
||||
if (item.type === "library-data") {
|
||||
return "data";
|
||||
}
|
||||
if (item.type === "library-metrics") {
|
||||
return "metrics";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Sort rank of the seeded sections; everything the user created sorts after them. */
|
||||
const SEEDED_SECTION_RANK: Record<string, number> = {
|
||||
"library-data": 0,
|
||||
"library-metrics": 1,
|
||||
};
|
||||
const UNRANKED = 2;
|
||||
|
||||
/** Data and Metrics always lead; user-created top-level folders follow, alphabetically. */
|
||||
function sortTopLevelRows(rows: TreeItem[]): TreeItem[] {
|
||||
const rankOf = ({ data }: TreeItem) => {
|
||||
if (isEmptyStateData(data) || !("is_library_root" in data)) {
|
||||
return UNRANKED;
|
||||
}
|
||||
return data.is_library_root
|
||||
? (SEEDED_SECTION_RANK[String(data.type)] ?? UNRANKED)
|
||||
: UNRANKED;
|
||||
};
|
||||
|
||||
return [...rows].sort(
|
||||
(a, b) => rankOf(a) - rankOf(b) || a.name.localeCompare(b.name),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,25 +62,13 @@ export function useLibraryTreeTableInstance({
|
||||
useLibraryCollections(collections);
|
||||
|
||||
const {
|
||||
tree: tablesTree,
|
||||
isLoading: loadingTables,
|
||||
error: tablesError,
|
||||
watchRows: watchTableRows,
|
||||
isChildrenLoading: isTableChildrenLoading,
|
||||
refreshCollections: refreshTableCollections,
|
||||
} = useLibraryCollectionTree(tableCollection, "data");
|
||||
const {
|
||||
tree: metricsTree,
|
||||
isLoading: loadingMetrics,
|
||||
error: metricsError,
|
||||
watchRows: watchMetricRows,
|
||||
isChildrenLoading: isMetricChildrenLoading,
|
||||
refreshCollections: refreshMetricCollections,
|
||||
} = useLibraryCollectionTree(
|
||||
metricCollection,
|
||||
"metrics",
|
||||
metricCollection?.id,
|
||||
);
|
||||
tree: libraryTree,
|
||||
isLoading: loadingLibrary,
|
||||
error: libraryError,
|
||||
watchRows: watchLibraryRows,
|
||||
isChildrenLoading: isLibraryChildrenLoading,
|
||||
refreshCollections,
|
||||
} = useLibraryCollectionTree(libraryCollection);
|
||||
const {
|
||||
tree: snippetTree,
|
||||
isLoading: loadingSnippets,
|
||||
@@ -95,20 +83,16 @@ export function useLibraryTreeTableInstance({
|
||||
} = useLibrarySearch(searchQuery, libraryCollection?.id, snippetTree);
|
||||
|
||||
const combinedTree = useMemo(
|
||||
() =>
|
||||
isSearchActive
|
||||
? searchTree
|
||||
: [...tablesTree, ...metricsTree, ...snippetTree],
|
||||
[isSearchActive, searchTree, tablesTree, metricsTree, snippetTree],
|
||||
() => (isSearchActive ? searchTree : [...libraryTree, ...snippetTree]),
|
||||
[isSearchActive, searchTree, libraryTree, snippetTree],
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
isLoadingCollections ||
|
||||
loadingTables ||
|
||||
loadingMetrics ||
|
||||
loadingLibrary ||
|
||||
loadingSnippets ||
|
||||
isSearchLoading;
|
||||
useErrorHandling(tablesError || metricsError || snippetsError);
|
||||
useErrorHandling(libraryError || snippetsError);
|
||||
|
||||
const libraryHasContent = useMemo(
|
||||
() =>
|
||||
@@ -213,18 +197,12 @@ export function useLibraryTreeTableInstance({
|
||||
cell: ({ row }) => (
|
||||
<ActionCell
|
||||
treeItem={row.original}
|
||||
refreshMetricCollections={refreshMetricCollections}
|
||||
refreshTableCollections={refreshTableCollections}
|
||||
refreshCollections={refreshCollections}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
isRemoteSyncReadOnly,
|
||||
onPublishTableClick,
|
||||
refreshMetricCollections,
|
||||
refreshTableCollections,
|
||||
],
|
||||
[isRemoteSyncReadOnly, onPublishTableClick, refreshCollections],
|
||||
);
|
||||
|
||||
const snippetRootId = snippetTree[0]?.id;
|
||||
@@ -315,15 +293,8 @@ export function useLibraryTreeTableInstance({
|
||||
|
||||
// Lazy-load subcollection items when expanded
|
||||
useEffect(() => {
|
||||
watchTableRows(treeTableInstance.rows);
|
||||
watchMetricRows(treeTableInstance.rows);
|
||||
}, [treeTableInstance.rows, watchTableRows, watchMetricRows]);
|
||||
|
||||
const isChildrenLoading = useCallback(
|
||||
(row: Parameters<typeof isTableChildrenLoading>[0]) =>
|
||||
isTableChildrenLoading(row) || isMetricChildrenLoading(row),
|
||||
[isTableChildrenLoading, isMetricChildrenLoading],
|
||||
);
|
||||
watchLibraryRows(treeTableInstance.rows);
|
||||
}, [treeTableInstance.rows, watchLibraryRows]);
|
||||
|
||||
let emptyMessage = null;
|
||||
if (!libraryHasContent) {
|
||||
@@ -337,10 +308,9 @@ export function useLibraryTreeTableInstance({
|
||||
return {
|
||||
treeTableInstance,
|
||||
allRows,
|
||||
isChildrenLoading,
|
||||
isChildrenLoading: isLibraryChildrenLoading,
|
||||
isLoading,
|
||||
emptyMessage,
|
||||
refreshTableCollections,
|
||||
refreshMetricCollections,
|
||||
refreshCollections,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ export function DataStudioMetricAboutPage() {
|
||||
urls={dataStudioMetricUrls}
|
||||
showAppSwitcher
|
||||
showDataStudioLink={false}
|
||||
isInlineEditable
|
||||
showManagementPanels
|
||||
titleSize="h3"
|
||||
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ export function DataStudioMetricDependenciesPage() {
|
||||
urls={dataStudioMetricUrls}
|
||||
showAppSwitcher
|
||||
showDataStudioLink={false}
|
||||
isInlineEditable
|
||||
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ export function DataStudioMetricDimensionsPage() {
|
||||
urls={dataStudioMetricUrls}
|
||||
showAppSwitcher
|
||||
showDataStudioLink={false}
|
||||
isInlineEditable
|
||||
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ export function DataStudioMetricHistoryPage() {
|
||||
urls={dataStudioMetricUrls}
|
||||
showAppSwitcher
|
||||
showDataStudioLink={false}
|
||||
isInlineEditable
|
||||
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ export function DataStudioMetricOverviewPage() {
|
||||
urls={dataStudioMetricUrls}
|
||||
showAppSwitcher
|
||||
showDataStudioLink={false}
|
||||
isInlineEditable
|
||||
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ export function DataStudioMetricQueryPage() {
|
||||
urls={dataStudioMetricUrls}
|
||||
showAppSwitcher
|
||||
showDataStudioLink={false}
|
||||
isInlineEditable
|
||||
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import { allCollectionModels } from "metabase/common/components/Pickers/utils";
|
||||
import type {
|
||||
GetEntityPickerSyntheticLibraryItemFunction,
|
||||
LibraryCollectionType,
|
||||
LibrarySubCollectionType,
|
||||
} from "metabase/plugins/oss/library";
|
||||
import { useGetLibraryCollectionQuery } from "metabase-enterprise/api";
|
||||
@@ -128,7 +129,11 @@ export function getCollectionPickerItems({
|
||||
parentItem: OmniPickerItem;
|
||||
items: CollectionItem[];
|
||||
}): OmniPickerItem[] | undefined {
|
||||
if (parentItem.model !== "collection" || parentItem.type !== "library") {
|
||||
if (
|
||||
parentItem.model !== "collection" ||
|
||||
parentItem.type !== "library" ||
|
||||
!parentItem.is_library_root
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -137,17 +142,17 @@ export function getCollectionPickerItems({
|
||||
"library-metrics",
|
||||
];
|
||||
|
||||
return librarySubCollectionType.flatMap((type) => {
|
||||
const sectionItems = items.filter((item) =>
|
||||
const sectionItems = librarySubCollectionType.flatMap((type) => {
|
||||
const typedItems = items.filter((item) =>
|
||||
isLibrarySectionCollectionItem(item, type),
|
||||
);
|
||||
|
||||
const realRoot = sectionItems.find((item) => item.is_library_root);
|
||||
const realRoot = typedItems.find((item) => item.is_library_root);
|
||||
if (realRoot) {
|
||||
return [realRoot];
|
||||
}
|
||||
|
||||
if (sectionItems.length > 0) {
|
||||
if (typedItems.length > 0) {
|
||||
const syntheticItem = getEntityPickerSyntheticLibraryItem({
|
||||
collectionId: parentItem.id,
|
||||
type,
|
||||
@@ -158,6 +163,14 @@ export function getCollectionPickerItems({
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
// Folders the user created at the top level of the Library are plain `library` collections and
|
||||
// pass through untouched; the two seeded sections above are the only rewritten rows.
|
||||
const userFolders = items.filter(
|
||||
(item) => item.model === "collection" && item.type === "library",
|
||||
);
|
||||
|
||||
return [...sectionItems, ...userFolders];
|
||||
}
|
||||
|
||||
function isLibrarySectionCollectionItem(
|
||||
@@ -213,6 +226,6 @@ export const isLibraryDataCollectionType = (
|
||||
|
||||
export const isLibraryCollectionType = (
|
||||
type?: string | null,
|
||||
): type is LibrarySubCollectionType => {
|
||||
): type is LibraryCollectionType => {
|
||||
return isLibrarySubCollectionType(type) || type === "library";
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ const libraryParentItem: OmniPickerItem = {
|
||||
name: "Library",
|
||||
model: "collection",
|
||||
type: "library",
|
||||
is_library_root: true,
|
||||
};
|
||||
|
||||
describe("getCollectionPickerItems", () => {
|
||||
@@ -76,6 +77,52 @@ describe("getCollectionPickerItems", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should pass through folders the user created at the top level", () => {
|
||||
const dataRoot = createMockCollectionItem({
|
||||
id: 2,
|
||||
name: "Data",
|
||||
model: "collection",
|
||||
type: "library-data",
|
||||
is_library_root: true,
|
||||
});
|
||||
const userFolder = createMockCollectionItem({
|
||||
id: 8,
|
||||
name: "Finance",
|
||||
model: "collection",
|
||||
type: "library",
|
||||
});
|
||||
|
||||
expect(
|
||||
getCollectionPickerItems({
|
||||
parentItem: libraryParentItem,
|
||||
items: [dataRoot, userFolder],
|
||||
}),
|
||||
).toEqual([dataRoot, userFolder]);
|
||||
});
|
||||
|
||||
it("should return undefined for a user-created Library folder, so its children are listed as-is", () => {
|
||||
const userFolderParent: OmniPickerItem = {
|
||||
id: 8,
|
||||
name: "Finance",
|
||||
model: "collection",
|
||||
type: "library",
|
||||
};
|
||||
|
||||
expect(
|
||||
getCollectionPickerItems({
|
||||
parentItem: userFolderParent,
|
||||
items: [
|
||||
createMockCollectionItem({
|
||||
id: 9,
|
||||
name: "Nested",
|
||||
model: "collection",
|
||||
type: "library",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for non-Library parent items", () => {
|
||||
const regularParentItem: OmniPickerItem = {
|
||||
id: 6,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { userToColor } from "metabase/admin/people/colors";
|
||||
import { useListUsersQuery } from "metabase/api";
|
||||
import { UserAvatar } from "metabase/common/components/UserAvatar";
|
||||
import {
|
||||
@@ -203,7 +202,7 @@ export const AddDataAppUsers = ({
|
||||
p="0.5rem 1rem"
|
||||
onClick={() => addUser(user)}
|
||||
>
|
||||
<UserAvatar bg={userToColor(user)} user={user} />
|
||||
<UserAvatar user={user} />
|
||||
<Text fw="bold" size="lg">
|
||||
{user.common_name}
|
||||
</Text>
|
||||
|
||||
@@ -45,11 +45,13 @@ export function GdriveConnectionModal({
|
||||
const { data: { email: serviceAccountEmail } = {} } =
|
||||
useGetServiceAccountQuery(shouldShow ? undefined : skipToken);
|
||||
|
||||
const { data: gSheetData, error } = useGetGsheetsFolderQuery(
|
||||
shouldShow ? undefined : skipToken,
|
||||
);
|
||||
const {
|
||||
data: gSheetData,
|
||||
error,
|
||||
isLoading,
|
||||
} = useGetGsheetsFolderQuery(shouldShow ? undefined : skipToken);
|
||||
|
||||
if (!shouldShow) {
|
||||
if (!shouldShow || isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import {
|
||||
findRequests,
|
||||
@@ -22,9 +23,11 @@ import { GdriveConnectionModal } from "./GdriveConnectionModal";
|
||||
const setup = ({
|
||||
status,
|
||||
isAdmin = true,
|
||||
folderResponse,
|
||||
}: {
|
||||
status: GdrivePayload["status"];
|
||||
isAdmin?: boolean;
|
||||
folderResponse?: Promise<Partial<GdrivePayload>>;
|
||||
}) => {
|
||||
const settings = createMockSettings({
|
||||
"show-google-sheets-integration": true,
|
||||
@@ -39,6 +42,9 @@ const setup = ({
|
||||
setupGdriveGetFolderEndpoint({
|
||||
status,
|
||||
});
|
||||
if (folderResponse) {
|
||||
fetchMock.modifyRoute("gdrive-get-folder", { response: folderResponse });
|
||||
}
|
||||
setupGdriveServiceAccountEndpoint(
|
||||
"super-service-account@testing.metabase.com",
|
||||
);
|
||||
@@ -62,6 +68,20 @@ describe("Google Drive > Connect / Disconnect modal", () => {
|
||||
expect(await screen.findByText("Disconnect")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show connection modal while the connection status is loading", async () => {
|
||||
const { promise: folderResponse, resolve } =
|
||||
Promise.withResolvers<Partial<GdrivePayload>>();
|
||||
setup({ status: "active", folderResponse });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.callHistory.called("gdrive-get-folder")).toBe(true),
|
||||
);
|
||||
expect(screen.queryByText("Import Google Sheets")).not.toBeInTheDocument();
|
||||
|
||||
resolve({ status: "active" });
|
||||
expect(await screen.findByText("Disconnect")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show connection modal if not connected", async () => {
|
||||
setup({
|
||||
status: "not-connected",
|
||||
|
||||
@@ -194,7 +194,7 @@ exports[`DataComplexityCards renders the data complexity cards 2`] = `
|
||||
</div>
|
||||
<div
|
||||
data-accordion="true"
|
||||
data-variant="default"
|
||||
data-variant="separated"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
@@ -345,7 +345,7 @@ exports[`DataComplexityCards renders the data complexity cards 2`] = `
|
||||
</div>
|
||||
<div
|
||||
data-accordion="true"
|
||||
data-variant="default"
|
||||
data-variant="separated"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "__support__/ui";
|
||||
import { URL_UPDATE_DEBOUNCE_DELAY } from "metabase/common/hooks/use-url-state";
|
||||
import { Route } from "metabase/router";
|
||||
import { SEARCH_DEBOUNCE_DURATION } from "metabase/utils/constants";
|
||||
import type { RowValue } from "metabase-types/api";
|
||||
import {
|
||||
createMockColumn,
|
||||
@@ -187,28 +188,41 @@ describe("ErrorOverview", () => {
|
||||
});
|
||||
|
||||
it("recovers from an error when the search changes and the retry succeeds", async () => {
|
||||
mockGetBoundingClientRect({ width: 100, height: 100 });
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
try {
|
||||
mockGetBoundingClientRect({ width: 100, height: 100 });
|
||||
|
||||
let shouldFail = true;
|
||||
fetchMock.post("path:/api/dataset", () =>
|
||||
shouldFail
|
||||
? { status: 500, body: { message: "Audit query failed" } }
|
||||
: createDatasetResponse([{ id: 1, card_name: "Recovered question" }]),
|
||||
);
|
||||
let shouldFail = true;
|
||||
fetchMock.post("path:/api/dataset", () =>
|
||||
shouldFail
|
||||
? { status: 500, body: { message: "Audit query failed" } }
|
||||
: createDatasetResponse([{ id: 1, card_name: "Recovered question" }]),
|
||||
);
|
||||
|
||||
renderWithProviders(<Route path="/" element={<ErrorOverview />} />, {
|
||||
withRouter: true,
|
||||
});
|
||||
renderWithProviders(<Route path="/" element={<ErrorOverview />} />, {
|
||||
withRouter: true,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Audit query failed")).toBeInTheDocument();
|
||||
const search = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
|
||||
expect(await screen.findByText("Audit query failed")).toBeInTheDocument();
|
||||
const search = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
|
||||
|
||||
// changing the query is the only way to clear RTK's error and refire
|
||||
shouldFail = false;
|
||||
await userEvent.type(search, "recovered");
|
||||
// changing the query is the only way to clear RTK's error and refire
|
||||
shouldFail = false;
|
||||
await userEvent
|
||||
.setup({ advanceTimers: jest.advanceTimersByTime })
|
||||
.type(search, "recovered");
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(SEARCH_DEBOUNCE_DURATION);
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Recovered question")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Audit query failed")).not.toBeInTheDocument();
|
||||
// scoped to the row: TreeTable briefly renders a hidden copy of each
|
||||
// cell into document.body to measure auto-width columns
|
||||
const row = await screen.findByTestId("erroring-question");
|
||||
expect(within(row).getByText("Recovered question")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Audit query failed")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces an internal-query error returned in the dataset body", async () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ export const CollectionSyncRow = ({
|
||||
const icon = getIcon({
|
||||
model: "collection",
|
||||
type: collection.type,
|
||||
is_remote_synced: isChecked,
|
||||
icon: collection.icon,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { useListCollectionsQuery } from "metabase/api";
|
||||
import type { CollectionTreeItem } from "metabase/common/collections/utils";
|
||||
import { Tree } from "metabase/common/components/tree";
|
||||
import type { ITreeNodeItem } from "metabase/common/components/tree/types";
|
||||
import { SidebarCollectionLink } from "metabase/nav/containers/MainNavbar/SidebarItems";
|
||||
|
||||
import { useRemoteSyncDirtyState } from "../../hooks/use-remote-sync-dirty-state";
|
||||
import { CollectionSyncStatusBadge } from "../SyncedCollectionsSidebarSection/CollectionSyncStatusBadge";
|
||||
|
||||
interface CollectionsNavTreeProps {
|
||||
collections: CollectionTreeItem[];
|
||||
selectedId?: number | string;
|
||||
onSelect?: (item: ITreeNodeItem) => void;
|
||||
}
|
||||
|
||||
export const CollectionsNavTree = ({
|
||||
collections,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: CollectionsNavTreeProps) => {
|
||||
// Fetch flat list to check for remote-synced collections
|
||||
const { data: collectionsList = [] } = useListCollectionsQuery({
|
||||
archived: false,
|
||||
});
|
||||
|
||||
const hasRemoteSyncedCollections = useMemo(
|
||||
() => collectionsList.some((c) => c.is_remote_synced),
|
||||
[collectionsList],
|
||||
);
|
||||
|
||||
const { isCollectionDirty } = useRemoteSyncDirtyState();
|
||||
|
||||
const showChangesBadge = useCallback(
|
||||
(itemId?: number | string) => {
|
||||
if (!hasRemoteSyncedCollections) {
|
||||
return false;
|
||||
}
|
||||
return isCollectionDirty(itemId);
|
||||
},
|
||||
[hasRemoteSyncedCollections, isCollectionDirty],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tree
|
||||
data={collections}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
TreeNode={SidebarCollectionLink}
|
||||
role="tree"
|
||||
aria-label="collection-tree"
|
||||
rightSection={(item) =>
|
||||
showChangesBadge(item?.id) && <CollectionSyncStatusBadge />
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,214 +0,0 @@
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { mockSettings } from "__support__/settings";
|
||||
import { createMockState } from "__support__/state";
|
||||
import { renderWithProviders, screen, waitFor } from "__support__/ui";
|
||||
import { Route } from "metabase/router";
|
||||
import type { Collection } from "metabase-types/api";
|
||||
import { createMockCollection } from "metabase-types/api/mocks";
|
||||
|
||||
// Mock useRemoteSyncDirtyState
|
||||
const mockIsCollectionDirty = jest.fn(
|
||||
(_id: number | string | undefined) => false,
|
||||
);
|
||||
jest.mock("../../hooks/use-remote-sync-dirty-state", () => ({
|
||||
useRemoteSyncDirtyState: () => ({
|
||||
isCollectionDirty: mockIsCollectionDirty,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CollectionsNavTree } from "./CollectionsNavTree";
|
||||
|
||||
const createMockCollectionTreeItem = (
|
||||
overrides: Partial<Collection> = {},
|
||||
): Collection => ({
|
||||
...createMockCollection({
|
||||
id: 1,
|
||||
name: "Test Collection",
|
||||
is_remote_synced: false,
|
||||
...overrides,
|
||||
}),
|
||||
});
|
||||
|
||||
const setupEndpoints = ({
|
||||
collections = [createMockCollectionTreeItem()],
|
||||
}: {
|
||||
collections?: Collection[];
|
||||
} = {}) => {
|
||||
fetchMock.get("path:/api/collection", collections);
|
||||
};
|
||||
|
||||
const setup = ({
|
||||
collections = [createMockCollectionTreeItem()],
|
||||
collectionsList = [createMockCollectionTreeItem()],
|
||||
// Unjustified type cast. FIXME
|
||||
dirtyCollectionIds = [] as number[],
|
||||
}: {
|
||||
collections?: Collection[];
|
||||
collectionsList?: Collection[];
|
||||
dirtyCollectionIds?: number[];
|
||||
} = {}) => {
|
||||
setupEndpoints({ collections: collectionsList });
|
||||
|
||||
// Configure mock to return true for dirty collection IDs
|
||||
mockIsCollectionDirty.mockImplementation(
|
||||
(id: number | string | undefined) =>
|
||||
typeof id === "number" && dirtyCollectionIds.includes(id),
|
||||
);
|
||||
|
||||
return renderWithProviders(
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<CollectionsNavTree
|
||||
// Unjustified type cast. FIXME
|
||||
collections={collections as any}
|
||||
selectedId={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>
|
||||
}
|
||||
/>,
|
||||
{
|
||||
storeInitialState: createMockState({
|
||||
settings: mockSettings({}),
|
||||
}),
|
||||
withDND: true,
|
||||
withRouter: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe("CollectionsNavTree", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.clearHistory();
|
||||
mockIsCollectionDirty.mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("should render collections tree", async () => {
|
||||
const collection = createMockCollectionTreeItem({
|
||||
name: "My Collection",
|
||||
});
|
||||
setup({ collections: [collection] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Collection")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render multiple collections", async () => {
|
||||
const collection1 = createMockCollectionTreeItem({
|
||||
id: 1,
|
||||
name: "Collection 1",
|
||||
});
|
||||
const collection2 = createMockCollectionTreeItem({
|
||||
id: 2,
|
||||
name: "Collection 2",
|
||||
});
|
||||
setup({ collections: [collection1, collection2] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Collection 1")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Collection 2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dirty state badges", () => {
|
||||
it("should not show badge when no collections are remote-synced", async () => {
|
||||
const collection = createMockCollectionTreeItem({
|
||||
id: 1,
|
||||
name: "Regular Collection",
|
||||
is_remote_synced: false,
|
||||
});
|
||||
setup({
|
||||
collections: [collection],
|
||||
collectionsList: [collection],
|
||||
dirtyCollectionIds: [1],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Regular Collection")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Badge should not be present since collection is not remote-synced
|
||||
expect(
|
||||
screen.queryByTestId("remote-sync-status"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show badge when collection has dirty changes and is remote-synced", async () => {
|
||||
const collection = createMockCollectionTreeItem({
|
||||
id: 1,
|
||||
name: "Synced Collection",
|
||||
is_remote_synced: true,
|
||||
});
|
||||
setup({
|
||||
collections: [collection],
|
||||
collectionsList: [collection],
|
||||
dirtyCollectionIds: [1],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Synced Collection")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("remote-sync-status")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show badge for collection without dirty changes", async () => {
|
||||
const syncedCollection = createMockCollectionTreeItem({
|
||||
id: 1,
|
||||
name: "Clean Synced Collection",
|
||||
is_remote_synced: true,
|
||||
});
|
||||
setup({
|
||||
collections: [syncedCollection],
|
||||
collectionsList: [syncedCollection],
|
||||
dirtyCollectionIds: [],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Clean Synced Collection")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Badge should not be present since there are no dirty changes
|
||||
expect(
|
||||
screen.queryByTestId("remote-sync-status"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show badge only for collections with changes", async () => {
|
||||
const dirtyCollection = createMockCollectionTreeItem({
|
||||
id: 1,
|
||||
name: "Dirty Collection",
|
||||
is_remote_synced: true,
|
||||
});
|
||||
const cleanCollection = createMockCollectionTreeItem({
|
||||
id: 2,
|
||||
name: "Clean Collection",
|
||||
is_remote_synced: true,
|
||||
});
|
||||
setup({
|
||||
collections: [dirtyCollection, cleanCollection],
|
||||
collectionsList: [dirtyCollection, cleanCollection],
|
||||
dirtyCollectionIds: [1],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Dirty Collection")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Clean Collection")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
// Only one badge should be present
|
||||
const badges = screen.getAllByTestId("remote-sync-status");
|
||||
expect(badges).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { CollectionsNavTree } from "./CollectionsNavTree";
|
||||
@@ -3,22 +3,16 @@
|
||||
* rules in metabase/ui's Accordion.module.css. The compound selectors are written flat on purpose:
|
||||
* nesting them would make postcss-nesting wrap them in `:is()` and change the specificity math.
|
||||
*/
|
||||
.accordion .item,
|
||||
.accordion .item + .item,
|
||||
.accordion .item[data-active] {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Padding lives on the row around it, which the switch shares. */
|
||||
.accordion .control {
|
||||
background-color: transparent;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.accordion .label {
|
||||
color: var(--mb-color-text-primary);
|
||||
font-size: inherit;
|
||||
font-weight: normal;
|
||||
line-height: inherit;
|
||||
@@ -26,15 +20,10 @@
|
||||
}
|
||||
|
||||
.accordion .chevron {
|
||||
border: 0;
|
||||
color: var(--mb-color-text-secondary);
|
||||
height: auto;
|
||||
margin-left: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.accordion .content {
|
||||
border-top: none;
|
||||
padding-top: 1rem;
|
||||
padding-left: 3rem;
|
||||
}
|
||||
|
||||
@@ -134,12 +134,11 @@ const RequiredSyncItem = ({
|
||||
type: row.type,
|
||||
is_personal: row.personal,
|
||||
location: "/",
|
||||
is_remote_synced: isSynced,
|
||||
is_library_root: row.type === "library",
|
||||
});
|
||||
|
||||
return (
|
||||
<Accordion.Item value={row.key} mt="sm">
|
||||
<Accordion.Item value={row.key}>
|
||||
{/* The switch sits outside the control, so flipping it doesn't also toggle the panel. */}
|
||||
<Group
|
||||
gap="sm"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import ErrorBoundary from "metabase/common/components/ErrorBoundary";
|
||||
@@ -12,9 +11,6 @@ import type { SyncedCollectionsSidebarSectionProps } from "metabase/plugins/type
|
||||
import { Box, Flex, Group, Text } from "metabase/ui";
|
||||
|
||||
import { useGitSyncVisible } from "../../hooks/use-git-sync-visible";
|
||||
import { useRemoteSyncDirtyState } from "../../hooks/use-remote-sync-dirty-state";
|
||||
|
||||
import { CollectionSyncStatusBadge } from "./CollectionSyncStatusBadge";
|
||||
|
||||
export const SyncedCollectionsSidebarSection = ({
|
||||
onItemSelect,
|
||||
@@ -24,30 +20,6 @@ export const SyncedCollectionsSidebarSection = ({
|
||||
const hasSyncedCollections = syncedCollections.length > 0;
|
||||
|
||||
const { isVisible: isGitSyncVisible } = useGitSyncVisible();
|
||||
const { dirty, isCollectionDirty } = useRemoteSyncDirtyState();
|
||||
|
||||
const hasEntityRemoved = useMemo(
|
||||
() => dirty.some((entity) => entity.sync_status === "removed"),
|
||||
[dirty],
|
||||
);
|
||||
|
||||
const showChangesBadge = (itemId?: number | string) => {
|
||||
if (typeof itemId !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collectionIsUpdated = isCollectionDirty(itemId);
|
||||
const hasSingleRootCollection = syncedCollections.length === 1;
|
||||
|
||||
return (
|
||||
// Collection was updated
|
||||
collectionIsUpdated ||
|
||||
// An item was removed and this is the root synced collection (TODO: this is a workaround before we do UXW-2181)
|
||||
(hasEntityRemoved &&
|
||||
hasSingleRootCollection &&
|
||||
syncedCollections[0].id === itemId)
|
||||
);
|
||||
};
|
||||
|
||||
if (!isGitSyncVisible) {
|
||||
return null;
|
||||
@@ -77,9 +49,6 @@ export const SyncedCollectionsSidebarSection = ({
|
||||
TreeNode={SidebarCollectionLink}
|
||||
role="tree"
|
||||
aria-label="collection-tree"
|
||||
rightSection={(item) =>
|
||||
showChangesBadge(item?.id) && <CollectionSyncStatusBadge />
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -29,16 +29,6 @@ export function useRemoteSyncDirtyState(): RemoteSyncDirtyState {
|
||||
[dirty],
|
||||
);
|
||||
|
||||
const isCollectionDirty = useCallback(
|
||||
(collectionId: number | string | undefined) => {
|
||||
if (typeof collectionId !== "number") {
|
||||
return false;
|
||||
}
|
||||
return !!changedCollections[collectionId];
|
||||
},
|
||||
[changedCollections],
|
||||
);
|
||||
|
||||
const hasAnyCollectionDirty = useCallback(
|
||||
(collectionIds: Set<number> | number[]) => {
|
||||
const ids =
|
||||
@@ -78,7 +68,6 @@ export function useRemoteSyncDirtyState(): RemoteSyncDirtyState {
|
||||
isDirty,
|
||||
hasRemovedItems,
|
||||
isLoading,
|
||||
isCollectionDirty,
|
||||
hasAnyCollectionDirty,
|
||||
hasDirtyInCollectionTree,
|
||||
refetch,
|
||||
|
||||
@@ -92,45 +92,6 @@ describe("useRemoteSyncDirtyState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCollectionDirty", () => {
|
||||
it("returns false for collection without dirty items", async () => {
|
||||
const { result } = setup({
|
||||
dirty: [createMockDirtyEntity({ collection_id: 1 })],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.dirty.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(result.current.isCollectionDirty(2)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for collection with dirty items", async () => {
|
||||
const { result } = setup({
|
||||
dirty: [createMockDirtyEntity({ collection_id: 1 })],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.dirty.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(result.current.isCollectionDirty(1)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-numeric collection id", async () => {
|
||||
const { result } = setup({
|
||||
dirty: [createMockDirtyEntity({ collection_id: 1 })],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.dirty.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(result.current.isCollectionDirty("root")).toBe(false);
|
||||
expect(result.current.isCollectionDirty(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAnyCollectionDirty", () => {
|
||||
it("returns false when no collections in set are dirty", async () => {
|
||||
const { result } = setup({
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import { hasPremiumFeature } from "metabase-enterprise/settings";
|
||||
|
||||
import { LibraryNav } from "./LibraryNav";
|
||||
import { CollectionsNavTree } from "./components/CollectionsNavTree";
|
||||
import { GitSettingsModal } from "./components/GitSettingsModal";
|
||||
import { GitSyncControls } from "./components/GitSyncControls";
|
||||
import { GitSyncSetupMenuItem } from "./components/GitSyncSetupMenuItem";
|
||||
@@ -38,7 +37,6 @@ export function initializePlugin() {
|
||||
SyncedCollectionsSidebarSection;
|
||||
PLUGIN_REMOTE_SYNC.GitSyncAppBarControls = GitSyncControls;
|
||||
PLUGIN_REMOTE_SYNC.GitSettingsModal = GitSettingsModal;
|
||||
PLUGIN_REMOTE_SYNC.CollectionsNavTree = CollectionsNavTree;
|
||||
PLUGIN_REMOTE_SYNC.CollectionSyncStatusBadge = CollectionSyncStatusBadge;
|
||||
PLUGIN_REMOTE_SYNC.REMOTE_SYNC_INVALIDATION_TAGS =
|
||||
REMOTE_SYNC_INVALIDATION_TAGS;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
BulkActionButton,
|
||||
} from "metabase/common/components/BulkActionBar";
|
||||
import { GenericError } from "metabase/common/components/ErrorPages";
|
||||
import { useCloseNavbarOnMount } from "metabase/common/hooks/use-close-navbar-on-mount";
|
||||
import { getUserIsAdmin } from "metabase/current-user";
|
||||
import { useSelector } from "metabase/redux";
|
||||
import { useLocation, useParams } from "metabase/router";
|
||||
@@ -49,8 +48,6 @@ export const EditTableDataContainer = () => {
|
||||
const { dbId: dbIdParam, tableId: tableIdParam } =
|
||||
useParams<EditTableDataContainerParams>();
|
||||
|
||||
useCloseNavbarOnMount();
|
||||
|
||||
const databaseId = parseInt(dbIdParam ?? "", 10);
|
||||
const tableId = parseInt(tableIdParam ?? "", 10);
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
useCreateCollectionMutation,
|
||||
useListCollectionsQuery,
|
||||
} from "metabase/api";
|
||||
import { useCreateCollectionMutation } from "metabase/api";
|
||||
import { CreateCollectionForm } from "metabase/common/collections/components/CreateCollectionForm";
|
||||
import type { CreateCollectionProperties } from "metabase/common/collections/components/CreateCollectionForm/CreateCollectionForm";
|
||||
import { buildCollectionTree } from "metabase/common/collections/utils";
|
||||
@@ -23,8 +20,6 @@ import {
|
||||
tenantSpecificCollections,
|
||||
tenantUsersPersonalCollections,
|
||||
} from "metabase/urls";
|
||||
import { useGetRemoteSyncChangesQuery } from "metabase-enterprise/api";
|
||||
import { CollectionSyncStatusBadge } from "metabase-enterprise/remote_sync/components/SyncedCollectionsSidebarSection/CollectionSyncStatusBadge";
|
||||
import type { Collection } from "metabase-types/api";
|
||||
|
||||
export const MainNavSharedCollections = ({
|
||||
@@ -47,23 +42,6 @@ export const MainNavSharedCollections = ({
|
||||
const isTenantsEnabled = useSetting("use-tenants");
|
||||
const isAdmin = useSelector(getUserIsAdmin);
|
||||
|
||||
// Fetch flat list of tenant collections to check if any are remote-synced
|
||||
const { data: tenantCollectionsList = [] } = useListCollectionsQuery(
|
||||
{ namespace: "shared-tenant-collection" },
|
||||
{ skip: !isTenantsEnabled },
|
||||
);
|
||||
|
||||
// Check if any tenant collections have is_remote_synced=true
|
||||
const hasRemoteSyncedTenantCollections = useMemo(
|
||||
() => tenantCollectionsList.some((c) => c.is_remote_synced),
|
||||
[tenantCollectionsList],
|
||||
);
|
||||
|
||||
const { data: dirtyData } = useGetRemoteSyncChangesQuery(undefined, {
|
||||
skip: !hasRemoteSyncedTenantCollections,
|
||||
refetchOnFocus: true,
|
||||
});
|
||||
|
||||
const [createCollection] = useCreateCollectionMutation();
|
||||
|
||||
const sharedTenantCollectionTree = useMemo(
|
||||
@@ -71,26 +49,6 @@ export const MainNavSharedCollections = ({
|
||||
[sharedTenantCollections],
|
||||
);
|
||||
|
||||
const changedCollections = useMemo(
|
||||
() => dirtyData?.changedCollections ?? {},
|
||||
[dirtyData?.changedCollections],
|
||||
);
|
||||
|
||||
const showChangesBadge = useCallback(
|
||||
(itemId?: number | string) => {
|
||||
if (
|
||||
!hasRemoteSyncedTenantCollections ||
|
||||
!changedCollections ||
|
||||
typeof itemId !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!changedCollections[itemId];
|
||||
},
|
||||
[hasRemoteSyncedTenantCollections, changedCollections],
|
||||
);
|
||||
|
||||
const handleCreateTenantCollection = useCallback(
|
||||
async (values: CreateCollectionProperties) => {
|
||||
await createCollection({
|
||||
@@ -142,9 +100,6 @@ export const MainNavSharedCollections = ({
|
||||
TreeNode={SidebarCollectionLink}
|
||||
role="tree"
|
||||
aria-label="tenant-collection-tree"
|
||||
rightSection={(item) =>
|
||||
showChangesBadge(item?.id) && <CollectionSyncStatusBadge />
|
||||
}
|
||||
/>
|
||||
{canAccessTenantSpecificCollections && (
|
||||
<PaddedSidebarLink icon="group" url={tenantSpecificCollections()}>
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "metabase/ui";
|
||||
import { tenantIdToColor } from "metabase-enterprise/tenants/utils/colors";
|
||||
import * as Urls from "metabase-enterprise/urls";
|
||||
import type { Tenant } from "metabase-types/api";
|
||||
|
||||
@@ -99,10 +98,7 @@ export const TenantsListing = ({
|
||||
className={CS.link}
|
||||
gap="lg"
|
||||
>
|
||||
<UserAvatar
|
||||
user={{ name: tenant.name }}
|
||||
bg={tenantIdToColor(tenant.id)}
|
||||
/>
|
||||
<UserAvatar user={{ name: tenant.name }} />
|
||||
<Box component="span" fw={700} c="core-brand">
|
||||
{tenant.name}
|
||||
</Box>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { color } from "metabase/ui/colors";
|
||||
import type { Tenant } from "metabase-types/api";
|
||||
|
||||
const groupColorPalette = [
|
||||
color("feedback-negative"),
|
||||
color("accent2"),
|
||||
color("core-brand"),
|
||||
color("accent4"),
|
||||
color("accent1"),
|
||||
];
|
||||
|
||||
export const tenantIdToColor = (tenantId: Tenant["id"]) => {
|
||||
return groupColorPalette[tenantId % groupColorPalette.length];
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user