Compare commits
63 Commits
readable-c
...
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 | ||
|
|
0694a11c90 | ||
|
|
fa20179ff6 | ||
|
|
cdc7f386af | ||
|
|
d994a365f7 | ||
|
|
d7b9a71698 | ||
|
|
12a22c0392 | ||
|
|
6ec07f7518 | ||
|
|
aa9c3797e8 | ||
|
|
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 573 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 374 KiB After Width: | Height: | Size: 518 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
@@ -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
|
||||
|
||||
|
||||
2821
docs/api.json
generated
@@ -165,7 +165,6 @@ config:
|
||||
help-link: metabase
|
||||
help-link-custom-destination: https://www.metabase.com/help/premium
|
||||
hide-stacktraces: false
|
||||
http-channel-allowed-networks: external-only
|
||||
humanization-strategy: simple
|
||||
index-update-thread-count: 2
|
||||
jdbc-data-warehouse-connection-pool-checkout-timeout-ms: 0
|
||||
@@ -253,7 +252,6 @@ config:
|
||||
loading-message: doing-science
|
||||
login-page-illustration: default
|
||||
login-page-illustration-custom: null
|
||||
map-tile-server-allowed-networks: null
|
||||
map-tile-server-url: https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
mcp-apps-cors-custom-origins: ''
|
||||
mcp-apps-cors-enabled-clients: []
|
||||
@@ -284,7 +282,6 @@ config:
|
||||
notification-link-base-url: null
|
||||
notification-system-event-thread-pool-size: 5
|
||||
notification-thread-pool-size: 3
|
||||
oidc-allowed-networks: allow-all
|
||||
oidc-providers: []
|
||||
oidc-user-provisioning-enabled: true
|
||||
persisted-model-refresh-cron-schedule: 0 0 0/6 * * ? *
|
||||
@@ -384,5 +381,4 @@ config:
|
||||
use-native-pivot-tables: false
|
||||
use-tenants: false
|
||||
user-visibility: all
|
||||
warehouse-allowed-networks: null
|
||||
```
|
||||
|
||||
@@ -887,6 +887,7 @@ Prevent the exception middleware from including stacktraces in responses.
|
||||
|
||||
- Type: keyword
|
||||
- Default: `external-only`
|
||||
- Environment variable only: you can't set this in the Admin settings or in a [configuration file](./config-file.md).
|
||||
|
||||
Controls which types of hosts are allowed as HTTP channel destinations.
|
||||
Options:
|
||||
@@ -895,6 +896,8 @@ Options:
|
||||
- allow-all (no restrictions including localhost).
|
||||
.
|
||||
|
||||
Set this when a notification webhook must reach a host on your private network (`allow-private`) or on this machine (`allow-all`). Default is `external-only`
|
||||
|
||||
### `MB_HUMANIZATION_STRATEGY`
|
||||
|
||||
- Type: keyword
|
||||
@@ -1770,6 +1773,7 @@ The custom illustration for the login page.
|
||||
|
||||
- Type: keyword
|
||||
- Default: `null`
|
||||
- Environment variable only: you can't set this in the Admin settings or in a [configuration file](./config-file.md).
|
||||
|
||||
Controls which networks Metabase may connect to for map tile servers.
|
||||
Options:
|
||||
@@ -1778,6 +1782,8 @@ Controls which networks Metabase may connect to for map tile servers.
|
||||
- allow-all (no restrictions).
|
||||
Defaults to external-only on Metabase Cloud and allow-private when self-hosted.
|
||||
|
||||
Allowed network for the map tile server.
|
||||
|
||||
### `MB_MAP_TILE_SERVER_URL`
|
||||
|
||||
- Type: string
|
||||
@@ -1786,6 +1792,16 @@ Controls which networks Metabase may connect to for map tile servers.
|
||||
|
||||
The map tile server URL template used in map visualizations, for example from OpenStreetMaps or MapBox. This URL is visible to clients, so do not include private keys.
|
||||
|
||||
### `MB_MAX_UNAUTHENTICATED_REQUEST_BODY_BYTES`
|
||||
|
||||
- Type: integer
|
||||
- Default: `4194304`
|
||||
- Environment variable only: you can't set this in the Admin settings or in a [configuration file](./config-file.md).
|
||||
|
||||
Maximum size in bytes of an HTTP request body from a caller who is not authenticated. Larger requests are rejected with HTTP 413 before the body is read.
|
||||
|
||||
Applies to requests without a valid session, API key or token. The body is bounded before authentication is checked, so this is the most memory an anonymous request can make the server buffer. It covers login, setup, password reset, SSO callbacks, and public or embedded queries; none of those legitimately send more than a few hundred kilobytes. Authenticated requests are not limited.
|
||||
|
||||
### `MB_MCP_APPS_CORS_CUSTOM_ORIGINS`
|
||||
|
||||
- Type: string
|
||||
@@ -2014,10 +2030,12 @@ If Metabase stops sending notifications like alerts, it may be because long-runn
|
||||
|
||||
- Type: keyword
|
||||
- Default: `allow-all`
|
||||
- [Configuration file name](./config-file.md): `oidc-allowed-networks`
|
||||
- Environment variable only: you can't set this in the Admin settings or in a [configuration file](./config-file.md).
|
||||
|
||||
What networks are OIDC requests allowed to? Possible values: 'allow-all' (default), 'allow-private', or 'external-only'.
|
||||
|
||||
Set this to tighten which networks OIDC discovery and token requests may reach; it defaults to allow-all. Other values: external-only and allow-private
|
||||
|
||||
### `MB_OIDC_PROVIDERS`
|
||||
|
||||
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
@@ -2164,7 +2182,7 @@ Network timeout (in seconds) for remote git operations such as fetch, push, clon
|
||||
- Default: `300000`
|
||||
- [Configuration file name](./config-file.md): `remote-sync-task-time-limit-ms`
|
||||
|
||||
The maximum amount of time a remote sync task will be given to complete.
|
||||
How long a remote sync task may go without proving its process is alive (via heartbeat, or progress on rows without one) before it is treated as dead and superseded. A slow but live task is never affected. The task itself is aborted after ten times this limit.
|
||||
|
||||
### `MB_REMOTE_SYNC_TOKEN`
|
||||
|
||||
@@ -2983,6 +3001,7 @@ Note: Users with row or column security restrictions will never see suggestions.
|
||||
|
||||
- Type: keyword
|
||||
- Default: `null`
|
||||
- Environment variable only: you can't set this in the Admin settings or in a [configuration file](./config-file.md).
|
||||
|
||||
Controls which networks Metabase may connect to for warehouse connections.
|
||||
Options:
|
||||
@@ -2992,6 +3011,8 @@ Options:
|
||||
Defaults to external-only on Metabase Cloud and allow-all when self-hosted.
|
||||
Also covers the SSH tunnel host and the database auth-provider URLs.
|
||||
|
||||
Set this when Metabase must reach a warehouse on a private network (allow-private) or on this machine (allow-all). There is no admin UI for it, and a value stored in the application database is ignored. Defaults to external-only on Metabase Cloud and allow-all when self-hosted. Metabase refuses to start if this is set to anything but one of the three policies, rather than run on a policy nobody chose.
|
||||
|
||||
## Other environment variables
|
||||
|
||||
The following environment variables can only be set via the environment. They cannot be set by the configuration file.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,6 +4,12 @@ title: Driver interface changelog
|
||||
|
||||
# Driver Interface Changelog
|
||||
|
||||
## Metabase 0.65.0
|
||||
|
||||
- `sql.qp/use-ctes-for-stages?` is a multi-method for drivers to opt-in to compiling multi-stage queries
|
||||
with CTEs instead of nested subselects. Drivers should only do this if they satisfy all of the criteria
|
||||
in the docstring of this method.
|
||||
|
||||
## Metabase 0.64.0
|
||||
|
||||
- `metabase.driver.sql.normalize/default-schema` now takes the database as well as the driver:
|
||||
@@ -84,6 +90,10 @@ title: Driver interface changelog
|
||||
- `metabase.driver/compile-create-index` `[driver schema table structured]` -- compiles a `:standalone` index into
|
||||
the DDL statement(s) that create it.
|
||||
|
||||
- `metabase.driver/humanize-index-error-message` `[driver message]` -- trims the message of an exception raised by
|
||||
`fetch-table-indexes` or by index DDL before it is shown to the user. Defaults to returning it unchanged;
|
||||
ClickHouse drops the `(version ...) (queryId=...)` tail its server appends.
|
||||
|
||||
- `metabase.driver.sql-jdbc.sync.interface/db-tables` is now a multimethod for retrieving JDBC metadata
|
||||
tables. SQL JDBC drivers can override this method to customize which database objects are discovered during sync.
|
||||
|
||||
|
||||
@@ -648,6 +648,11 @@ The Topic column on the [Activity log](#activity-log) model takes one of:
|
||||
- transform-run-canceled
|
||||
- transform-run-start
|
||||
- transform-run-timeout
|
||||
- transform-test-create
|
||||
- transform-test-delete
|
||||
- transform-test-run-start
|
||||
- transform-test-run-timeout
|
||||
- transform-test-update
|
||||
- update-permission-failure
|
||||
- update-transform
|
||||
- upload-append
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -70,14 +70,14 @@ describe("scenarios > embedding-sdk > mantine styles leakage", () => {
|
||||
getSdkRoot().within(() => {
|
||||
cy.get("button")
|
||||
.contains("Filter")
|
||||
.should("have.css", "color", "rgb(255, 0, 0)");
|
||||
.should("have.css", "color", "color(srgb 0.72 0 0)");
|
||||
|
||||
cy.findByTestId("notebook-button").click();
|
||||
|
||||
cy.findByRole("button", { name: "Visualize" }).should(
|
||||
"have.css",
|
||||
"background-color",
|
||||
"rgb(255, 0, 0)",
|
||||
"color(srgb 0.72 0 0)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -131,11 +131,11 @@ describe("scenarios > admin > datamodel > segments", () => {
|
||||
|
||||
// Detail view
|
||||
cy.get("main").findByText("Description").should("be.visible");
|
||||
cy.button("See this segment").should("be.visible");
|
||||
cy.findByRole("link", { name: "See this segment" }).should("be.visible");
|
||||
|
||||
// Segment fields
|
||||
cy.findByRole("link", { name: /Fields in this segment/ }).click();
|
||||
cy.button("See this segment").should("not.exist");
|
||||
cy.findByRole("link", { name: "See this segment" }).should("not.exist");
|
||||
cy.get("main")
|
||||
.findByText(`Fields in ${SEGMENT_NAME}`)
|
||||
.should("be.visible");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -250,9 +250,8 @@ describe("scenarios > documents > public", () => {
|
||||
cy.log("Verify the text wasn't updated");
|
||||
H.documentContent().findByText(text).should("exist");
|
||||
|
||||
cy.log("Verify that run/close buttons don't exist but a metabot icon does");
|
||||
cy.log("Verify that run/close buttons don't exist");
|
||||
H.documentContent().find("button").should("not.exist");
|
||||
H.documentContent().icon("metabot").should("exist");
|
||||
});
|
||||
|
||||
it("should allow downloading results from embedded cards", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||