Compare commits

...

63 Commits

Author SHA1 Message Date
Kyle Doherty
16bbf677a5 Rename nav sections, flatten Library, move Metabot into the page header
- Nav section switcher tabs are now "Library" (repository icon) and
  "Playground" (beaker icon); browse page copy updated to match.
- Library rail content drops its collapsible heading; folders render directly
  (section keeps its "Library" accessible name).
- Metabot button moves from the Playground rail into the app page header,
  between search and the app switcher (hidden in iframes), and reserves its
  32px slot while permissions load so the header controls don't shift.
2026-09-28 15:36:15 -04:00
Kyle Doherty
e1d1d31a1a Polish main-app metric page header and app page header
- App header breadcrumbs render without icons (CollectionBreadcrumbs showIcons),
  match the page's gutter/background via a published page frame
  (setPageFrame replaces setPageBackground), and align vertically with the
  nav rail's Official/Unofficial switcher.
- Metric header: no metric icon; main-app about page title uses h1 size
  (PaneHeader titles gain a size option).
- Main-app metric pages render title and description read-only;
  Data Studio metric pages opt into inline editing (isInlineEditable).
2026-09-28 13:31:54 -04:00
Kyle Doherty
7b91252075 Trim official metric page and drop remote-sync collection styling
Metric page (main app):
- Pills under the chart reuse the Breakdowns logic: first 4 dimensions,
  "Show more" adds 4, curated default stays visible. Breakdowns panel removed.
- Definition panel collapsed by default (DetailPanel gains collapsible /
  defaultCollapsed).
- Dimensions and History panels shown only in Data Studio
  (showManagementPanels).

Remote sync:
- Collections no longer swap to the synced_collection icon; they show their
  own icon (CollectionCaption now honors collection.icon). Removes
  isSyncedCollection plugin, REMOTE_SYNC_COLLECTION, and the isTenantUser
  icon plumbing.
- Removes unsynced-changes dots from all nav collection trees, including the
  CollectionsNavTree plugin and isCollectionDirty. Data Studio tab badges kept.
2026-09-28 13:11:34 -04:00
Kyle Doherty
8dfb8c5b67 Merge remote-tracking branch 'origin/master' into nav-split-prototype
# Conflicts:
#	frontend/src/metabase/browse/models/BrowseModels.tsx
#	frontend/src/metabase/search/components/DropdownSidebarFilter/DropdownSidebarFilter.tsx
2026-09-28 11:09:02 -04:00
Eric Normand
e8f85e28e0 GHY-3274: Initial sync should not attempt to create field values (#82963)
* GHY-3274: Skip the FieldValues scan on the initial sync of a Database

FieldValues are only created on demand, by the /values endpoints. A new Database has none. But the initial sync (sync-database!, which the database-create event runs) still ran the field-values phase. That phase did per-Field app-DB work for every Field: a count and a delete of expired advanced FieldValues, and an exists check before clearing each non-list Field. On a new Database none of that work can find anything.

sync-database! now reads the initial_sync_status of the Database from the app DB when it starts. When the initial sync is not complete, a :full sync runs the metadata and analyze phases and skips the field-values phase. The status comes from the app DB and not from the map that the caller passes, because the metadata phase marks the Database complete, and callers can hold a map from before the first sync. Later syncs, and the scheduled field-values task, run the scan as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* GHY-3274: Read initial_sync_status from the database map, not the app DB

sync-database! fetched the Database from the app DB to read its initial_sync_status. Production callers do not need that: the database-create event and advanced-config pass the Database they just created, the sample-data code creates or selects the Database first, and the notify API loads it per request. Only tests passed a map from before the first sync to a later sync. So sync-database! now reads the status from its argument, and saves one app-DB query per sync.

Three places in the tests re-synced with a stale with-temp map and expected the later sync to refresh FieldValues: one-off-dbs/insert-rows-and-sync!, scanning-test, and sync-and-find-values! in the warehouse-schema FieldValues tests. They now select the Database from the app DB before each later sync.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* GHY-3274: Stub the unique task list in the task filtering e2e test

The "filtering should work" test in tools.cy.spec.ts opens the task list with task=field values scanning, and checks that the "Filter by task" picker shows that value. The picker is a Select whose options come from GET /api/task/unique-tasks, and a Select shows an empty input when its value is not an option. The test stubbed GET /api/task but not unique-tasks, so it depended on real task history.

That real task history had a "field values scanning" row only because the initial sync of the Sample Database ran the FieldValues scan. This branch skips that scan on an initial sync, so the name is no longer in the list, and the picker was empty. The test now stubs unique-tasks with the task it filters on, in the same way that it already stubs the task rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:02:54 +00:00
Denis Berezin
63cbcc92e3 Render the move-questions-into-dashboards modal without a second overlay (#83023) 2026-09-28 19:46:21 +05:00
Denis Berezin
16ad7b75f2 Keep query column order for new table columns (#82476) (#82972)
* Keep query column order for new table columns (#82476)

* Fix CI
2026-09-28 19:45:55 +05:00
Nemanja Glumac
eedda27c89 Cover Mongo convert-to-native on a unit test level (#83011)
* Move Mongo convert-to-native coverage out of e2e

The Mongo e2e test asserted on a specific product row, but the converted
pipeline has no $sort, so row order depends on the Mongo container's
natural order and the row often fell outside the rendered grid.

Replace it with backend tests (no default $limit, nested query over a
converted native card runs, re-converts with its collection and without
Bson wrappers, and the re-converted query runs) and Jest tests for the
Mongo-specific labels and the collection handoff on convert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep the positive collection case next to the negative one

The "no collection" case only proves something if the same fixture can
produce a collection; without the database feature flag, native extras
are silently dropped and the negative assertion passes vacuously.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:44:17 +02:00
Nemanja Glumac
1a0ff4340b Replace flaky metric-aggregation e2e test with a unit test (#82964)
* Replace flaky metric-aggregation e2e test with a unit test

The CodeMirror value() e2e helper read the editor text once, so a read
that landed before the initial format settled failed without retrying.
It is now a Cypress query that re-reads on every assertion retry.

The notebook e2e test is replaced by an AggregateStep unit test. Writing
it exposed a blur during the initial format overwriting the formatted
source with the stale empty one; blur is now ignored while formatting.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Read CodeMirror text through invoke queries instead of a custom command

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:32:38 +02:00
Nemanja Glumac
0bdad48e91 Command palette e2e spec simplification (#82905)
* Move command palette shortcut coverage down to RTL

Split the shortcuts monolith into a navigation test and a focus-suppression
test, and drop the e2e assertions a unit test can make deterministically:
shortcut modal toggling, c f / c d, the [ navbar toggle, admin tab digits,
the mobile search button, palette action presence, and the snowplow counts.

New unit specs drive the real shortcuts through kbar and assert the router,
the redux modal state and the tracking call. Admin settings links per role
were already covered by the PaletteResults suite, so those two e2e tests go
away as duplicates.

AdminNavbar's spec binds digits against a fixture tab list; the real list's
order, which those digits index into, is pinned next to getAdminPaths.

Dropped Meta+[ outright: toggle-navbar binds "[", and the modifier trigger
is $mod+. handled outside kbar, so the press matched nothing and the
assertion after it passed on the sidebar's pre-existing state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fold the documents-in-recents palette test into the main one

The test was kept separate because it needed the bleeding-edge token, which
enabled enough extra features to disturb the main palette test. Documents
moved to OSS: the token activation and the /api/ee/document endpoint went
with it, so the separation costs a cold app boot and buys nothing.

The document is created before the dashboard so the dashboard stays the most
recent entry and the existing selection assertion holds.

Also pin the personal-collection name on the read path in Clojure, which is
the one thing the e2e assertion covers that no backend test did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fold the API-order palette test into the main one

Both tests are "real search results render correctly in the palette" and
each paid its own cold app boot. The order check moves into the query block
of the main test, right after the other search assertions.

The searchData alias is registered immediately before its query rather than
at the top of the test: cy.get yields the last matching request, and the
earlier searches in the merged test would shadow it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Anchor the shortcut navigation test on rendered pages

cy.location resolves when a route transition starts, not when it finishes, so
chaining chords on URL assertions alone lets the next chord land mid-mount
and be dropped. Each hop now waits for content on the destination page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Sync the dashboard bookmark shortcut on its API writes

The o shortcut decides between add and remove from the refetched bookmark
list, so pressing it twice in a row can toggle against stale state and leave
the dashboard bookmarked. Each press now waits for its write, and the
positive assertion between them proves the list refetched before the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Set up the palette permissions test through the API

The test needs a non-admin with settings, monitoring, table-metadata and
database-management access; how those grants get made is not what it covers.
Driving the two permission graphs directly replaces a cold boot into the
admin permissions screen, ten UI interactions and two save round-trips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Wait for the bookmark toggle before the dashboard shortcut

visitDashboard resolves on the dashcard query responses, not on render, so
the o keystroke could land before DashboardBookmark mounted and registered
the shortcut — no request, no toggle. Anchor on the button that component
renders, which is the same thing that owns the shortcut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Wait for the app bar before opening the palette after sign-in

The palette registers its toggle only once its effect sees the logged-in
user, and the home greeting renders independently of the app bar, so the
ctrl+k could land while the palette was still disabled and be swallowed.
Anchor on the search button, which the same shell renders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Tighten restricted-shortcut and admin-path filtering tests

Assert the pathname stays put right after a restricted shortcut, and cover
the admin paths reducer dropping paths for a non-admin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Mount the admin app reducer at its typed store path in tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 15:31:35 +02:00
Nemanja Glumac
9b193b0937 Replace flaky help text docs link e2e test with a unit test (#82954)
* Replace flaky help text docs link e2e test with a unit test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Assert the help text docs link click is not cancelled

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:14:41 +02:00
Nemanja Glumac
1139be54dd Replace upload-permission e2e tests with unit coverage (#82973)
* Replace upload-permission e2e tests with unit coverage

The "permissions" e2e block restored the postgres-12 snapshot only to
assert upload button visibility, which is covered by backend
can_upload tests and CollectionHeader unit tests. Add the missing
unit case: button hidden when uploads are enabled but canUpload is
false.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Add a missing backend coverage

* Make upload-permission unit tests assert the right cause

The sandboxed can_upload test enabled uploads on the original database
while with-gtaps-for-user! queries a temporary copy, so can_upload was
false because uploads were disabled. Enable uploads inside the gtaps
block and sanity-check with an admin on the same database.

Add CollectionContent cases driving the upload button from the
database's can_upload response.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:13:18 +02:00
Nemanja Glumac
32176e152a Keep the Google Drive modal closed until the connection status loads (#83009)
While the connection query was loading, its missing status was read as
"not-connected", so connected admins briefly saw the connect modal. The
modal now renders nothing until the status arrives.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:12:16 +02:00
Chris Vandoodewaard
1d5c08b71d make number of versions we patch nightly configurable (#82766)
* make number of versions we patch nightly configurable

* if 0 should still patch current version
2026-09-28 08:51:21 -04:00
Anna Tomka
bc75a88888 GDGT-2458: Accordion (#82562)
* test(ui): add Accordion Storybook story and Loki filter

* feat(ui): restyle Accordion from Figma

* fix(Accordion): keep theme selectors one class deep so call-site overrides still win

* refactor: drop Accordion call-site overrides now covered by the theme

* test(ui): add Loki reference for Accordion Overview story

* fix(Accordion): hide the default chevron from assistive tech

* test: update DataComplexityCards snapshot for the Accordion separated default

* fix(RemoteSyncDependencyModal): drop overrides the new Accordion theme makes redundant, keep control transparent

* fix(RemoteSyncDependencyModal): let the Accordion theme space the rows

* fix(Accordion): keep icon grey on disabled hover, honour the radius prop

* test(ui): add disabled hover row to the Accordion Overview story

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-28 10:20:07 +00:00
Voytek Pituła
cd6bcc23e0 [BOT-2138] Centralize shared logic of metabot LLM provider adapters (#82165)
* Refactor metabot LLM provider adapters: centralize shared logic in `adapter.clj` while simplifying  implementations.

* Clarify error message for unsupported adapter models

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Rework auth, add some types

* drop span cusotmization

* drop extract and headers from CatalogOpts

* fix counts

* revert adapter/context-window-fn

* more tests for headers merging

* normalize-known-model fix

* doc fix

* more strict catalog name resolution

* simplify

* google coverage v1

* registry v1

* registry v2

* simplify

* align bedroc, drop dead ai-proxy?

* fix docstring

* improve tests

* rename listing to model-listing

* rename ai-proxy?

* rename keywords

* improve catalog error handling

* drop redundant content type

* move google auth inside request span

* route vllm's lsit-models through request!

* Update src/metabase/metabot/self/registry.clj

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>

* remove history from docs and comments

* Update src/metabase/metabot/self/registry.clj

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>

* fix var refs in optional/required

* refactor model context-window handling to use registry function

* migrate span attributes to nested :attributes map in adapter and update tests

* standardize function schemas with `mu/defn` and introduce `ResolvedRef` for adapter inputs

* remove redundant comment block in adapter test

* adjust schemas for closed maps enforcment

* reject unsupported AI proxy usage in Azure adapter and expand test coverage

* refactor registry-test to dynamically derive providers and improve adapter validation

* bring back old logging behavior

* stringify `:family` span attribute across adapters and update tests

* fix vllm test name and bedrock reference

* standardize `list-models` function schemas across adapters with `mu/defn` and update related tests

* add schema conformity test for `:supported-models` across adapters

* fix Auth schema

* Get back old tru errors

* Bake-in content-type header

* Expand `ModelListing` schema to include `learned-config` with additional closed map fields for connection reasoning.

* Remove `^:parallel` marker from test to disable parallel execution

* Update src/metabase/metabot/self/claude.clj

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>

* Rename `:learned-config` to `:connection-info`

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-09-28 11:07:48 +02:00
Fraser
381ab6c1a0 Small store batch: QueryModalType, metabot re-export (#79347)
Move QueryModalType and the metabot slice type out of shared redux

Co-authored-by: Romeo Van Snick <romeo@romeovansnick.be>
2026-09-28 21:31:09 +13:00
YB
176d0682e2 Document where action runs are logged (#82742)
document where action runs are logged
2026-09-28 13:18:46 +08:00
Fraser
daa0c256a6 Build Storybook with --test for Loki screenshot runs (#83003) 2026-09-28 11:16:08 +13:00
Romeo Van Snick
2df3f6daa0 Avoid modulejs warning in stylelint (#82988)
* Avoid modulejs warning in stylelint

* Fix eslint errors in stylelint plugin after .mjs rename

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 18:21:41 -04:00
Eric Normand
503f96cb55 GHY-4651: SendMeteringEvents does full table scan of query_execution (#83034)
GHY-4651: Let the metering query use the query_execution started_at index

SendMeteringEvents runs every 15 minutes and on every boot. It counts yesterday's query executions with WHERE CAST(started_at AS DATE) = CAST(? AS DATE). Casting the column hides it from idx_query_execution_started_at, so the app DB scans the whole query_execution table. On busy instances that table holds millions of rows.

The query now filters with a half-open range, started_at >= yesterday 00:00 UTC AND started_at < today 00:00 UTC. The DB can answer that range from the index. The db function becomes query-execution-statistics-between, and query-execution-last-utc-day computes the UTC day bounds. The docstring already said UTC day; the old cast used the DB session time zone instead.

The new test in token-check-test runs metering-stats, captures the SQL it sends for query_execution, runs EXPLAIN on the H2 app DB, and checks that the plan uses the started_at index. A second test pins the day boundaries: yesterday 00:00Z is in, today 00:00Z is out.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:09:44 -05:00
Andrei Simionescu
f2e185369f Always show the Metabot illustrations toggle (#82946)
* Always show the Metabot illustrations toggle
* Only mention the chat sidebar in the illustrations label and docs
* Test the illustrations toggle with the default icon
2026-09-25 21:30:29 +01:00
Eric Normand
9db952d568 GHY-4323: related-tables lookup does a permission query per table and an uncapped IN (#82962)
GHY-4323: Cap related-table ids before the lookup in get_fields

related-tables-by-requested-table collected every FK target of every requested table into one IN clause, then applied the 50-table cap to the result. A table with many FK columns made the lookup ask for tables that the response then dropped. The cap now applies to each table's targets before the lookup, so the IN holds at most 50 ids per requested table.

The :target hydration in the metadata fetch already drops unreadable targets, so only inactive or unbrowsable targets can now shrink the surfaced list below 50.

The other half of the ticket, one permission query per related table, is already fixed. Since 6908f26b76, field/readable-fields-only primes the table perms cache for every FK target while it hydrates :target, and the related tables are exactly those targets. The new API test covers it: as a non-admin with per-table grants and 60 related tables, the call succeeds under the N+1 guard, and it fails with that prime removed.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 13:18:51 -05:00
Chris Truter
9e27bdf31f Take host-part credentials off the pgvector URL (#83010)
* Take host-part credentials off the pgvector URL too

Credentials written before the host (user:pass@host) stayed on the URL.
pgjdbc reads that form as part of the hostname, so the password showed up
in its "unknown host" error and its DEBUG line.

- Move them into the credentials map, whatever the scheme, splitting at
  the host part's last @, with %-escapes decoded and + kept as is. Setting
  one both there and as a param fails at startup.
- Refuse any other unencoded @, which is what an unencoded / or ? in the
  password leaves behind, with a message that quotes none of the URL.
- Rethrow a malformed %-escape without the decoder's message, which quotes
  part of the value.

* Accept an unencoded @ in pgvector URL query values

pgjdbc decodes query values and gives @ no special meaning, so Azure's
documented user=name@server must keep working. Only a host part with no
path after it, the shape an unencoded ? in a password leaves, still
refuses an @ in the query.
2026-09-25 16:07:23 +00:00
Chris Truter
20f3f6cbdc Keep pgvector credentials out of logs (#82930) 2026-09-25 15:22:14 +00:00
Nemanja Glumac
2f3fe9904e Stop ErrorOverview recovery test from racing the search debounce (#83005)
Stop ErrorOverview recovery test from racing the search debounce

Fake timers skip the 500ms debounce so it no longer eats into findBy's
1s budget, and the lookup is scoped to the row so TreeTable's transient
off-screen measurement copy of the cell can't produce a duplicate match.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 12:14:18 +02:00
Nemanja Glumac
57a08becb3 Cover pivot subtotal date localization with a Jest test (#82947)
Pivot a month-bucketed date breakout under a German locale and assert the
subtotal label keeps the localized month. Formatting the value a second time
re-parses "Mai 2026" and yields "Januar 2026", which is the regression the
assertion pins.

Drop the Cypress reproduction: it rendered a pivot of Product Title x month x
User ID to check the same label, and was racing the render.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 08:25:36 +02:00
Andrei Chernykh
fc5cf5c469 Bump @snowplow/browser-tracker to 4.10.2 (#82955)
* Bump @snowplow/browser-tracker to 4.10.2

* Fix SDK analytics spec for batched Snowplow requests
2026-09-24 14:18:19 -07:00
bryan
c03c02a9a1 Lint values, not call style, and write the value-binding rubric down (#82686)
* Lint values, not call style, and write the value-binding rubric down

Two changes to the value-binding sweep (GHY-4477), both correcting guidance that
earlier namespace PRs were written against.

Stop asking for query maps. The linter's `lint-kv-args!` check asked every
`:column value` call to be rewritten as a query map, on the premise that a query
map is the shape the HugSQL extraction in step 2 reads off. That premise does not
hold. Both extraction spikes show it: the glossary spike (#82530) extracted
cleanly *from* kv-args, and the SSO spike (#82587) still needed substantial
rewrites *from* query maps -- a `SELECT 1 ... LIMIT 1` reshape and empty-collection
sentinels. Extraction is semantic. It turns conditional clauses into flags, picks
portable SQL, and discards the HoneySQL shape entirely (`h2x/like-substring`'s
`[:escape ...]` form becomes plain `ESCAPE '!'` text). A query map is not closer
to the target, just a different thing to throw away.

Converting also introduced a bug class. Toucan applies a model's `deftransforms`
`:in` fn only in kv-arg position -- it hooks `query/apply-kv-arg`, and a raw
`{:where ...}` map never passes through there (toucan2 calls HoneySQL "an outlet
to bypass type transforms"). So moving a keyword value into a where map drops the
transform, and HoneySQL formats a keyword in a value slot as an *identifier*:

    (t2/select :model/SearchIndexMetadata :engine :appdb)
    ;; => ["... WHERE ENGINE = ?"  "appdb"]          transform ran

    (t2/select :model/SearchIndexMetadata {:where [:= :engine :appdb]})
    ;; => ["... WHERE ENGINE = \"APPDB\""]           transform skipped, broken

That shipped twice on sweep branches before being caught (`sync/db.clj`'s
`:semantic_type :type/Name`, `collections/db.clj`'s `:type :api-key`). It compiles,
runs, and returns wrong rows. Nothing static can check the hand-written
post-transform value, so the check that produced these conversions is removed
rather than narrowed.

Lint kv-arg values instead. Removing the style check on its own would have left a
real gap: `value-nodes` only understood query-map clause shapes, so a value passed
as a kv-arg pair was never examined for a missing marker. `lint-unmarked-values!`
now walks kv-arg pairs too, starting after the `:model/...` keyword rather than at
a fixed offset (several fns take an argument before the model, e.g.
`(t2/select-one-fn :value :model/Setting :key k)`). Inside a kv-arg the column
comes from the pair key, so every argument of an operator form is a value --
`:id [:in ids]` checks `ids` -- unlike a query-map clause where the first argument
is the column.

Net effect on findings: call style is no longer reported; an unmarked value is
reported whichever style it was written in. Verified against
`settings/db.clj` (kv-arg style, opted in temporarily): both `setting-key` reads
are now flagged, and the `value` in `update!`'s changes map is correctly not.
`content_translation/db.clj`, the merged worked example, stays at zero.

Write the rubric down. docs/developers-guide/value-binding-rubric.md records every
decision the sweep needs, so the remaining namespace PRs stop re-litigating them in
review. Seven ordered rules, each with an example; the two that were previously
implicit are now explicit -- never mark a literal (it cannot carry a request value,
and in step 2 it becomes a HugSQL hole every caller fills with the same constant),
and leave a possibly-empty collection unmarked (Toucan rewrites `[:in col []]` to
`false` inside the step the marker wraps). It also records the `t2/update!` shapes
that work, what `value_guard` refuses for you, and the known limits the lint cannot
see -- so "zero findings" is replaced by "every finding explained by a rule".

* Frame the call-style rules as scaffolding that HugSQL extraction retires

A HugSQL query fn takes one map and has no override surface, so the
kv-arg-vs-query-map question -- and the transform-position trap that comes with
it -- does not exist for an extracted namespace. Say so, rather than leaving
these rules to read as permanent house style.

* See value slots inside conditionally built clauses

value-nodes stopped at a list node, so any value a when/if/cond-> built was
invisible to the unmarked-value check -- a false negative in a security lint,
and a common shape: permissions/db.clj and collections/db.clj both assemble
where clauses that way.

    (t2/select :model/Setting {:where [:and (when k [:= :key k]) [:= :o o]]})
    ;; o was flagged, k was not

Walking list-node children finds it. This surfaces real unbound values in four
namespaces (dependencies 12->17, sync 7->10, collections 5->7, search 5->6);
they are bound in the namespace PRs stacked on this branch.

* Correct the transform section: the call style breaks it, not the marker

The section implied that marking a value on a transformed column is what
produces the identifier comparison. It is not. value_guard lifts the
[:auto/param column v] 3-arity from inside apply-kv-arg, so a MARKED kv-arg
still runs the transform -- verified against :model/SearchIndexMetadata, the
bare and marked kv-arg forms compile to byte-identical SQL.

Only the where-map form skips the transform. So a marker on a transformed
column in kv-arg position is redundant rather than dangerous, and the reason to
leave it off is that the transform already binds the value.

Caught by a reviewer that compiled all three forms rather than taking the
section at its word.

* Rewrite the transform and written-value rules as single explanations

The transform correction was bolted onto the end of the section, so it stated
the rule, then contradicted its own framing, and showed the same example twice.
Rewritten as one block that shows all three forms together -- kv-arg bare,
kv-arg marked, query map -- so the variable is visible: the call style is what
takes a value off the transform path, not the marker. Adds the converse case a
sweeper actually hits: a call that was ALREADY a query map on master must carry
the post-transform value written out.

Rule 1's example showed a t2/update! CONDITIONS map while the prose was about
the changes map, which is the confusion the rule exists to prevent. Replaced
with a real settings/db.clj call where the condition is marked and the changes
map is bare, plus an explicit note that only the trailing changes map is exempt.

Also corrects the stated mechanism. The reason not to mark a written value is
that the column's `:in` transform runs on the marker and persists it as data --
mi/transform-encrypted-json is an :in transform, so an encrypted column
encrypts the marker. The earlier text attributed this to a
define-before-insert hook on :model/Setting, which does not exist; Setting's
encryption is application code plus a column transform.

* Refuse a marker outside a value slot, and see two more shapes

Three findings from review, all verified against a running app DB.

The guard was silent on the one case it most needed to catch. `auto-param`
lifts a marker wherever it sits, so one written in a `:select`, `:from`,
`:order-by` or `:group-by` clause became a bound parameter in a slot HoneySQL
wanted an identifier for -- compiling to the literal identifier `PARAM` and
discarding the value, with nothing downstream able to notice:

    (t2/query {:select [[:auto/param "name"]] :from [:setting]})
    ;; => ["SELECT \"PARAM\" AS \"P3R1MO8JB3WD0J\" FROM \"SETTING\""]

`check-marker-placement!` now refuses that as `::marker-outside-value-slot`. It
stops at a nested query map, because a subquery's own clauses are checked on
their own terms -- `t2/exists?` wraps the query in `:select [[[:exists {...}]]]`
whose inner `:where` is a real value slot, and scanning the outer `:select`
wholesale rejected it. A join's ON condition is likewise a value slot, so only
the table halves of a join clause are checked.

The lint could not see a conditions map. `(t2/update! model {:col v} changes)`
filters on `v`, but it is not a `:where` clause, so neither walker descended it
-- and the rubric recommends that exact shape, so a namespace written that way
passed vacuously. Now walked for `update!`, `update-or-insert!` and `delete!`,
taking only the map immediately after the model (a later map is the changes map,
exempt by rule 1). An operator form there holds only values, since the column
comes from the map key.

The lint could not see into a subquery. The operator branch returned its
arguments without descending, so `[:in :id {:select ... :where [:= :z z]}]` left
`z` unchecked. It now keeps each value and descends into it; descending alone
would have dropped the plain-token case.

Rubric corrections from the same review, each verified rather than reasoned:
rule 2's failure mode (marking a column keeps the comparison and binds the
column NAME, it does not drop it), rule 5 (`:not-in` gets no empty-collection
rewrite, so an empty one emits invalid `NOT IN ()` marked or not; rule 4 wins
for collections of ids; a non-empty proof must be local), rule 4 (`long`
truncates a non-integral number rather than throwing), rule 1 (a written value
is not the injection shape this project targets, rather than never structural),
rule 7 (booleans from outside are values too), the transform mechanism
(`apply-kv-arg` maps the transform over an operator form's tail, which is why a
marked kv-arg keeps it -- nothing to do with `value_guard`), and a new
false-negatives subsection, because the rubric previously described the lint as
only over-flagging.

* Scan subqueries for misplaced markers, and allow one inside an expression

Two holes in check-marker-placement!, both verified by compiling the form.

It only scanned the top-level map, so the silent case survived one level down:

    {:select [[[:exists ^:allow-subquery
                {:select [[:auto/param "name"]] :from [:setting]}] :e]]}
    ;; => SELECT EXISTS (SELECT "PARAM" AS "PACGBLW31WM3R" FROM "SETTING")

No throw, value dropped -- exactly what the check exists to prevent. The
docstring, the test, and the rubric all claimed a subquery was "checked on its
own terms", which was not implemented; nothing re-ran the clause scan on a
nested map. warehouse_schema/db.clj:317 already uses ^:allow-subquery with
:exists, so the shape is live. It now walks every nested map and scans each
one's clauses.

It also refused legitimate value slots. A comparison inside an identifier
clause is a real value slot, and both of these threw at compile:

    {:select [[[:= :engine [:auto/param "h2"]] :is_match]]}
    {:order-by [[[:case [:= :a [:auto/param 1]] 1 :else 2] :asc]]}

So a converted namespace would break the moment it grew a computed projection
or a CASE sort key. `:join` already needed a carve-out for its ON condition;
this is the same need, and the file already recognizes the shape via
`operator-form?`, so the scan descends past a keyword-headed form.

Pre-existing failures in metabase.app-db.custom-migrations-test are unchanged:
24 on origin/ghy-4477-rubric-foundation, 24 with these fixes.

* Classify identifier slots positionally, and close four lint blind spots

Findings from four independent reviews of this PR, each verified by compiling
the form or by linting a probe rather than by reasoning.

The guard missed its own target case. `marker-in-identifier-position?` decided
what was a value by head shape, but HoneySQL tells `[expr alias]` from
`[:= a v]` POSITIONALLY. So `[:t [:auto/param "al"]]` read as though `:t`
headed an operator form, and the alias slot -- the shape the check exists to
catch -- passed:

    {:from [[:t [:auto/param "al"]]]}    => "SELECT * FROM t AS param k"
    {:select [[:a [:auto/param "al"]]]}  => "SELECT a AS param k FROM t"

Now an entry is destructured as `[expr & aliases]`: index 0 may be an
expression and is descended, everything after it is a name and is refused.

`:order-by` and `:group-by` are no longer refused. HoneySQL binds a param in
both (`ORDER BY ?`), so a marker there is a pointless no-op, not a dropped
value -- and the test asserted the throw as correct, pinning a premise HoneySQL
contradicts. Refusing it would have turned a harmless mistake into a
production exception.

`:cross-join` no longer takes the alternating path. It has no ON condition, so
`take-nth 2` skipped every odd-indexed table; a marker there was missed.
Only the four join clauses that actually pair a table with a condition use it.
Added `:using`, `:create-table`, `:drop-table`, `:truncate`, `:partition-by`,
`:window`, `:select-top`.

The scan now short-circuits on `contains-marker?`. It runs on every app-DB
query at compile, and almost none carry a marker. 18.0us -> 13.7us on a
join-heavy query with no marker.

Four lint blind spots, all with live call sites:

  `[:!= :col v]` / `[:<> :col v]` were unchecked -- `value-operators` listed
  only `not=`. collections/db.clj:283 passes a request `user-id` through `:!=`
  in a namespace a stacked PR reports as converted. Added `!=`, `<>`,
  `is-distinct-from`, `is-not-distinct-from`, `not-between`.

  A value inside a function-call form -- `[:= :k [:lower v]]`, which compiles
  to `LOWER(?)` -- was dropped by the `:else` branch, whose token children
  return nothing on their own. Live in audit_app/db.clj. Same fix reaches a
  literal collection: `[:in :k [a b]]` now checks both elements.

  A two-arity `t2/update!` had its CHANGES map read as conditions. Toucan's
  arglist ends in the changes map, so a lone trailing map is changes --
  remote_sync/db.clj's mark-all-rsos-synced! was flagged for a written value,
  contradicting rubric rule 1. `delete!` has no changes map, so its lone map
  is still conditions.

  A query map passed where conditions go was walked twice, reporting every
  value twice. Live in mcp/db.clj, metabot/db.clj, testing_api/db.clj.

Findings are now deduped by source position, which also covers a multi-arg
operator in a kv-arg: `:d [:between lo hi]` reported `hi` twice, because
`value-nodes` misreads the vector as a clause while `kv-arg-value-nodes` reads
it correctly. Deduping is robust where keeping the walkers disjoint is not.

Also: the marker payload is no longer descended (a marked value was reported as
though bare once the `:else` branch started yielding children), `ex-data`
carries the query so a dynamically-built one can be traced, and `args` is no
longer shadowed inside the conditions-map walker.

* Bring the rubric's guard and false-negative sections up to date

The guard section described the head-shape classifier and claimed :order-by /
:group-by were refused; both changed. The false-negative list still carried the
conditions-map, function-call-form, literal-collection, operator-alias and
subquery shapes as open, which are now covered -- and was missing the two that
remain open, a positional pk and a non-literal model.

* Drop the unused clojure.string require and the bang from check-marker-placement

Two clj-kondo failures on the previous push, both mine.

`clojure.string` became unused when the join carve-out stopped matching clause
names by suffix.

`check-marker-placement!` only throws -- it mutates nothing -- but the bang made
`:metabase/validate-deftest` read every call in a `^:parallel` test as a
destructive operation. Renamed rather than whitelisted, since the name was
wrong: the convention marks state change, and this is a predicate that refuses.

* Don't ask for a marker where the guard refuses one

`value-nodes` walked every clause of a query map, and the `:else` vector branch
yields each child as a candidate value. Together those reported symbols sitting
in clauses that name columns and tables, and the message always says the same
thing: write it as `[:auto/param x]`. In a `:select` or a `:from` that advice is
not merely noise -- `check-marker-placement` in this same PR throws
`::marker-outside-value-slot` when you follow it, so the two halves of the
mechanism contradicted each other.

A query map's clause values are now split. A clause that names things
contributes only what is nested inside it: a subquery's own clauses, and the
arguments of a keyword-headed comparison. Everything else is unchanged.

Measured over `src/metabase` and `enterprise/backend/src`, with the linter
forced on for every namespace: 1981 warnings to 1963. Eighteen removed, none
added. All eighteen are identifier slots -- `output-table-id`,
`table-wildcard`, `sort-direction`, `order-column`, `id-column`, `cast-form`,
and a `union-query` alias. Every value-carrying shape survives: a computed
projection, a `CASE` sort key, a subquery nested in `:from`, a subquery in an
`:in`, a plain where clause, a kv-arg.

`:order-by` and `:group-by` are in the set even though the guard deliberately
allows a marker there. A marker in `:order-by` compiles to `ORDER BY ? ASC`,
which loses the ordering, so asking for one is still wrong advice.

Two related shapes stay open, both false positives that ask for a marker in a
position that holds no value:

- A clause built with `assoc` or `cond->` is not a map in the source text, so
  the walker still reads it as a plain vector. `dependencies/db.clj:492` keeps
  its three `sort-direction` warnings.
- A binding vector inside a `let` or a `for` is walked like a collection
  literal, so the binding names are reported.

Neither is urgent: the linter is opt-in per namespace, the noise surfaces one
namespace at a time during a conversion that reads every warning, and the
rubric's gate is already that a rule or a limit explains each one.

* Allow a marker at index 0 of an expression entry

In :select, :select-distinct, :select-top, :returning and :partition-by,
index 0 of an [expr alias] entry is an expression, and HoneySQL binds a
param there (SELECT ? AS a). The guard refused it as an identifier.

The table clauses also bind a param at index 0 (FROM ? AS a), but a
table cannot be a parameter, so the guard still refuses a marker there.

* Refuse a marker that is the whole identifier clause value

When the value of a clause was itself a marker, as in :from [:auto/param "t"],
the guard split it into :auto/param and the payload. Neither is a marker, so
the guard passed it, and it compiled to FROM param, k with the value dropped.
Treat a marker value as a single entry, ahead of the join branch too.

* Remove the value-binding rubric from the repo

A reviewer asked that it not live in the repo.

* Give the lint the clause context it was guessing at

Two fixes, both places where the lint's judgement of a slot did not match
`value-guard`'s.

**Split the expression-entry clauses.** `identifier-clauses` was one flat set,
so the lint stayed silent about every position in `:select`, `:returning` and
friends. The guard no longer agrees: its `expression-entry-clauses` treats index
0 of an `[expr alias]` entry as a value slot, and `{:select [[[:auto/param "a"]
:k]]}` compiles to `SELECT ? AS "K"` with the value bound. The silence there was
a false negative -- a request value in a computed projection reached the
statement text with nothing to report it.

Index 0 of an entry is now a value slot. Everything after it is a name, and a
bare entry is a name, which matches what the guard refuses:

    {:select [[:auto/param "a"]]}        refused   (bare entry)
    {:select [[:key [:auto/param "a"]]]} refused   (alias)
    {:from   [[[:auto/param "t"] :x]]}   refused   (table)
    {:select [[[:auto/param "a"] :k]]}   binds     SELECT ? AS "K"

**Carry clause context through `assoc`.** A clause written outside a map literal
reached the walker through the list branch, which keeps no record of anything,
so the name-slot check never ran. The lint then asked for a marker on a column
name and on a sort direction. Both fixes it named break the query, and one
breaks it in silence: marking the direction throws a ClassCastException, and
marking the column compiles to `ORDER BY ?` and loses the ordering with nothing
at runtime to catch it.

A clause key inside an `assoc` call now routes its value the way a key in a map
literal does. `cond->` is covered because the walk descends into the nested
`assoc`.

Measured over `src/metabase` and `enterprise/backend/src`, with the linter
forced on for every namespace: 1963 warnings to 1954.

Fifteen removed, all of them the `assoc` case: `sort-direction` and
`sort-column` in task_history/db.clj:174, cache/db.clj:102 and
dependencies/db.clj:492, `column` in queries/db.clj:53, models/db.clj:152 and
:171 and documents/db.clj:58, and `table-level-case` in permissions/db.clj:89.

Six added, all of them expression entries the flat set had been hiding:
`output-table-id` in data_studio/db.clj (three sites), `cast-form` in
app_db/db.clj:20, `domain-expr` in internal_stats/db.clj:206 and
`admin-group-id` in permissions/db.clj:681. Each holds a column reference today,
so each is a rule 2 finding to record rather than a value to mark -- but the
lint can no longer tell, which is the point.

Still open, and not addressed here: a binding vector inside a `fn` or a `let` is
walked like a literal collection, so its names are reported; `:order-by` and
`:group-by` are absent from the guard's set, where a bare marker entry drops the
value; and the guard's DDL keys are still missing from the lint's set.

* Give the rubric comments their own reasons

The rubric left the repo, so five comments cite a file a reader cannot open.
"Rubric rule 1" was a useful short form while it was here; now it asks the
reader to go looking in the git history.

Each one now states the reason instead of pointing at it. A written value is
never marked because the column's `:in` transform runs on the marker itself and
stores it as data, and the conditions-map shape is checked because the sweep
leaves it in place.

Comments only. No behaviour changes.

---------

Co-authored-by: Eric Normand <ericwnormand@gmail.com>
2026-09-24 21:05:55 +00:00
Kevin Kelleher
af8ca49a25 Prevent showing stale exploration summary documents (#82442)
avoid fetching stale document while a save is ongoing
2026-09-24 15:32:23 -04:00
Kevin Kelleher
cfb8cbd6f9 Hide exploration error pages for unrequested query variants (#82450)
filter out failed variant pages
2026-09-24 15:32:11 -04:00
Eric Normand
3eff0fac3d GHY-4213: Remote Sync: importing a GUI model before the target's database schema sync completes stores untyped columns (#82945)
GHY-4213: Infer untyped model metadata after a metadata sync

A GUI model that is deserialized (for example by a Remote Sync import) before the target has synced its tables cannot infer its result metadata. The import then stores only the exported model overrides, with base_type type/* and no field ids, and nothing revisits it. Filters on those columns offer only "Is empty" and "Not empty".

After each metadata sync of a database, look at the unarchived MBQL models on that database. For each model whose stored metadata has no known base type, infer the metadata again, keeping the model overrides. A model that still cannot be inferred is left as it is. The update goes straight to the app DB, so it publishes no card-update event and does not mark remote-synced content dirty.

Tests: an API-level test imports a GUI model through POST /api/ee/remote-sync/import before its table is synced, runs POST /api/database/:id/sync_schema, and checks the column types in GET /api/card/:id. Contract tests cover untyped-metadata? and backfill-untyped-model-metadata!.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:16:53 -05:00
metamben
b6f625ed4b Use jdbc-type for SQL Server user defined types as fallback (#82860)
Closes #62335
Closes QUE2-143
2026-09-24 17:49:26 +00:00
Eric Normand
bb9482f0fd GHY-4399: Remote Sync doesn't clean up nested content when a parent collection is archived, so orphaned files accumulate in git forever (#82876)
* GHY-4399: Cascade collection archive to nested content in the remote sync ledger

Archiving a collection archives its whole subtree in bulk SQL and publishes one collection-update event for the top collection only. The remote sync collection handler marked only that collection as delete, so the next push removed its own file and left every nested collection, card, and other item in the repo for good.

The handler now brings the ledger rows of the subtree in line with each entity archived state when the collection is archived, or when its own row was a pending delete (it was unarchived before a push). Rows of archived entities become delete. Delete rows of entities no longer archived become update, so restoring a folder before a push does not delete its files.

subtree-expr matched descendants by the prefix /<id>/, which is correct only for root collections. It now uses the location the children have (location + id + /), which is the same string for roots and is also correct for nested collections.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* GHY-4399: Cascade collection archive to transforms in the remote sync ledger

The cascade marked a subtree row as delete only when the entity itself was archived. Transforms have no archived column, so a transform in an archived Transforms folder stayed synced, and the next incremental push left its file on the remote. This is the first repro in the ticket.

A row now also counts as archived when the collection it is in is archived. Unarchiving restores it to update when neither the entity nor its collection is archived and the entity still exists.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 11:59:46 -05:00
Andrei Chernykh
8317274709 Migrate AutocompleteInput dropdown off Emotion (#82948) 2026-09-24 09:46:29 -07:00
Sébastien
c796ec01ec Handle both list and search default-value pickers in embedding e2e test (#82727) 2026-09-24 12:35:20 -04:00
Eric Normand
fa7362a1e4 GHY-4554: Mark untrusted data in MCP v2 tool results with an unforgeable boundary (#82879)
* GHY-4554: Mark untrusted data in MCP v2 tool results with an unforgeable boundary

Tool results put instance data (card names, warehouse rows, column comments) next to server prose, with nothing to mark where the data stops. A planted instruction in a name could pass for an instruction from Metabase.

message/data holds data from outside the server. render writes it as JSON between <data boundary="..."> and </data boundary="..."> lines that carry a random 32-hex-digit value, which the data cannot predict and so cannot close early. The initialize instructions explain the convention in one line.

message/json-text encodes JSON and then writes invisible, line-breaking, private-use, and unassigned code points (Cc, Cf, Zl, Zp, Co, Cs, Cn) as \uXXXX escapes. Decoded values do not change. The transport encodes JSON-RPC responses with it, so structuredContent is escaped on the wire too.

Every (message/raw (json/encode ...)) site and common/success-content now use message/data. Tool tests read the text through test-util/strip-data-boundary.

* GHY-4554: Make question_write and transform_write text-only

Both tools sent their payload as structuredContent as well as in the text block. Claude Code shows the model structuredContent in place of the text block, so the model saw the payload with no data boundary and with invisible characters raw. No client reads their structuredContent, so they now answer text-only.

* GHY-4554: Say to keep untrusted content out of structuredContent

A client may show the model structuredContent in place of the text block, and there the data has no boundary and its invisible characters decode raw.

* GHY-4554: Put glossary lookup results inside a data boundary

Glossary terms and definitions are user-written free text. The glossary() listing already put them inside the per-response data boundary, but glossary(term) quoted them into server prose. The lookup now returns its matches in the same data envelope as the listing, so a planted closing tag or instruction reads as data.

* GHY-4554: Make strip-data-boundary strict

The test helper returned text with no data boundary unchanged, so a tool that stopped wrapping its result still passed its suite. It now throws, and the suites that read error results through the same helper read those raw. With the renderer changed to emit bare JSON, every MCP v2 tool suite now fails.

* GHY-4554: Extend the never-follow rule to quoted values

Server prose quotes every value it did not write, so a planted instruction can reach the model inside a quoted value as well as inside a data boundary. The initialize instructions now name both.

* GHY-4554: Name the sliced table by id in the get_fields paging line

The paging line put the warehouse-controlled table name in server prose, outside the data boundary, and printed null when a fields projection dropped the name and id. It now names the table by the id from the source row and gives the exact next call, so the name stays inside the data boundary and the agent always knows which table to page.
2026-09-23 21:21:22 +00:00
Eric Normand
361fc91d80 GHY-4636: MB CLI doesn't have flag for expected branch, git-sync import fails with expected_branch: missing required key, received: nil (#82874)
GHY-4636: Let remote-sync import run without expected_branch

The mb CLI calls POST /api/ee/remote-sync/import with only a branch. GHY-4019 made expected_branch a required key, so every CLI import failed schema validation with 'expected_branch: missing required key'.

expected_branch is a compare-and-swap guard against a stale browser tab. A headless client has no cached view that can go stale, so the key is now optional. The endpoint runs the guard only when the client sends the key. The web UI still sends it, so its behavior does not change.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 14:49:22 -05:00
Nemanja Glumac
f4ab93af92 Extract the backend profiling trigger into a single entry point (#82854)
* Give on-demand test profiling a single entry point

backend.yml, app-db.yml, drivers.yml and semantic-search.yml each carried their
own workflow_dispatch block, their own PROFILE_TESTS/ONLY_TESTS env block, and a
selector input, all to support the same thing: run one leg of one suite on
demand, under a profiler, optionally narrowed to named tests.

profile.backend.yml now owns the only workflow_dispatch and calls the four
unchanged. They declare nothing for this feature: inside a called workflow
`github.event` is the caller's payload, so they read `target` and `tests` off
`github.event.inputs` directly. On a normal CI run those are absent and render
empty, so every gate's `== ''` clause holds and each suite behaves as before.

One `target` dropdown rather than a suite plus a leg keeps the gates at their
existing two clauses, and disambiguates `postgres`, which names a leg in three
suites.

Also fixes two ways a profiled run produced no flamegraph:

- test-driver built `:profile-tests` into BASE, but BASE is used only on the
  ONLY_TESTS branch. Profiling without a test selection ran unprofiled while
  still reporting profiled=true. The alias now goes on ALIASES too, which the
  other three branches use.
- The multi-JVM path runs each JVM from a sibling directory, so the profiler's
  relative output dir resolves per JVM. merge_outputs copied back junit and
  logs but not the flamegraphs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Collect profile output from multi-JVM test runs

collect-profile predates the multi-JVM test path and reads only the layout a
single-JVM run produces. A partitioned run nests its junit XML one directory per
partition under target/junit, and merges one logs/test-log-p<index>.json per
JVM, so the non-recursive `target/junit/*.xml` glob and the exact
`logs/test-log.json` path matched nothing: an empty wall-time summary, and an
artifact holding the flamegraphs alone.

The two summary greps become `grep -r --include='*.xml'`, which reads both
layouts without needing globstar, and the upload paths become globs matching
test-driver's own failure-log upload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Simplify inputs

* Use `test` namespace

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 18:40:49 +00:00
Timofey Kachalov
6c4b0182c1 Fixed custom actions from the mapQuestionClickActions plugin not appearing in EditableDashboard click action popover (#82832) 2026-09-23 22:33:12 +04:00
Phoomparin Mano
0694a11c90 Fix typed schema action template tag handling (#82488)
Co-authored-by: Timofei <timofei@metabase.com>
2026-09-23 19:41:59 +04:00
Egor Grushin
fa20179ff6 Restyle Button to the Figma spec and migrate call sites (#82236) 2026-09-23 16:59:13 +02:00
Riley Thompson
cdc7f386af Compile mbql stages to CTEs for postgres (#81914)
* initial commit

* Use CTEs for every multi-stage Postgres query

Drop the "standalone statement" gate and the native/persisted-stage and
join-source fallbacks. Postgres accepts WITH everywhere we embed a compiled
query (JOIN parens, CTE bodies, card-reference subqueries, CREATE TABLE AS,
INSERT INTO), so the only remaining condition is the driver opting in and
the query having more than one stage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix changelog

* simplify with mapv

* comments

* Build CTE tests with lib and assert results only

Rewrite the four multi-stage tests added on this branch to build their
queries with lib/query and a metadata provider instead of mt/mbql-query,
and drop the compiled-SQL string assertions so they only check that each
query runs on Postgres and returns the expected rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* trim comments

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 14:09:50 +00:00
github-automation-metabase
d994a365f7 Auto-update documentation for master (#82878)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-23 09:40:25 -04:00
Phoomparin Mano
d7b9a71698 Fix including library sub-collection throws an error in typed schemas (#82513)
Co-authored-by: Timofei <timofei@metabase.com>
2026-09-23 17:34:22 +04:00
YB
12a22c0392 [GDGT-3229] read ClickHouse indexes from SHOW CREATE TABLE instead of system tables (#82573)
ClickHouse denies SELECT on system.data_skipping_indices, so index verification failed for every request (the index itself was created). fetch-table-indexes now reads one SHOW CREATE TABLE and parses its INDEX and ORDER BY lines. A failed warehouse read now carries the driver's message on the request and as warehouse_error on the indexes list, with an alert on the indexes page.
2026-09-23 21:14:23 +08:00
Ryan Laurie
6ec07f7518 Pin to clickhouse 26.8 (#82868)
pin to clickhouse 26.8
2026-09-22 22:28:11 +00:00
Fraser
aa9c3797e8 Move the embed setup modal state into the embedding module (#81656)
Co-authored-by: Romeo Van Snick <romeo@romeovansnick.be>
2026-09-22 18:27:38 -04:00
Kyle Doherty
401167cc00 Merge branch 'master' into nav-split-prototype 2026-09-22 06:20:55 -04:00
Kyle Doherty
2947b47c23 Select Library tables in the Official rail
A published table has no page of its own — the rail links it as the
ad-hoc question that reads it, /question#<serialized card> — so there was
no card id to key a selection off and the row never highlighted.

Recover the table from the serialized card in the hash, load it, and
select it. Its collection also feeds the section seed, so landing on or
reloading a table URL stays on Official, matching metrics.

The hash is user-controlled, so its shape is checked rather than
asserted; anything unrecognised just leaves the rail alone.
2026-09-21 15:41:57 -04:00
Kyle Doherty
abb710334f Keep Library content out of the open rail
Opening a metric pushed it into the Unofficial rail's open list. The
Library is a curated reference you navigate to and read, not a piece of
work you open, and it already has its own home in the Official rail.

Skip any card or dashboard whose collection is part of the Library.
Dashboards cannot live there today, but the rule is about the Library
rather than about metrics, so it is applied to both.
2026-09-21 15:36:48 -04:00
Kyle Doherty
fed0dbfd58 Read the metric route's card id from the right param
The metric routes declare their param as :cardId, but the navbar read
params.slug for every card path. On /metric/:cardId that was undefined,
so the card was never fetched — which meant no collection to derive the
nav section from and nothing to select, leaving a reload on Unofficial
with nothing highlighted.

With the card loaded the rest of the chain already worked: the seed
resolves to Official off the library collection, the selection resolves
to metric-<id>, and the tree auto-expands the ancestors of the selected
node, so the library collection opens with the metric highlighted.
2026-09-21 15:34:36 -04:00
Kyle Doherty
edd44ef851 Seed the nav section from what is open, and select metric pages
Reloading an official metric dropped the rail to Unofficial: the section
override lives in redux, so a refresh wiped it and the fallback keyed off
the pathname, which says nothing about authority. /metric/... is not
/browse, so it resolved to Unofficial.

Derive a seed from the collection of whatever is open, taking Official
when that collection is marked official or is part of the Library.
Precedence is now explicit choice, then the open entity, then the URL.

getSelectedItems ignored metric paths entirely, so nothing was selected
on a metric page. Handle them alongside questions and models, and carry
the search model on the selection so the Official rail, whose rows are
keyed model-id, can highlight the open entity rather than only its
containing collection.
2026-09-21 15:27:25 -04:00
Kyle Doherty
1cd4d62061 Make the Official/Unofficial switcher a view-only toggle
Switching sections navigated to whatever you last had open in the target
half, so flipping the rail threw away the page you were on. The rail is
a view of the sidebar, not a destination; toggling it should only change
what the sidebar shows.

Drop the navigate call. That makes hrefFor, the per-section last-path
map and the effect feeding it dead, so they go too. The URL still seeds
the initial section for a deep link.
2026-09-21 15:18:50 -04:00
Kyle Doherty
426c9e6406 Use the resolved collection icon in the main navbar
buildCollectionTree already derives each node's icon via
getCollectionIcon, which is where a collection's own icon is honoured.
SidebarCollectionLink then re-derived it from the tree node, whose icon
field is the resolved IconData object rather than the stored name, so a
custom icon never survived to the Official and Library nav sections and
every folder fell back to its type default.

Read the resolved icon off the node instead. The tenant override moves
with it: MainNavbarContainer already passes isTenantUser into the tree
builder, and the one remaining caller that did not is unreachable for
tenant users.
2026-09-20 09:26:20 -04:00
Kyle Doherty
feb7aa645c Allow arbitrary Library folders with custom icons
Library folders were limited to the two seeded Data and Metrics
collections. The backend already inherited the library type onto
children, so subfolders worked, but nothing was exposed in Data Studio
and the Library root rejected everything but those two sections.

Top-level folders reuse the existing `library` type rather than adding
a new one: the root is told apart from user folders by is_library_root,
which is keyed off the hard-coded entity_ids and already existed. The
content rules become: the root holds folders only, a user folder holds
tables, metrics and nested folders, Data and Metrics are unchanged.

Collections gain a nullable icon column so a folder can carry a chosen
icon; when it is null the icon is still derived from the type.

canPlaceEntityInCollection now takes the destination collection instead
of a bare type string. The rule depends on root-vs-folder, and those
share a type, so the string alone would have made the Library root a
valid table-publish target.

The frontend move rule disagreed with the backend in both directions,
permitting a Data subfolder onto the root and blocking user folders from
nesting. It now mirrors check-library-update.
2026-09-20 09:19:20 -04:00
Kyle Doherty
5349fb75d7 clean up crumbs 2026-09-17 16:30:13 -04:00
Kyle Doherty
5073c20eb1 Metabot conversations show up in the Open list
Starting a conversation with Metabot now puts it in the rail's Open list, next
to the questions and dashboards you have open, so the sidebar panel's thread is
reachable again after you navigate away from it.

- `getStartedConversations` lists the conversations with at least one message,
  in creation order. Empty ones are skipped — an agent always holds a
  conversation, so listing those would show a row for Metabot merely existing —
  and the SQL-suggestion and explorations agents are excluded, since they answer
  in place and have no conversation page to link to.
- Rows link to /metabot/conversation/:id. The title is generated after the first
  answer, so a row starts as "New conversation" and renames itself in place when
  the title lands.
- The selector holds its result identity while ids and titles are unchanged, so
  a streaming answer does not re-fire the effect on every token.
2026-09-17 13:19:43 -04:00
Kyle Doherty
9d6d4c656e Raw data becomes a drawer with a warehouse tree
Data gets the same treatment as collections: one rail row, no section heading,
and a drawer holding the thing itself rather than a link to a page about it.

- The row reads Raw data and opens a drawer with the warehouse as a tree:
  databases, their schemas, and the tables in them. Every row links to the
  browse page it stands for, so the tree is a faster route to the same
  destinations, and any node can be expanded in place instead.
- Children are fetched only when a node is expanded, so opening the drawer costs
  one database list and nothing else.
- Tables with virtual ids (cards in the Saved Questions database) are left out:
  they have no /table/:slug route to link to.
- The Add data action moves from the old section header into the drawer header.
  `BrowseNavSection` and its spec are gone, along with the
  `expand-browse-in-nav` setting it read.
2026-09-17 13:08:57 -04:00
Kyle Doherty
2f02a29249 Data only in Unofficial, Getting Started collapsed, Trash in the drawer
- The Data section (Databases) moves inside the Unofficial branch. Official is
  curated content; browsing the warehouse belongs to the creation half.
- Getting Started starts collapsed, so the rail opens on the user's own work
  rather than on onboarding.
- Trash moves to the bottom of the collections drawer, where the things it holds
  were deleted from, instead of being a permanent rail row.
2026-09-17 13:02:22 -04:00
Kyle Doherty
3b9a1df5ac Collections move to a slide-out drawer; the rail lists what is open
The Unofficial rail stops being a place to browse collections and becomes a
place to see what you are working on, matching the prototype's explore rail.

- Collections are now one row that slides a drawer out from the right edge of
  the rail, floating over the content: the tree, other users' personal
  collections and the create action all move into it. The drawer closes on
  Escape, on clicking outside the rail, and on navigating — picking something is
  the drawer's whole job, so it gets out of the way afterwards. The
  `expand-collections-in-nav` setting is no longer read.
- Opening a question, model, metric or dashboard adds it to an Open list in the
  rail, so several things can be held at once and switched between. Closing a
  row only unlists it; the entity is untouched and reopening restores the row in
  place. State is page-session only (`openNavItems` in the app slice), like the
  section switcher.
- Models and Metrics are gone from Data, which is now just Databases. The browse
  list pages stay routed; they are reached from content rather than from a
  permanent nav row.
2026-09-17 12:40:22 -04:00
Kyle Doherty
7e9ffc83ce Remove the top app bar; rail is always shown, header owns search and account
The app bar is gone. In its place the content area gets its own header —
where you are on the left, search and the account switcher on the right —
matching the prototype, where the rail runs the full height of the window and
each page carries its own header.

- New `AppPageHeader`, rendered once above the scrolling content for every
  non-admin page, holding the collection breadcrumbs, the search button and the
  app switcher. `getIsAppBarVisible` becomes `getIsPageHeaderVisible` and keeps
  gating admin / Data Studio / Monitor / fullscreen / embedding.
- The New button moves to a footer pinned over the bottom of the rail, and the
  Metabot button and git-sync controls move into the rail, all inside the
  Unofficial half: that is the creation section, and the prototype's rail footer
  belongs to creation too.
- The logo, the sidebar toggle and the whole open/close/collapse-by-pathname
  machinery are deleted: `isNavbarOpen`, its three actions, `getIsNavbarOpen`,
  `isNavbarOpenForPathname`, `useCloseNavbarOnMount`, and every caller that
  closed the rail on mount or on entering a sidebar. The rail no longer changes
  with the entity you are looking at.
- Consumers that positioned themselves around a collapsible rail now assume it
  is always there: the bulk-action toast is always offset by the rail width, the
  detail view always subtracts it from the viewport, and the query builder
  header always uses its narrow-viewport layout.

Question lineage and the detail-view row nav went with the app bar; the
prototype puts that context in each page's own header rather than in app chrome.
`SearchBar` (the inline field used only by interactive embedding's app bar) is
left in the tree but is no longer reachable.
2026-09-17 12:29:50 -04:00
Kyle Doherty
35bf6ca092 Official/Unofficial nav split, prototype metric & model detail pages
Ports the load-bearing parts of the consumption/creation split prototype.

Nav
- Two-segment Official/Unofficial switcher at the top of the main rail,
  backed by a `navSection` app-state slice (page-session, not persisted).
  Section is seeded from the URL for deep links, then user-driven, and
  remembers the last path per section for the life of the page.
- Official renders the Library and official-collection trees with their
  *items* hanging off each collection; Unofficial keeps Home, Bookmarks,
  the collections tree, data apps and Trash. The Data links render in both.
- Official membership is the item's own collection being marked official or
  the item living in the Library. Authority level does not inherit in
  Metabase, so neither does this. There is no authority-level search
  parameter, so the official half is filtered client-side.

Browse
- /browse/metrics and /browse/models get the prototype shell (icon + title,
  dot-separated meta, description paragraph) via a shared BrowsePageLayout,
  with the table inside a bordered panel and a header button that flips the
  section. No tabs - the nav switcher is the control.
- Both tables gain a non-sortable Owner column. Model rows now open the
  model detail page rather than the query builder.

Metric page
- The pill-tab bar is gone; the About route is the whole read view: headline
  figure with prior-period and year-over-year deltas, chart, dimension pills,
  then Definition / Breakdowns / Dimensions / Used by / History panels beside
  a sticky fact rail. The six routes stay so Data Studio's re-hosting keeps
  working, reachable through a Back to metric link.
- Used by embeds the existing EE dependency graph rather than a port of the
  prototype's own graph.

Model page
- New /model/:slug/detail with Fields / Definition / Used by / History and
  the same fact rail; the index no longer redirects to actions.

Avatars
- UserAvatar draws a deterministic DiceBear glyphs tile from a stable seed
  instead of rendering initials on a brand-coloured div, so the same person
  carries the same avatar everywhere. The bg colour plumbing it replaces is
  removed, along with the now-dead colour helpers and userColor props.

Notes
- TrendInfo/AboutVisualization are deleted: the headline card now owns the
  figure and its deltas, and printing both would have doubled the number.
- The metric dataset-value helpers moved to common/metrics so the metrics
  feature can use them without importing from browse.
- ModelHistory/ModelUsedBy are local to the models feature because module
  boundaries forbid models -> query_builder imports.
2026-09-17 11:59:28 -04:00
1063 changed files with 18757 additions and 11428 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
name: Profile Backend
# The only on-demand entry point for the backend test suites. Dispatching this workflow runs one
# job of one suite against the ref you dispatch on, under clj-async-profiler, and attaches a
# flamegraph to the job.
#
# The suite workflows below are called unchanged: services, matrix legs, test args and quarantine
# gating are whatever the normal CI run uses, because they are the same jobs. They declare nothing
# for this feature -- they read `suite`, `db` and `tests` off `github.event.inputs`, which inside a
# called workflow is this dispatch's payload.
#
# `suite` picks the workflow, `db` picks the leg within it. `db` is free text so that adding a
# driver or an app-DB leg needs no edit here; an unknown driver fails the drivers run through
# mage's `--only-driver`, and an unknown app-DB or semantic-search leg matches nothing and runs no
# jobs.
#
# Renaming an input here means updating every reader -- `grep -rn github.event.inputs .github/`.
# Nothing enforces that, deliberately: a stale name reads as empty, which runs every leg of the
# suite unprofiled, on the run you are watching. It cannot affect a normal CI run, where those
# references are empty either way.
on:
workflow_dispatch:
inputs:
suite:
description: 'Which suite to profile.'
type: choice
required: true
options:
- backend
- app-db
- drivers
- semantic-search
db:
description: 'Which leg of the suite: app-db takes mariadb, mysql or postgres; drivers takes a driver name, e.g. bigquery or mongo-ssl; semantic-search takes h2, mariadb, mysql, postgres or appdb-mode. Ignored by backend. Blank runs the whole suite -- every leg for app-db and semantic-search, mage''s usual decisions for drivers.'
type: string
required: false
default: ''
tests:
description: 'Optional: profile only these tests, comma- or space-separated, e.g. metabase.query-processor-test/some-test. Bare namespaces work too. Blank profiles the leg''s usual set.'
type: string
required: false
default: ''
# These jobs gate on `inputs.suite` -- this workflow's own input. Only the called workflows reach
# for `github.event.inputs.*`, because `inputs` there is their own `workflow_call` signature.
jobs:
backend:
if: ${{ inputs.suite == 'backend' }}
uses: ./.github/workflows/backend.yml
secrets: inherit
app-db:
if: ${{ inputs.suite == 'app-db' }}
uses: ./.github/workflows/app-db.yml
secrets: inherit
drivers:
if: ${{ inputs.suite == 'drivers' }}
uses: ./.github/workflows/drivers.yml
secrets: inherit
semantic-search:
if: ${{ inputs.suite == 'semantic-search' }}
uses: ./.github/workflows/semantic-search.yml
secrets: inherit

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

After

Width:  |  Height:  |  Size: 518 KiB

View File

@@ -58,6 +58,11 @@ const config: StorybookConfig = {
name: "@storybook/react-webpack5",
options: {},
},
build: {
test: {
disableTreeShaking: false,
},
},
typescript: {
reactDocgen: "react-docgen-typescript",
reactDocgenTypescriptOptions: {

View File

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

@@ -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=="],

View File

@@ -32,7 +32,7 @@ Upload a custom icon. SVG or PNG with a transparent background gives the cleanes
![Metabot illustrations](./images/metabot-illustrations.png)
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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View 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**.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }) => {

View File

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

View File

@@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More