Compare commits

...

55 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
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
504 changed files with 12116 additions and 8545 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.

After

Width:  |  Height:  |  Size: 80 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

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

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

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

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

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

View File

@@ -241,82 +241,6 @@ describe("converting question to SQL (metabase#12651, metabase#21615, metabase#3
});
});
describe(
"converting question to a native query (metabase#15946, metabase#32121, metabase#38181, metabase#40557)",
{ tags: "@mongo" },
() => {
const MONGO_DB_NAME = "QA Mongo";
beforeEach(() => {
H.restore("mongo-5");
cy.signInAsAdmin();
});
it("should work for both simple and nested questions based on previously converted GUI query", () => {
H.startNewQuestion();
H.miniPicker().within(() => {
cy.findByText(MONGO_DB_NAME).click();
cy.findByText("Products").click();
});
cy.log("Simple question");
openSidebar("native");
cy.intercept("POST", "/api/dataset").as("dataset");
cy.findByTestId("native-query-preview-sidebar").within(() => {
cy.findByText("Native query for this question").should("exist");
H.NativeEditor.get()
.should("be.visible")
.and("contain", "$project")
.and("not.contain", "$limit");
cy.button("Convert this question to a native query").click();
});
cy.wait("@dataset");
cy.log("Database and table should be pre-selected (metabase#15946)");
cy.findByTestId("selected-database").should("have.text", MONGO_DB_NAME);
cy.findByTestId("selected-table").should("have.text", "Products");
cy.get("[data-testid=cell-data]").should("contain", "Small Marble Shoes");
cy.log("Nested question");
cy.log(
"should be possible to save a question and `Explore results` (metabase#32121)",
);
H.saveQuestion("foo", undefined, {
path: ["Our analytics"],
});
cy.intercept("POST", "/api/dataset").as("exploreDataset");
cy.findByTestId("qb-header").findByText("Explore results").click();
cy.wait("@exploreDataset");
cy.get("[data-testid=cell-data]").should("contain", "Small Marble Shoes");
cy.log("The generated query should be valid (metabase#38181)");
H.openNotebook();
openSidebar("native");
cy.intercept("POST", "/api/dataset").as("dataset2");
cy.findByTestId("native-query-preview-sidebar").within(() => {
cy.findByText("Native query for this question").should("exist");
H.NativeEditor.get()
.should("be.visible")
.and("contain", "$project")
.and("not.contain", "$limit")
.and("not.contain", "BsonString")
.and("not.contain", "BsonInt32");
cy.button("Convert this question to a native query").click();
});
cy.wait("@dataset2");
cy.log(
"Database and table should be pre-selected (metabase#15946 and/or metabase#40557)",
);
cy.findByTestId("selected-database").should("have.text", MONGO_DB_NAME);
cy.findByTestId("selected-table").should("have.text", "Products");
cy.get("[data-testid=cell-data]").should("contain", "Small Marble Shoes");
});
},
);
describe("scenarios > notebook > native query preview sidebar tracking events", () => {
beforeEach(() => {
H.resetSnowplow();
@@ -399,12 +323,10 @@ function resizeSidebar(amountX: number, cb: ResizeSidebarCallback) {
});
}
function openSidebar(variant: "sql" | "native" = "sql") {
const label = variant === "sql" ? "View SQL" : "View native query";
cy.findByLabelText(label).should("be.visible").click();
function openSidebar() {
cy.findByLabelText("View SQL").should("be.visible").click();
}
function closeSidebar(variant: "sql" | "native" = "sql") {
const label = variant === "sql" ? "Hide SQL" : "Hide native query";
cy.findByLabelText(label).should("be.visible").click();
function closeSidebar() {
cy.findByLabelText("Hide SQL").should("be.visible").click();
}

View File

@@ -663,43 +663,6 @@ describe("scenarios > question > notebook", { tags: "@slow" }, () => {
});
});
it("should not crash notebook when metric is used as an aggregation and breakout is applied (metabase#40553)", () => {
H.createQuestion(
{
query: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.SUBTOTAL, null]]],
},
type: "metric",
name: "Revenue",
},
{
wrapId: true,
idAlias: "metricId",
},
);
cy.get("@metricId").then((metricId) => {
const questionDetails = {
query: {
"source-table": ORDERS_ID,
breakout: [
["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
],
aggregation: ["metric", metricId],
},
};
H.createQuestion(questionDetails, { visitQuestion: true });
H.openNotebook();
H.getNotebookStep("summarize").contains("Revenue").click();
H.CustomExpressionEditor.value().should("equal", "[Revenue]");
});
});
it("should open only one bucketing popover at a time (metabase#45036)", () => {
H.visitQuestionAdhoc(
{

View File

@@ -3,7 +3,6 @@ import { chunk } from "underscore";
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { ADMIN_USER_ID } from "e2e/support/cypress_sample_instance_data";
const { ORDERS, ORDERS_ID, PEOPLE, PEOPLE_ID, PRODUCTS, PRODUCTS_ID } =
SAMPLE_DATABASE;
@@ -267,49 +266,6 @@ describe("#22206 adding and removing columns doesn't duplicate columns", () => {
});
});
describe("issue 23076", () => {
const questionDetails = {
name: "Orders, Distinct values of ID, Grouped by Product → Title and Created At (month) and User → ID",
query: {
"source-table": ORDERS_ID,
aggregation: [["distinct", ["field", ORDERS.ID, null]]],
breakout: [
["field", PRODUCTS.TITLE, { "source-field": ORDERS.PRODUCT_ID }],
["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
["field", PEOPLE.ID, { "source-field": ORDERS.USER_ID }],
],
},
display: "pivot",
visualization_settings: {
"pivot_table.column_split": {
rows: ["TITLE", "CREATED_AT", "ID"],
columns: [],
values: ["distinct"],
},
},
};
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
cy.request("PUT", `/api/user/${ADMIN_USER_ID}`, {
locale: "en-ZZ",
});
H.createQuestion(questionDetails, { visitQuestion: true });
});
it("should correctly translate dates (metabase#23076)", () => {
cy.findAllByText(/^\[zz\] Totals for/)
.should("be.visible")
.eq(1)
.invoke("text")
.should("eq", "[zz] Totals for May 2026");
});
});
describe("issue 28304", () => {
const questionDetails = {
name: "28304",

View File

@@ -171,8 +171,8 @@
body :- ::publish-table-selectors]
(api/check-data-analyst)
(let [target-collection (api/check-404 (data-studio.db/collection (:collection_id body)))
_ (api/check-400 (= (:type target-collection) collection/library-data-collection-type)
(tru "Tables can only be published to Library/Data collections."))
_ (api/check-400 (collection/can-contain-published-tables? target-collection)
(tru "Tables can only be published to Library/Data collections or Library folders."))
selectors (body->table-selectors body)
upstream-ids (all-upstream-table-ids selectors)
;; Don't move already-published upstream tables; only publish unpublished ones. Get table IDs before update

View File

@@ -34,10 +34,11 @@
:archive-operation-id nil})]
:order-by [[:%lower.name :asc]]}))
(mu/defn collection-type
"The type of the Collection with `collection-id`."
(mu/defn collection-type-and-entity-id
"The `:type` and `:entity_id` of the Collection with `collection-id` — enough to tell a library folder from one of
the seeded Library roots via [[metabase.collections.models.collection/library-root-collection?]]."
[collection-id :- ::lib.schema.id/collection]
(t2/select-one-fn :type [:model/Collection :type] :id collection-id))
(t2/select-one [:model/Collection :type :entity_id] :id collection-id))
(mu/defn unarchived-card-collection-types-reducible
"Reducible distinct Collection ID and Card type pairs of the unarchived Cards."

View File

@@ -6,20 +6,31 @@
[metabase.premium-features.core :refer [defenterprise]]
[toucan2.core :as t2]))
(def ^:private library-collection-content-specs
"A map from the `:type` of the parent collection to a spec for what contents it permits."
{collection/library-collection-type
{:allowed-content-types #{collection/library-data-collection-type
collection/library-metrics-collection-type}
:error-message "Cannot add anything to the Library collection"}
(defn- library-content-spec
"A spec for what contents `parent` permits, or `nil` if `parent` is not a library collection at all.
collection/library-data-collection-type
{:allowed-content-types #{:table collection/library-data-collection-type}
:error-message "Can only add tables to the 'Data' collection"}
The Library root only holds folders; a generic (user-created) library folder holds published tables, metrics, and
more folders; the seeded 'Data' and 'Metrics' trees stay single-purpose."
[{parent-type :type :as parent}]
(condp = parent-type
collection/library-collection-type
(if (collection/library-root-collection? parent)
{:allowed-content-types #{collection/library-collection-type
collection/library-data-collection-type
collection/library-metrics-collection-type}
:error-message "Only folders can be added to the Library"}
{:allowed-content-types #{:table :metric collection/library-collection-type}
:error-message "Can only add tables, metrics, and folders to a Library folder"})
collection/library-metrics-collection-type
{:allowed-content-types #{:metric collection/library-metrics-collection-type}
:error-message "Can only add metrics to the 'Metrics' collection"}})
collection/library-data-collection-type
{:allowed-content-types #{:table collection/library-data-collection-type}
:error-message "Can only add tables to the 'Data' collection"}
collection/library-metrics-collection-type
{:allowed-content-types #{:metric collection/library-metrics-collection-type}
:error-message "Can only add metrics to the 'Metrics' collection"}
nil))
(defenterprise check-allowed-content
"Check if the collection's content matches the allowed content.
@@ -27,13 +38,13 @@
:feature :library
[content-type collection-id]
(when collection-id
(let [collection-type (library.db/collection-type collection-id)]
(when-let [{:keys [allowed-content-types error-message]} (some-> collection-type
library-collection-content-specs)]
(let [parent (library.db/collection-type-and-entity-id collection-id)]
(when-let [{:keys [allowed-content-types error-message]} (library-content-spec parent)]
(when-not (allowed-content-types content-type)
(throw (ex-info error-message {:status-code 400}))))
(when (and (= content-type :table) (not= collection-type collection/library-data-collection-type))
(throw (ex-info "Tables can only be added to 'Data' collections" {:status-code 400})))))
(when (and (= content-type :table)
(not (collection/can-contain-published-tables? parent)))
(throw (ex-info "Tables can only be added to Library folders" {:status-code 400})))))
true)
(defenterprise check-library-update
@@ -49,6 +60,6 @@
(when (and (collection/is-library-collection? (:id collection))
(contains? change-keys :location)
(when-let [parent-id (collection/location-path->parent-id (:location collection))]
(not= (:type collection) (library.db/collection-type parent-id))))
(not= (:type collection) (:type (library.db/collection-type-and-entity-id parent-id)))))
(throw (ex-info "Cannot move a Library collection outside the Library" {}))))
true)

View File

@@ -61,10 +61,12 @@
;; the branch the client believes is currently active; rejected if it disagrees with the
;; remote-sync-branch setting (a pull/switch from a stale tab). `branch` is the operational
;; target (it differs from this on a branch switch); `expected_branch` is only the assertion.
[:expected_branch ms/NonBlankString]]]
;; Optional because headless clients (the `mb` CLI) hold no cached view that can go stale.
[:expected_branch {:optional true} ms/NonBlankString]]]
(api/check-superuser)
(api/check-400 (settings/remote-sync-enabled) "Remote sync is not configured.")
(check-branch-matches-setting! expected_branch)
(when expected_branch
(check-branch-matches-setting! expected_branch))
(let [branch-name (or branch (settings/remote-sync-branch))
user-id api/*current-user-id*
{task-id :id}

View File

@@ -3,7 +3,6 @@
additional logic, so no other namespace in the module runs a query itself."
(:require
[metabase-enterprise.remote-sync.schema :as remote-sync.schema]
[metabase.collections.core :as collections]
[metabase.collections.schema :as collections.schema]
[metabase.lib.schema.id :as lib.schema.id]
[metabase.util.malli :as mu]
@@ -164,6 +163,12 @@
ids :- [:sequential ms/PositiveInt]]
(t2/select [model :id :name] :id [:in ids]))
(mu/defn archived-by-id
"A map of ID to the `:archived` flag of the instances of `model` with `ids`."
[model :- :keyword
ids :- [:sequential ms/PositiveInt]]
(t2/select-pk->fn :archived model :id [:in ids]))
(mu/defn instances-in-collections
"The instances of `model` in the Collections with `collection-ids`, excluding those archived under the optional
`archived-key` column."
@@ -321,8 +326,9 @@
"Matches `collections` and all of their descendants."
[collections]
(into [:or [:in :id (map :id collections)]]
(for [collection collections]
[:like :location (str (collections/location-path collection) "%")])))
(for [{:keys [id location]} collections]
;; the location its children have, so a nested collection's descendants match too
[:like :location (str location id "/%")])))
(mu/defn collections
"The Collections with `collection-ids`."
@@ -533,6 +539,13 @@
[collection-ids :- [:set ::lib.schema.id/collection]]
(t2/select [:model/RemoteSyncObject :id :status] {:where (contents-rso-expr collection-ids)}))
(mu/defn content-rsos
"The `:id`, `:model_type`, `:model_id`, `:model_collection_id`, and `:status` of the RemoteSyncObjects of the
Collections with `collection-ids` and of their contents."
[collection-ids :- [:set ::lib.schema.id/collection]]
(t2/select [:model/RemoteSyncObject :id :model_type :model_id :model_collection_id :status]
{:where (contents-rso-expr collection-ids)}))
(mu/defn removed-content-rso-ids
"The IDs of the RemoteSyncObjects pending removal among those of the Collections with `collection-ids` and their
contents."

View File

@@ -295,6 +295,37 @@
(log/info "Library collection is no longer remote-synced, disabling Library content sync tracking")
(disable-library-tracking!)))))
(defn- cascade-archived-state!
"Brings the RemoteSyncObject rows of `collection`'s subtree, other than its own row, in line with the archived state
of each entity and of the collection it is in: rows of entities that are archived or in an archived collection
become 'delete', and 'delete' rows of existing entities that are neither become 'update'."
[collection]
;; Archiving a collection archives its subtree in bulk SQL, which publishes no event per descendant. Transforms
;; have no archived column, so only their collection shows that they were archived.
(let [collection-ids (remote-sync.db/subtree-collection-ids [collection])
collection-archived? (remote-sync.db/archived-by-id :model/Collection (vec collection-ids))
rows (->> (remote-sync.db/content-rsos collection-ids)
(remove #(and (= "Collection" (:model_type %)) (= (:id collection) (:model_id %)))))
now (t/offset-date-time)]
(doseq [[model-type type-rows] (group-by :model_type rows)
:let [{:keys [model-key archived-key]} (spec/spec-for-model-type model-type)]
:when (= :archived archived-key)
:let [entity-archived? (remote-sync.db/archived-by-id model-key (mapv :model_id type-rows))
archived? (fn [{:keys [model_id model_collection_id]}]
(or (true? (entity-archived? model_id))
(true? (collection-archived? model_collection_id))))
deleted (filter #(and (archived? %)
(not (contains? #{"delete" "removed"} (:status %))))
type-rows)
restored (filter #(and (contains? entity-archived? (:model_id %))
(not (archived? %))
(= "delete" (:status %)))
type-rows)]]
(when (seq deleted)
(remote-sync.db/set-rsos-status! (map :id deleted) "delete" now))
(when (seq restored)
(remote-sync.db/set-rsos-status! (map :id restored) "update" now)))))
(methodical/defmethod events/publish-event! ::collection-change-event
[topic event]
(let [{:keys [object]} event
@@ -313,7 +344,10 @@
should-sync?
(do
(log/infof "Creating remote sync object entry for collection %s (status: %s)" (:id object) status)
(create-or-update-remote-sync-object-entry! "Collection" (:id object) status hydrate-collection-details))
(create-or-update-remote-sync-object-entry! "Collection" (:id object) status hydrate-collection-details)
(when (and (= topic :event/collection-update)
(or (:archived object) (= "delete" (:status existing-entry))))
(cascade-archived-state! object)))
(and existing-entry (not should-sync?))
(do
(log/infof "Collection %s no longer needs syncing, marking as removed" (:id object))

View File

@@ -15,6 +15,7 @@
(java.nio.charset StandardCharsets)
(java.sql Connection SQLException)
(java.util.concurrent Executor)
(javax.sql DataSource)
(org.postgresql PGProperty)))
(set! *warn-on-reflection* true)
@@ -93,7 +94,11 @@
(into #{} (map #(.getName ^PGProperty %)) (PGProperty/values)))
(defn- url-decode ^String [^String s]
(URLDecoder/decode s StandardCharsets/UTF_8))
(try
(URLDecoder/decode s StandardCharsets/UTF_8)
(catch IllegalArgumentException _
;; the decoder's own message quotes part of the value, which may be a password
(throw (ex-info "Malformed %-escape in MB_PGVECTOR_DB_URL" {})))))
(defn- split-query
"Split a JDBC URL into [base pairs], where pairs is a seq of raw [key value] strings.
@@ -107,17 +112,63 @@
:let [[k v] (str/split pair #"=" 2)]]
[k (or v "")])]))
(defn- split-userinfo
"Split `user:password@` out of a JDBC URL's host part, returning [url credentials].
The credentials are a map of the decoded :user and :password, empty when the URL has none."
[^String base]
;; The host part runs from `//` to the next `/`.
;; Split it at its last `@`, not the first as libpq does, so an unencoded `@` in the password can't leave
;; part of it on the URL.
;; Any scheme matches, so a misspelt one, which DriverManager quotes, can't carry the credentials either.
(if-let [[_ scheme userinfo more] (re-matches #"([^/]*//)([^/]*)@(.*)" base)]
;; decode %-escapes only, as libpq does: a `+` here is a literal plus, not a space as in a query string
(let [decode #(url-decode (str/replace % "+" "%2B"))
[user password] (str/split userinfo #":" 2)
credentials (cond-> {:user (decode user)}
password (assoc :password (decode password)))]
[(str scheme more) credentials])
[base {}]))
(defn- check-no-stray-at!
"Throw if an `@` is left in `url` or `pairs` once the credentials are off, without quoting either."
[^String url pairs]
;; An `@` left on `url` means a password with an unencoded `/`, or an `@` that pgjdbc requires encoded anyway.
;; An unencoded `?` in a password ends the host part early, before the `/` pgjdbc requires after a host, and
;; moves the `@` into `pairs`.
;; Any other `@` in `pairs` is a plain value that pgjdbc accepts, such as Azure's `user=name@server`.
;; Refuse a stray `@` before an error or the URL can quote the part of the password it may hold.
(when (or (str/includes? url "@")
(and (re-matches #"[^/]*//[^/]+" url)
(some (fn [[k v]] (str/includes? (str k v) "@")) pairs)))
(throw (ex-info (str "MB_PGVECTOR_DB_URL has an unencoded @ outside its credentials. "
"Percent-encode reserved characters, e.g. @ as %40, / as %2F and ? as %3F.")
{}))))
(defn- parse-db-url
"Parse a pgvector JDBC URL into {:jdbc-url ... :pool-props ...}, or throw if it carries an unrecognized
parameter."
"Parse a pgvector JDBC URL into {:jdbc-url ... :credentials ... :pool-props ...}, or throw if it is
malformed or carries an unrecognized parameter.
Credentials come off the URL whether they're written as params or before the host."
[^String url]
(let [[base pairs] (split-query url)
default-pool (merge fixed-pool-props
(update-vals tunable-pool-props first))
{:keys [pool conn]}
(let [[base pairs] (split-query url)
[base-url host-credentials] (split-userinfo base)
_ (check-no-stray-at! base-url pairs)
default-pool (merge fixed-pool-props
(update-vals tunable-pool-props first))
{:keys [pool conn credentials]}
(reduce
(fn [acc [k raw-v]]
(cond
;; also set before the host: Postgres clients disagree on which wins, so refuse to pick one
(contains? host-credentials (keyword k))
(throw (ex-info (format "The pgvector URL sets %s both before the host and as a parameter" k)
{:param k}))
;; credentials: passed to pgjdbc as connection properties, off the URL, because the URL gets
;; printed -- pgjdbc logs it at DEBUG on every connection, and DriverManager quotes it in its
;; "No suitable driver" error
(contains? #{"user" "password" "sslpassword"} k)
(assoc-in acc [:credentials (keyword k)] (url-decode raw-v))
;; a c3p0 knob: coerce and apply to the pool (pgjdbc can't read these off the URL)
(contains? tunable-pool-props k)
(let [parse (second (tunable-pool-props k))
@@ -137,13 +188,14 @@
"(%s) or a Postgres connection property.")
k (str/join ", " (sort (keys tunable-pool-props))))
{:param k}))))
{:pool default-pool, :conn []}
{:pool default-pool, :conn [], :credentials host-credentials}
pairs)]
{:jdbc-url (cond-> base (seq conn) (str "?" (str/join "&" conn)))
:pool-props pool}))
{:jdbc-url (cond-> base-url (seq conn) (str "?" (str/join "&" conn)))
:credentials credentials
:pool-props pool}))
(defn- parsed-db-config
"Parse [[db-url]] into {:jdbc-url ... :pool-props ...}, or throw if it is unset."
"Parse [[db-url]] into {:jdbc-url ... :credentials ... :pool-props ...}, or throw if it is unset."
[]
(if db-url
(parse-db-url db-url)
@@ -165,16 +217,29 @@
(delay (.addShutdownHook (Runtime/getRuntime)
(Thread. ^Runnable shutdown-db! "semantic-search-pool-shutdown"))))
(defn- redacted-data-source
"Wrap `ds` so it prints without its JDBC URL, which can carry the database credentials.
c3p0 prints the data source it pools in its own string form, and that reaches the logs through exception
messages such as \"... has been closed() -- you can no longer use it\"."
^DataSource [^DataSource ds]
(reify DataSource
(getConnection [_] (.getConnection ds))
(getConnection [_ user password] (.getConnection ds user password))
(getLoginTimeout [_] (.getLoginTimeout ds))
(setLoginTimeout [_ seconds] (.setLoginTimeout ds seconds))
Object
(toString [_] "pgvector JDBC data source (URL redacted)")))
(defn init-db!
"Initialize c3p0 connection pool for semantic search database.
Requires MB_PGVECTOR_DB_URL environment variable."
[]
(locking data-source
(or @data-source
(let [{:keys [jdbc-url pool-props]} (parsed-db-config)
unpooled-ds (jdbc/get-datasource {:jdbcUrl jdbc-url})
(let [{:keys [jdbc-url credentials pool-props]} (parsed-db-config)
unpooled-ds (redacted-data-source (jdbc/get-datasource (assoc credentials :jdbcUrl jdbc-url)))
pooled-ds (DataSources/pooledDataSource
^javax.sql.DataSource unpooled-ds
unpooled-ds
(connection-pool/map->properties pool-props))]
(log/info "Initializing semantic search connection pool with properties:" pool-props)
(force shutdown-hook)
@@ -222,7 +287,7 @@
Always a one-shot connection: a pooled one carries the pool's timeouts, and waiting on a saturated pool
would read as an unreachable store."
[]
(jdbc/execute-one! (jdbc/get-datasource {:jdbcUrl (probe-jdbc-url)})
(jdbc/execute-one! (jdbc/get-datasource (assoc (:credentials (parsed-db-config)) :jdbcUrl (probe-jdbc-url)))
["SELECT 1 AS test"]
{:timeout probe-query-timeout-seconds}))

View File

@@ -101,9 +101,9 @@
(testing "returns 400 when collection_id is missing"
(mt/user-http-request :crowberto :post 400 "ee/data-studio/table/publish-tables"
{:table_ids [(mt/id :users)]}))
(testing "returns 400 when target collection is not a library-data collection"
(testing "returns 400 when target collection cannot hold published tables"
(mt/with-temp [:model/Collection {collection-id :id} {}]
(is (= "Tables can only be published to Library/Data collections."
(is (= "Tables can only be published to Library/Data collections or Library folders."
(mt/user-http-request :crowberto :post 400 "ee/data-studio/table/publish-tables"
{:table_ids [(mt/id :users)]
:collection_id collection-id})))))

View File

@@ -182,7 +182,8 @@
(mt/with-premium-features #{:library}
(let [search-term (random-name)]
(mt/with-temp
[:model/Collection {lib-id :id} {:name "Library" :type collection/library-collection-type :location "/"}
[:model/Collection {lib-id :id} {:name "Library" :type collection/library-collection-type :location "/"
:entity_id collection/library-entity-id}
:model/Collection {data-coll :id} {:name "Data"
:type collection/library-data-collection-type
:location (collection/location-path lib-id)}

View File

@@ -14,18 +14,65 @@
(is (some? (t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :metric, :collection_id (:id regular-collection)}))))
(is (some? (t2/insert! :model/Dashboard (merge (mt/with-temp-defaults :model/Dashboard) {:collection_id (:id regular-collection)}))))))))
(deftest library-completely-locked-down
(defn- library-root-defaults
"Temp-collection properties that make a collection look like the seeded Library root: the hard-coded
`entity_id` is what [[collection/library-root-collection?]] keys off."
[]
{:name "Test Library"
:type collection/library-collection-type
:entity_id collection/library-entity-id})
(defn- collection-under
"Fresh `:model/Collection` insert properties for a child of `parent`, with `:type` set to `folder-type`."
[parent folder-type]
(merge (mt/with-temp-defaults :model/Collection)
{:location (str (:location parent) (:id parent) "/")
:type folder-type}))
(deftest library-root-holds-only-folders
(mt/with-premium-features #{:library}
(mt/with-temp [:model/Collection no-allowed-content {:name "Test No Content" :type collection/library-collection-type}]
(testing "Cannot add anything to library collections"
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Cannot add anything to the Library collection"
(t2/insert! :model/Collection (merge (mt/with-temp-defaults :model/Collection) {:location (str "/" (:id no-allowed-content) "/")}))))
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Cannot add anything to the Library collection"
(t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :model, :collection_id (:id no-allowed-content)}))))
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Cannot add anything to the Library collection"
(t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :metric :collection_id (:id no-allowed-content)}))))
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Cannot add anything to the Library collection"
(t2/insert! :model/Dashboard (merge (mt/with-temp-defaults :model/Dashboard) {:collection_id (:id no-allowed-content)}))))))))
(mt/with-temp [:model/Collection library (library-root-defaults)]
(testing "folders of every library type can be created at the top level of the Library"
(doseq [folder-type [collection/library-collection-type
collection/library-data-collection-type
collection/library-metrics-collection-type]]
(testing (str "type " folder-type)
(is (= 1 (t2/insert! :model/Collection (collection-under library folder-type)))))))
(testing "the Library root rejects leaf content and untyped collections"
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Only folders can be added to the Library"
(t2/insert! :model/Table (merge (mt/with-temp-defaults :model/Table)
{:collection_id (:id library)
:is_published true}))))
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Only folders can be added to the Library"
(t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :metric :collection_id (:id library)}))))
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Only folders can be added to the Library"
(t2/insert! :model/Dashboard (merge (mt/with-temp-defaults :model/Dashboard) {:collection_id (:id library)}))))
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Only folders can be added to the Library"
(t2/insert! :model/Collection (collection-under library nil))))))))
(deftest library-folder-holds-tables-metrics-and-folders
(mt/with-premium-features #{:library}
(mt/with-temp [:model/Collection library (library-root-defaults)
:model/Collection folder {:name "My Folder"
:type collection/library-collection-type
:location (str "/" (:id library) "/")}]
(testing "a Library folder holds published tables"
(mt/with-temp [:model/Table table {:collection_id (:id folder)
:is_published true}]
(is (some? table))))
(testing "a Library folder holds metrics"
(mt/with-temp [:model/Card metric {:collection_id (:id folder)
:type :metric}]
(is (some? metric))))
(testing "a Library folder holds nested Library folders"
(is (= 1 (t2/insert! :model/Collection (collection-under folder collection/library-collection-type)))))
(testing "a Library folder rejects everything else"
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Can only add tables, metrics, and folders to a Library folder"
(t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :model :collection_id (:id folder)}))))
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Can only add tables, metrics, and folders to a Library folder"
(t2/insert! :model/Dashboard (merge (mt/with-temp-defaults :model/Dashboard) {:collection_id (:id folder)}))))
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Can only add tables, metrics, and folders to a Library folder"
(t2/insert! :model/Collection (collection-under folder nil))))))))
(deftest check-allowed-content-table
(mt/with-premium-features #{:library}
@@ -185,7 +232,7 @@
(deftest cannot-move-library-collections-to-vanilla-collection-test
(mt/with-premium-features #{:library}
(mt/with-temp [:model/Collection library-root {:name "Library" :type collection/library-collection-type}
(mt/with-temp [:model/Collection library-root (library-root-defaults)
:model/Collection data-root {:name "Data" :type collection/library-data-collection-type
:location (str "/" (:id library-root) "/")}
:model/Collection metrics-root {:name "Metrics" :type collection/library-metrics-collection-type

View File

@@ -12,6 +12,7 @@
[metabase.mcp.v2.message :as message]
[metabase.mcp.v2.redaction :as redaction]
[metabase.mcp.v2.registry :as registry]
[metabase.mcp.v2.test-util :as v2.tu]
[metabase.mcp.v2.tools.content :as tools.content]
[metabase.metabot.metadata-perms :as metadata-perms]
[metabase.test :as mt]
@@ -57,7 +58,7 @@
:include ["fields"]})]
(when error
(throw (ex-info (str "get_content rejected: " (message/render (:message error))) {:error error})))
(first (:results (json/decode+kw (-> result :content first :text))))))
(first (:results (json/decode+kw (v2.tu/strip-data-boundary (-> result :content first :text)))))))
(deftest sandboxed-caller-does-not-receive-fingerprints-test
(testing "a fingerprint is computed over every row of its table — `:min`/`:max` are individual cell

View File

@@ -8,6 +8,7 @@
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.test :as met]
[metabase.mcp.v2.test-util :as v2.tu]
[metabase.mcp.v2.tools.browse :as tools.browse]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
@@ -19,7 +20,7 @@
(defn- get-fields
[args]
(let [text (-> (tools.browse/browse-data args {}) :content first :text)]
(let [text (v2.tu/strip-data-boundary (-> (tools.browse/browse-data args {}) :content first :text))]
(json/decode+kw (first (str/split-lines text)))))
(defn- field-named

View File

@@ -13,6 +13,7 @@
[metabase.lib.metadata :as lib.metadata]
[metabase.mcp.v2.message :as message]
[metabase.mcp.v2.registry :as registry]
[metabase.mcp.v2.test-util :as v2.tu]
;; registers the run_saved_question tool for the call-tool seam below
[metabase.mcp.v2.tools.query]
[metabase.test :as mt]
@@ -48,7 +49,7 @@
(defn- response-text
"A dispatch outcome's text block, or a registry-level rejection's message."
[{:keys [result error]}]
(if error (message/render (:message error)) (-> result :content first :text)))
(if error (message/render (:message error)) (-> result :content first :text v2.tu/strip-data-boundary)))
(defn- dispatch-error?
"Whether a dispatch outcome is an error, at either layer: a registry-level rejection

View File

@@ -1,6 +1,7 @@
(ns metabase-enterprise.remote-sync.api-test
{:clj-kondo/config '{:linters {:deprecated-var {:exclude {metabase.test.data/mbql-query {:namespaces [metabase-enterprise.remote-sync.api-test]}}}}}}
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[diehard.core :as dh]
[java-time.api :as t]
@@ -13,10 +14,12 @@
[metabase-enterprise.remote-sync.source.git :as source.git]
[metabase-enterprise.remote-sync.source.protocol :as source.p]
[metabase-enterprise.remote-sync.test-helpers :as test-helpers]
[metabase.driver.settings :as driver.settings]
[metabase.settings.core :as setting]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[metabase.util :as u]
[metabase.util.quick-task :as quick-task]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
@@ -206,6 +209,79 @@
(is (=? {:status "success" :task_id int?} resp))
(is (remote-sync.task/successful? completed-task))))))))
(defn- gui-model-yaml
"YAML for a GUI model over `db-name`'s PUBLIC.VENUES, in the shape an export writes: the result metadata
carries only the model overrides, with no `base_type` and no field `id`."
[entity-id collection-id db-name]
(-> (test-helpers/generate-card-yaml entity-id "Venues Model" collection-id "model")
(str/replace "database_id: test-data (h2)" (str "database_id: " db-name))
(str/replace "dataset_query: {}"
(format (str "dataset_query:\n"
" database: %s\n"
" type: query\n"
" query:\n"
" source-table:\n"
" - %s\n"
" - PUBLIC\n"
" - VENUES")
db-name db-name))
(str/replace "result_metadata: null"
(str "result_metadata:\n"
(str/join (for [[col-name display-name] [["ID" "ID"]
["NAME" "Venue Name"]
["CATEGORY_ID" "Category ID"]
["LATITUDE" "Latitude"]
["LONGITUDE" "Longitude"]
["PRICE" "Price"]]]
(format "- display_name: %s\n name: %s\n visibility_type: normal\n"
display-name col-name)))))))
(deftest import-gui-model-before-schema-sync-test
(testing (str "GHY-4213: a GUI model imported before the target has synced its table stores untyped columns. "
"Once the schema sync runs, the model's columns must get their types and field ids, so that "
"the query builder offers typed filters on them.")
(let [details (:details (mt/db))
db-name (mt/random-name)
collection-id "ghy4213collectionxxxx"
card-eid "ghy4213modelxxxxxxxxx"]
;; Keep the new database from being synced when it is created, so its tables do not exist yet at import.
(mt/with-temporary-setting-values [disable-auto-sync true
remote-sync-url "https://github.com/test/repo.git"
remote-sync-token "test-token"
remote-sync-branch "main"]
(test-helpers/commit-with-temp
(fn []
(mt/with-temp [:model/Database {db-id :id} {:engine "h2" :details details :name db-name}]
(let [source (test-helpers/create-mock-source
:initial-files {"main" {(format "collections/%s_ghy/%s_ghy.yaml" collection-id collection-id)
(test-helpers/generate-collection-yaml collection-id "GHY 4213" :is-remote-synced true)
(format "collections/%s_ghy/cards/%s_venues_model.yaml" collection-id card-eid)
(gui-model-yaml card-eid collection-id db-name)}})]
(try
(mt/with-dynamic-fn-redefs [source/source-from-settings (constantly source)]
(let [{:keys [task_id]} (mt/user-http-request :crowberto :post 200 "ee/remote-sync/import"
{:force true :expected_branch "main"})]
(is (remote-sync.task/successful? (wait-for-task-completion task_id)))))
(mt/with-dynamic-fn-redefs [quick-task/submit-task! (fn [f]
(binding [driver.settings/*allow-testing-h2-connections* true]
(f)))]
(mt/user-http-request :crowberto :post 200 (format "database/%d/sync_schema" db-id)))
(let [card-id (t2/select-one-pk :model/Card :entity_id card-eid)
table-id (t2/select-one-pk :model/Table :db_id db-id :name "VENUES")
field-id (fn [field-name]
(t2/select-one-pk :model/Field :table_id table-id :name field-name))]
(is (=? [{:name "ID" :base_type "type/BigInteger" :id (field-id "ID")}
{:name "NAME" :base_type "type/Text" :id (field-id "NAME")
:display_name "Venue Name"}
{:name "CATEGORY_ID" :base_type "type/Integer" :id (field-id "CATEGORY_ID")}
{:name "LATITUDE" :base_type "type/Float" :id (field-id "LATITUDE")}
{:name "LONGITUDE" :base_type "type/Float" :id (field-id "LONGITUDE")}
{:name "PRICE" :base_type "type/Integer" :id (field-id "PRICE")}]
(:result_metadata (mt/user-http-request :crowberto :get 200 (str "card/" card-id))))))
(finally
(t2/delete! :model/Card :entity_id card-eid)
(t2/delete! :model/Collection :entity_id collection-id)))))))))))
(deftest import-with-specific-branch-test
(testing "POST /api/ee/remote-sync/import succeeds with specific branch"
(let [mock-develop (test-helpers/create-mock-source :branch "develop")]
@@ -239,6 +315,19 @@
(is (some (comp #{"Local Metric"} :name) (:dirty_objects resp))
"the response lists the un-pushed local metric"))))))))
(deftest import-without-expected-branch-succeeds-test
(testing "GHY-4636: `mb git-sync import` sends only `branch`; the import must run without `expected_branch`"
(let [mock-main (test-helpers/create-mock-source)]
(mt/with-temporary-setting-values [remote-sync-url "https://github.com/test/repo.git"
remote-sync-token "test-token"
remote-sync-branch "main"]
(mt/with-dynamic-fn-redefs [source/source-from-settings (constantly mock-main)]
(let [{:keys [task_id] :as resp} (mt/user-http-request :crowberto :post 200 "ee/remote-sync/import"
{:branch "main"})
completed-task (wait-for-task-completion task_id)]
(is (=? {:status "success" :task_id int?} resp))
(is (remote-sync.task/successful? completed-task))))))))
(deftest import-rejects-expected-branch-mismatch-test
(testing "POST /api/ee/remote-sync/import rejects when expected_branch disagrees with the configured setting"
(let [mock-main (test-helpers/create-mock-source)]

View File

@@ -3,9 +3,16 @@
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[diehard.core :as dh]
[metabase-enterprise.remote-sync.settings :as settings]
[metabase-enterprise.remote-sync.source :as source]
[metabase-enterprise.remote-sync.source.protocol :as source.p]
[metabase-enterprise.remote-sync.test-helpers :as test-helpers]
[metabase.collections.models.collection :as collection]
[metabase.events.core :as events]
[metabase.test :as mt]
[metabase.util :as u]
[metabase.util.yaml :as yaml]
[toucan2.core :as t2]))
(use-fixtures :each
@@ -97,3 +104,96 @@
(is (false? (:is_remote_synced response)))
(is (nil? (t2/select-one-fn :parent_id :model/Collection :id (:id child-collection))))
(is (false? (t2/select-one-fn :is_remote_synced :model/Collection :id (:id child-collection)))))))))
(defn- push-via-api!
"Pushes through `POST /api/ee/remote-sync/export` and waits for the export task to finish."
[]
(let [{:keys [task_id]} (mt/user-http-request :crowberto :post 200 "ee/remote-sync/export" {:branch "main"})]
(dh/with-retry {:max-retries 20 :delay-ms 250}
(u/prog1 (t2/select-one :model/RemoteSyncTask :id task_id)
(when (nil? (:ended_at <>))
(throw (ex-info "Not finished" {:task-id task_id})))))))
(defn- exported-entity-ids
"The entity_ids that have their own file in the current snapshot of `src`."
[src]
(let [snapshot (source.p/snapshot src)]
(into #{}
(keep (fn [path]
(try (:entity_id (yaml/parse-string (source.p/read-file snapshot path)))
(catch Exception _ nil))))
(source.p/list-files snapshot))))
(deftest archive-collection-removes-nested-content-from-remote-test
(testing "GHY-4399: archiving a collection inside a synced collection removes its whole subtree from the remote
on the next push, not only the collection's own file"
(test-helpers/clean-remote-sync-state
(fn []
(test-helpers/commit-with-temp
(fn []
(mt/with-premium-features #{:remote-sync}
(mt/with-model-cleanup [:model/Card :model/Collection]
(mt/with-temp [:model/Collection {synced-id :id} {:name "Synced" :is_remote_synced true :location "/"}]
(let [src (test-helpers/versioned-source :current "v-remote" :trees {"v-remote" {}})]
(mt/with-temporary-setting-values [remote-sync-url "https://github.com/test/repo.git"
remote-sync-token "test-token"
remote-sync-branch "main"]
(mt/with-dynamic-fn-redefs [source/source-from-settings (constantly src)]
(let [folder (mt/user-http-request :crowberto :post 200 "collection"
{:name "Bug Repro" :parent_id synced-id})
sub-folder (mt/user-http-request :crowberto :post 200 "collection"
{:name "Sub Folder" :parent_id (:id folder)})
new-card (fn [card-name coll-id]
(mt/user-http-request :crowberto :post 200 "card"
{:name card-name
:display "table"
:dataset_query (mt/mbql-query venues)
:visualization_settings {}
:collection_id coll-id}))
card (new-card "Card In Folder" (:id folder))
sub-card (new-card "Card In Sub Folder" (:id sub-folder))
subtree (set (map :entity_id [folder sub-folder card sub-card]))]
(is (=? {:outcome {:kind "pushed"}} (push-via-api!)) "first push pushes")
(is (= subtree (into #{} (filter subtree) (exported-entity-ids src)))
"precondition: the folder, its sub-folder, and both cards are on the remote")
(mt/user-http-request :crowberto :put 200 (str "collection/" (:id folder)) {:archived true})
(is (=? {:outcome {:kind "pushed"}} (push-via-api!)) "second push pushes")
(is (= #{} (into #{} (filter subtree) (exported-entity-ids src)))
"no file from the archived subtree stays on the remote"))))))))))))))
(deftest archive-transforms-folder-removes-its-transforms-from-remote-test
(testing "GHY-4399: archiving a Transforms folder removes the transforms in it from the remote on the next push.
Transforms have no archived column, so only their folder being archived says they left."
(test-helpers/clean-remote-sync-state
(fn []
(test-helpers/commit-with-temp
(fn []
(mt/with-premium-features #{:remote-sync :transforms-basic}
(mt/with-model-cleanup [:model/Transform :model/Collection]
(let [src (test-helpers/versioned-source :current "v-remote" :trees {"v-remote" {}})]
(mt/with-temporary-setting-values [remote-sync-url "https://github.com/test/repo.git"
remote-sync-token "test-token"
remote-sync-branch "main"
remote-sync-transforms true]
(mt/with-dynamic-fn-redefs [source/source-from-settings (constantly src)]
(let [folder (mt/user-http-request :crowberto :post 200 "collection"
{:name "Bug Repro" :namespace "transforms"})
;; H2 cannot be a transform target, so POST /api/transform refuses it; insert the transform
;; and publish the event that endpoint publishes
transform-id (t2/insert-returning-pk! :model/Transform
{:name "Transform In Folder"
:collection_id (:id folder)
:source {:type "query"
:query (mt/native-query {:query "SELECT 1"})}
:target {:type "table" :schema "PUBLIC" :name "ghy_4399"}})
transform (t2/select-one :model/Transform :id transform-id)
_ (events/publish-event! :event/transform-create
{:object transform :user-id (mt/user->id :crowberto)})
subtree (set (map :entity_id [folder transform]))]
(is (=? {:outcome {:kind "pushed"}} (push-via-api!)) "first push pushes")
(is (= subtree (into #{} (filter subtree) (exported-entity-ids src)))
"precondition: the folder and its transform are on the remote")
(mt/user-http-request :crowberto :put 200 (str "collection/" (:id folder)) {:archived true})
(is (=? {:outcome {:kind "pushed"}} (push-via-api!)) "second push pushes")
(is (= #{} (into #{} (filter subtree) (exported-entity-ids src)))
"no file from the archived folder stays on the remote")))))))))))))

View File

@@ -416,6 +416,98 @@
:status "update"}
(first entries)))))))
(defn- rso-statuses
"`{[model_type model_id] status}` for every RemoteSyncObject."
[]
(into {} (map (juxt (juxt :model_type :model_id) :status)) (t2/select :model/RemoteSyncObject)))
(defn- with-synced-nested-subtree!
"Calls `f` with `{:root :folder :sub-folder :card :sub-card :sibling-card}` ids: a remote-synced root holding a
sibling card and a folder, which holds a card and a sub-folder with its own card. Every entity except the root
has a synced RemoteSyncObject row."
[f]
(mt/with-temp [:model/Collection {root :id} {:name "Root" :is_remote_synced true :location "/"}
:model/Collection {folder :id} {:name "Folder" :is_remote_synced true :location (str "/" root "/")}
:model/Collection {sub-folder :id} {:name "Sub" :is_remote_synced true :location (str "/" root "/" folder "/")}
:model/Card {card :id} {:name "Card" :collection_id folder}
:model/Card {sub-card :id} {:name "Sub Card" :collection_id sub-folder}
:model/Card {sibling-card :id} {:name "Sibling Card" :collection_id root}]
(t2/delete! :model/RemoteSyncObject)
(t2/insert! :model/RemoteSyncObject
(for [[model-type model-id coll-id] [["Collection" folder folder]
["Collection" sub-folder sub-folder]
["Card" card folder]
["Card" sub-card sub-folder]
["Card" sibling-card root]]]
{:model_type model-type :model_id model-id :model_collection_id coll-id
:model_name "x" :status "synced" :status_changed_at (t/offset-date-time)}))
(f {:root root :folder folder :sub-folder sub-folder :card card :sub-card sub-card :sibling-card sibling-card})))
(defn- set-subtree-archived!
"Sets `archived` on `folder` and its contents, as archiving and unarchiving a collection do in bulk SQL."
[{:keys [folder sub-folder card sub-card]} archived]
(t2/update! :model/Collection :id [:in [folder sub-folder]] {:archived archived})
(t2/update! :model/Card :id [:in [card sub-card]] {:archived archived}))
(deftest collection-update-event-archived-cascades-delete-to-subtree-test
(testing "GHY-4399: archiving a nested collection marks its descendant collections and all contents of its subtree
'delete', and leaves content outside the subtree alone"
(with-synced-nested-subtree!
(fn [{:keys [folder sub-folder card sub-card sibling-card] :as ids}]
(set-subtree-archived! ids true)
(events/publish-event! :event/collection-update
{:object (t2/select-one :model/Collection :id folder) :user-id (mt/user->id :rasta)})
(is (= {["Collection" folder] "delete"
["Collection" sub-folder] "delete"
["Card" card] "delete"
["Card" sub-card] "delete"
["Card" sibling-card] "synced"}
(rso-statuses)))))))
(deftest collection-update-event-unarchived-restores-subtree-test
(testing "GHY-4399: unarchiving a collection before the next push marks its unarchived contents 'update' again, so
the push does not delete their files; content still archived on its own stays 'delete'"
(with-synced-nested-subtree!
(fn [{:keys [folder sub-folder card sub-card sibling-card] :as ids}]
(set-subtree-archived! ids true)
(events/publish-event! :event/collection-update
{:object (t2/select-one :model/Collection :id folder) :user-id (mt/user->id :rasta)})
(set-subtree-archived! ids false)
(t2/update! :model/Card :id sub-card {:archived true})
(events/publish-event! :event/collection-update
{:object (t2/select-one :model/Collection :id folder) :user-id (mt/user->id :rasta)})
(is (= {["Collection" folder] "update"
["Collection" sub-folder] "update"
["Card" card] "update"
["Card" sub-card] "delete"
["Card" sibling-card] "synced"}
(rso-statuses)))))))
(deftest collection-update-event-archive-cascades-to-transforms-test
(testing "GHY-4399: archiving a Transforms folder marks the transforms in it 'delete', and unarchiving it marks them
'update' again; transforms have no archived column, so their folder's archived state decides"
(mt/with-temporary-setting-values [remote-sync-transforms true]
(mt/with-temp [:model/Collection {folder :id} {:name "Folder" :namespace "transforms" :location "/"}
:model/Transform {transform :id} {:name "Transform" :collection_id folder}]
(t2/delete! :model/RemoteSyncObject)
(t2/insert! :model/RemoteSyncObject
(for [[model-type model-id] [["Collection" folder] ["Transform" transform]]]
{:model_type model-type :model_id model-id :model_collection_id folder
:model_name "x" :status "synced" :status_changed_at (t/offset-date-time)}))
(let [set-archived! (fn [archived]
(t2/update! :model/Collection :id folder {:archived archived})
(events/publish-event! :event/collection-update
{:object (t2/select-one :model/Collection :id folder)
:user-id (mt/user->id :rasta)}))]
(set-archived! true)
(is (= {["Collection" folder] "delete"
["Transform" transform] "delete"}
(rso-statuses)))
(set-archived! false)
(is (= {["Collection" folder] "update"
["Transform" transform] "update"}
(rso-statuses))))))))
(deftest collection-update-event-no-entry-for-normal-collection-test
(testing "collection-update event doesn't create entry for non-remote-synced collections"
(mt/with-temp [:model/Collection normal-collection {:name "Normal"}]

View File

@@ -68,3 +68,19 @@
(is (= nil
(:based_on_upload (get-card))
(:based_on_upload (get-collection-item))))))))))))
(deftest can-upload-false-for-sandboxed-user-test
(mt/test-drivers (mt/normal-drivers-with-feature :uploads)
(mt/dataset dataset
(met/with-gtaps-for-user! :rasta {:gtaps {:venues {}}}
(upload-test/with-uploads-enabled!
(testing "Sanity check: an unsandboxed admin can upload to the same database"
(is (true? (:can_upload (mt/user-http-request :crowberto :get 200 (str "database/" (mt/id)))))))
(testing "GET /api/database/:id"
(is (false? (:can_upload (mt/user-http-request :rasta :get 200 (str "database/" (mt/id)))))))
(testing "GET /api/database"
(is (false? (->> (mt/user-http-request :rasta :get 200 "database")
:data
(filter #(= (mt/id) (:id %)))
first
:can_upload)))))))))

View File

@@ -1,10 +1,12 @@
(ns metabase-enterprise.semantic-search.db-test
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.semantic-search.db.datasource :as semantic.db.datasource])
(:import
(com.mchange.v2.c3p0 PoolBackedDataSource)
(com.mchange.v2.c3p0 PoolBackedDataSource PooledDataSource)
(java.sql SQLException SQLTimeoutException)
(java.util.logging Handler Level LogRecord Logger)
(javax.sql DataSource)))
(set! *warn-on-reflection* true)
@@ -39,6 +41,48 @@
#"MB_PGVECTOR_DB_URL environment variable is required"
(semantic.db.datasource/init-db!))))))
(deftest pool-hides-credentials-test
(testing "the pool's string form, which c3p0 puts in its exception messages, leaves out the URL credentials"
;; db-url is a value, not a fn, so with-dynamic-fn-redefs can't bind it
(with-redefs [semantic.db.datasource/db-url "jdbc:postgresql://localhost:5432/mb?user=mb&password=hunter2"
semantic.db.datasource/data-source (atom nil)]
(let [pool ^PooledDataSource (semantic.db.datasource/init-db!)]
(.close pool)
(let [e (is (thrown-with-msg? SQLException #"has been closed" (.getConnection pool)))]
(is (not (str/includes? (str (ex-message e)) "hunter2")))
(is (not (str/includes? (str pool) "hunter2"))))))))
(deftest unrecognized-url-hides-credentials-test
(testing "the error for a URL no driver accepts, which quotes the URL, leaves out the credentials"
;; The driver rejects the `jdbc:postgres:` spelling, so DriverManager reports it with the URL.
;; Then next.jdbc retries with the right spelling, and only a failed retry lets that error through.
(with-redefs [semantic.db.datasource/db-url "jdbc:postgres://pgvector.invalid/mb?user=mb&password=hunter2"]
(let [e (is (thrown-with-msg? SQLException #"No suitable driver"
(semantic.db.datasource/probe-dedicated-connection!)))]
(is (not (str/includes? (str (ex-message e)) "hunter2")))))))
(deftest pgjdbc-debug-log-hides-credentials-test
(testing "the URL the driver logs at DEBUG leaves out the credentials"
(let [^Logger logger (Logger/getLogger "org.postgresql.Driver")
level (.getLevel logger)
logged (atom [])
handler (proxy [Handler] []
(publish [^LogRecord record]
(swap! logged conj [(.getMessage record) (vec (.getParameters record))]))
(flush [])
(close []))]
(.setLevel logger Level/FINE)
(.addHandler logger handler)
(try
;; the .invalid domain never resolves, and the driver logs the URL before it tries to connect
(with-redefs [semantic.db.datasource/db-url "jdbc:postgresql://pgvector.invalid/mb?user=mb&password=hunter2"]
(is (thrown? SQLException (semantic.db.datasource/probe-dedicated-connection!))))
(finally
(.removeHandler logger handler)
(.setLevel logger level)))
(is (=? [["Connecting with URL: {0}" ["jdbc:postgresql://pgvector.invalid/mb?connectTimeout=5&socketTimeout=10"]]]
(filter #(str/starts-with? (first %) "Connecting with URL") @logged))))))
(deftest test-connection-before-init-test
(testing "test-connection! throws exception when pool not initialized"
(let [orig-data-source @semantic.db.datasource/data-source]
@@ -64,6 +108,11 @@
(def ^:private base-url "jdbc:postgresql://localhost:5432/mb_semantic_search")
(defn- userinfo-url
"[[base-url]] with `userinfo` written before the host."
[userinfo]
(str "jdbc:postgresql://" userinfo "@localhost:5432/mb_semantic_search"))
(deftest probe-jdbc-url-test
(testing "the probe URL fills in the fail-fast timeouts the one-shot datasource would otherwise lack"
;; db-url is a value, not a fn, so with-dynamic-fn-redefs can't bind it
@@ -76,28 +125,29 @@
(is (= (str base-url "?connectTimeout=5&socketTimeout=10")
(probe-jdbc-url)))))
(testing "unrelated connection params pass through, in the order they were written"
(with-redefs [semantic.db.datasource/db-url (str base-url "?user=mb&sslmode=require")]
(is (= (str base-url "?user=mb&sslmode=require&connectTimeout=5&socketTimeout=10")
(with-redefs [semantic.db.datasource/db-url (str base-url "?tcpKeepAlive=true&sslmode=require")]
(is (= (str base-url "?tcpKeepAlive=true&sslmode=require&connectTimeout=5&socketTimeout=10")
(probe-jdbc-url))))))
(deftest parse-db-url-defaults-test
(testing "a URL with no params leaves the URL untouched and uses the default pool props"
;; strict = (not =?) so an unexpected extra/missing prop also fails, not just a wrong value
(is (= {:jdbc-url base-url
:pool-props {;; tunable knobs at their defaults
"maxPoolSize" 5
"minPoolSize" 0
"initialPoolSize" 0
"checkoutTimeout" 10000
"unreturnedConnectionTimeout" 0
"debugUnreturnedConnectionStackTraces" false
"testConnectionOnCheckout" false
;; fixed props operators can't override
"idleConnectionTestPeriod" 60
"maxIdleTimeExcessConnections" 600
"maxConnectionAge" 1800
"acquireIncrement" 1
"dataSourceName" "metabase-semantic-search-db"}}
(is (= {:jdbc-url base-url
:credentials {}
:pool-props {;; tunable knobs at their defaults
"maxPoolSize" 5
"minPoolSize" 0
"initialPoolSize" 0
"checkoutTimeout" 10000
"unreturnedConnectionTimeout" 0
"debugUnreturnedConnectionStackTraces" false
"testConnectionOnCheckout" false
;; fixed props operators can't override
"idleConnectionTestPeriod" 60
"maxIdleTimeExcessConnections" 600
"maxConnectionAge" 1800
"acquireIncrement" 1
"dataSourceName" "metabase-semantic-search-db"}}
(parse-db-url base-url)))))
(deftest parse-db-url-pool-knob-test
@@ -113,10 +163,43 @@
(testing "a recognized Postgres connection param stays on the URL for pgjdbc"
(is (=? {:jdbc-url (str base-url "?tcpKeepAlive=true")}
(parse-db-url (str base-url "?tcpKeepAlive=true")))))
(testing "pool knobs are stripped while connection params + credentials are retained, in order"
(is (=? {:jdbc-url (str base-url "?user=postgres&tcpKeepAlive=true")
(testing "pool knobs are stripped while connection params are retained, in order"
(is (=? {:jdbc-url (str base-url "?sslmode=require&tcpKeepAlive=true")
:pool-props {"maxPoolSize" 8}}
(parse-db-url (str base-url "?user=postgres&maxPoolSize=8&tcpKeepAlive=true"))))))
(parse-db-url (str base-url "?sslmode=require&maxPoolSize=8&tcpKeepAlive=true")))))
(testing "credentials move off the URL, decoded, to be passed as connection properties"
(is (= {:jdbc-url (str base-url "?tcpKeepAlive=true")
:credentials {:user "postgres"
:password "p&ss=word"
:sslpassword "k3y@pass"}}
(select-keys (parse-db-url (str base-url "?user=postgres&password=p%26ss%3Dword&tcpKeepAlive=true"
"&sslpassword=k3y%40pass"))
[:jdbc-url :credentials]))))
(testing "credentials in the host part come off the URL too, with %-escapes decoded and a `+` kept as is"
(is (= {:jdbc-url (str base-url "?tcpKeepAlive=true")
:credentials {:user "postgres", :password "p@ss:w+rd"}}
(select-keys (parse-db-url (str (userinfo-url "postgres:p%40ss:w+rd") "?tcpKeepAlive=true"))
[:jdbc-url :credentials]))))
(testing "an unencoded `@` in a host-part password still leaves the whole password off the URL"
(is (=? {:jdbc-url base-url, :credentials {:user "postgres", :password "p@ss"}}
(parse-db-url (userinfo-url "postgres:p@ss")))))
(testing "host-part credentials come off whatever the scheme, even one no driver accepts"
;; CI spells the scheme `jdbc:postgres:`, and `jdbc:postgresq:` is a typo DriverManager would quote
(doseq [scheme ["jdbc:postgres://" "jdbc:postgresq://"]]
(is (=? {:jdbc-url (str scheme "localhost:5432/mb_semantic_search")
:credentials {:user "postgres", :password "secret"}}
(parse-db-url (str scheme "postgres:secret@localhost:5432/mb_semantic_search"))))))
(testing "a credential set both in the host part and in the query throws, without quoting either value"
(let [e (is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"The pgvector URL sets password both before the host and as a parameter"
(parse-db-url (str (userinfo-url "postgres:secret") "?password=other"))))]
(is (not (re-find #"secret|other" (str (ex-message e) (ex-data e)))))))
(testing "an unencoded `@` in a query value passes through, as pgjdbc accepts it"
(doseq [url [(str base-url "?user=mylogin@srv&password=p@ss")
"jdbc:postgresql://?service=pgvector&user=mylogin@srv&password=p@ss"]]
(is (=? {:credentials {:user "mylogin@srv", :password "p@ss"}}
(parse-db-url url))))))
(deftest parse-db-url-validation-test
(testing "an unrecognized param throws rather than being silently ignored by pgjdbc"
@@ -128,7 +211,23 @@
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Invalid value for pgvector pool parameter maxPoolSize"
(parse-db-url (str base-url "?maxPoolSize=lots"))))))
(parse-db-url (str base-url "?maxPoolSize=lots")))))
(testing "an `@` outside the host part throws without quoting the URL"
(doseq [url ["jdbc:postgresql://alice:se/cret@db/mb" ; `/` in the password
"jdbc:postgresql://alice:se?cret@db/mb" ; `?` in the password
"jdbc:postgresql://alice:se?service=cret@db/mb" ; `?`, putting the `@` in a param
"jdbc:postgresql://alice:s@e?cret@db/mb" ; `@` and then `?` in the password
"jdbc:postgresql://alice:secret@db/mb@elsewhere"]] ; `@` in the database name
(let [e (is (thrown? clojure.lang.ExceptionInfo (parse-db-url url)))]
(is (=? {:message (str "MB_PGVECTOR_DB_URL has an unencoded @ outside its credentials. "
"Percent-encode reserved characters, e.g. @ as %40, / as %2F and ? as %3F.")
:data {}}
{:message (ex-message e), :data (ex-data e)}))
(is (not (re-find #"cret|elsewhere" (str (ex-message e) (ex-data e))))))))
(testing "a malformed %-escape in a credential throws without quoting any of the value"
(let [e (is (thrown? clojure.lang.ExceptionInfo (parse-db-url (str base-url "?password=hunter%zz"))))]
(is (=? {:message "Malformed %-escape in MB_PGVECTOR_DB_URL", :data {}, :cause nil}
{:message (ex-message e), :data (ex-data e), :cause (ex-cause e)})))))
(deftest pool-props-applied-by-c3p0-test
(testing "c3p0 actually understands and applies every pool property name we set"

View File

@@ -5,7 +5,7 @@ import { useState } from "react";
import { act, screen, waitFor } from "__support__/ui";
import { ensureMetabaseProviderPropsStore } from "embedding-sdk-shared/lib/ensure-metabase-provider-props-store";
import type { GeneratedCard } from "metabase/api/ai-streaming/schemas";
import { metabotActions } from "metabase/metabot/state";
import { getMetabotState, metabotActions } from "metabase/metabot/state";
import {
createTestMetabotState,
lastReqBody,
@@ -430,21 +430,25 @@ describe("useMetabot", () => {
});
expect(
store.getState().metabot?.conversations?.[conversationId]?.messages
.length,
getMetabotState(store.getState()).conversations[conversationId]
?.messages.length,
).toBe(0);
expect(store.getState().metabot?.agents?.omnibot?.visible).toBe(false);
expect(getMetabotState(store.getState()).agents.omnibot?.visible).toBe(
false,
);
await userEvent.click(screen.getByTestId("submit-btn"));
await waitFor(() => {
expect(
store.getState().metabot?.conversations?.[conversationId]?.messages
.length,
getMetabotState(store.getState()).conversations[conversationId]
?.messages.length,
).toBeGreaterThan(0);
});
expect(store.getState().metabot?.agents?.omnibot?.visible).toBe(false);
expect(getMetabotState(store.getState()).agents.omnibot?.visible).toBe(
false,
);
});
it("resolves to undefined even though agent.submitInput returns an action", async () => {

View File

@@ -1,9 +1,11 @@
import type { Collection, CollectionId } from "metabase-types/api";
import type { CollectionId } from "metabase-types/api";
export const findCollectionById = (
collections: Collection[],
export const findCollectionById = <
T extends { id: CollectionId; children?: T[] },
>(
collections: T[],
collectionId: CollectionId,
): Collection | null => {
): T | null => {
if (!collections || collections.length === 0) {
return null;
}

View File

@@ -2,12 +2,11 @@ import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import {
setupPropertiesEndpoints,
setupSettingsEndpoints,
setupUpdateSettingEndpoint,
setupStatefulSettingsEndpoints,
} from "__support__/server-mocks";
import { mockSettings } from "__support__/settings";
import { renderWithProviders, screen } from "__support__/ui";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import { createMockSettings } from "metabase-types/api/mocks";
import { MetabotCustomizationPage } from "./MetabotCustomizationPage";
@@ -27,9 +26,8 @@ function setup({
"metabot-show-illustrations": showIllustrations,
});
setupPropertiesEndpoints(settings);
setupStatefulSettingsEndpoints(settings);
setupSettingsEndpoints([]);
setupUpdateSettingEndpoint();
// Seed the store state too: without it, the render harness seeds the
// settings bootstrap with *defaults*, and assertions can run against the
@@ -54,10 +52,9 @@ describe("MetabotCustomizationPage", () => {
expect(
screen.queryByRole("button", { name: /Remove custom icon/ }),
).not.toBeInTheDocument();
expect(screen.queryByText("Metabot illustrations")).not.toBeInTheDocument();
expect(
screen.queryByAltText("Metabot illustration preview"),
).not.toBeInTheDocument();
screen.getByRole("switch", { name: /Show Metabot illustrations/ }),
).toBeChecked();
});
it("shows the remove button and illustrations toggle when a custom icon is set", async () => {
@@ -80,7 +77,7 @@ describe("MetabotCustomizationPage", () => {
expect(preview).toHaveAttribute("src", "data:image/png;base64,abc123");
});
it("shows the illustrations toggle when illustrations are off, even with the default icon", async () => {
it("keeps the illustrations toggle after turning illustrations on with the default icon", async () => {
setup({ showIllustrations: false });
const toggle = await screen.findByRole("switch", {
@@ -93,5 +90,10 @@ describe("MetabotCustomizationPage", () => {
{ method: "PUT" },
);
expect(await call?.request?.json()).toEqual({ value: true });
await waitFor(() =>
expect(
screen.getByRole("switch", { name: /Show Metabot illustrations/ }),
).toBeChecked(),
);
});
});

View File

@@ -34,7 +34,6 @@ export function MetabotIconField() {
const [iconError, setIconError] = useState("");
const isDefaultIcon = !metabotIcon || metabotIcon === "metabot";
const isIllustrationsSectionVisible = !isDefaultIcon || !showIllustrations;
const iconPreviewSrc =
!isDefaultIcon && typeof metabotIcon === "string" ? metabotIcon : null;
@@ -162,40 +161,38 @@ export function MetabotIconField() {
</Flex>
)}
</Flex>
{isIllustrationsSectionVisible && (
<Stack mt="xl" gap="sm">
<Text fz="md" fw="bold">
{t`Metabot illustrations`}
</Text>
<Group gap="xl" align="center" wrap="nowrap">
<Flex align="center" gap="sm" flex="1" miw={0}>
<Box
component="img"
src={EmptyDashboardBot}
alt={t`Metabot illustration preview`}
w="3rem"
h="3rem"
flex="0 0 auto"
/>
<Text fz="md" c="text-secondary" flex="1">
{t`Show Metabot illustrations in chat sidebar and AI exploration page`}
</Text>
</Flex>
<Switch
aria-label={t`Show Metabot illustrations`}
checked={!!showIllustrations}
onChange={(e) =>
updateShowIllustrations({
key: "metabot-show-illustrations",
value: e.currentTarget.checked,
toast: false,
})
}
disabled={isLoadingIllustrations}
<Stack mt="xl" gap="sm">
<Text fz="md" fw="bold">
{t`Metabot illustrations`}
</Text>
<Group gap="xl" align="center" wrap="nowrap">
<Flex align="center" gap="sm" flex="1" miw={0}>
<Box
component="img"
src={EmptyDashboardBot}
alt={t`Metabot illustration preview`}
w="3rem"
h="3rem"
flex="0 0 auto"
/>
</Group>
</Stack>
)}
<Text fz="md" c="text-secondary" flex="1">
{t`Show Metabot illustrations in the chat sidebar`}
</Text>
</Flex>
<Switch
aria-label={t`Show Metabot illustrations`}
checked={!!showIllustrations}
onChange={(e) =>
updateShowIllustrations({
key: "metabot-show-illustrations",
value: e.currentTarget.checked,
toast: false,
})
}
disabled={isLoadingIllustrations}
/>
</Group>
</Stack>
</Stack>
);
}

View File

@@ -14,11 +14,6 @@ export const REGULAR_COLLECTION: CollectionAuthorityLevelConfig = {
icon: "folder",
};
export const REMOTE_SYNC_COLLECTION: CollectionInstanceAnaltyicsConfig = {
type: null,
icon: "synced_collection",
};
export const OFFICIAL_COLLECTION: CollectionAuthorityLevelConfig = {
type: "official",
get name() {

View File

@@ -26,7 +26,6 @@ import {
filterOutItemsFromInstanceAnalytics,
getCollectionType,
isRegularCollection,
isSyncedCollection,
useGetIcon,
} from "./utils";
@@ -34,10 +33,6 @@ import {
* Initialize collections plugin features that depend on hasPremiumFeature.
*/
export function initializePlugin() {
if (hasPremiumFeature("remote_sync")) {
PLUGIN_COLLECTIONS.isSyncedCollection = isSyncedCollection;
}
if (hasPremiumFeature("official_collections")) {
PLUGIN_COLLECTIONS.isRegularCollection = isRegularCollection;
PLUGIN_COLLECTIONS.REGULAR_COLLECTION = REGULAR_COLLECTION;

View File

@@ -19,7 +19,6 @@ import {
INSTANCE_ANALYTICS_COLLECTION,
OFFICIAL_COLLECTION,
REGULAR_COLLECTION,
REMOTE_SYNC_COLLECTION,
} from "./constants";
export function isRegularCollection({
@@ -52,20 +51,11 @@ export function isInstanceAnalyticsCollection(
);
}
export function isSyncedCollection(
collection: Pick<Collection, "is_remote_synced">,
): boolean {
return collection.is_remote_synced === true;
}
export const useGetIcon = () => {
const getIconBase = useGetIconBase();
return useCallback(
(
item: ObjectWithModel,
{ isTenantUser = false }: { isTenantUser?: boolean } = {},
): IconData => {
(item: ObjectWithModel): IconData => {
const collectionType = getCollectionType({
// Unjustified type cast. FIXME
type: (item.type as CollectionType) || item.collection_type,
@@ -77,18 +67,11 @@ export const useGetIcon = () => {
}
if (item.model === "collection") {
// Library collections keep their special icon regardless of sync status
// Library collections keep their special icon regardless of authority level
if (PLUGIN_LIBRARY.isLibraryCollectionType(item.type)) {
return getIconBase(item);
}
// tenant users see the normal icon, they don't know what a synced collection is
if (item.is_remote_synced && !isTenantUser) {
return {
name: REMOTE_SYNC_COLLECTION.icon,
};
}
if (
item.authority_level === "official" ||
item.collection_authority_level === "official"

View File

@@ -109,20 +109,6 @@ describe("Collections plugin utils", () => {
});
});
it("should return the correct icon for a remote synced collection", () => {
const getIcon = createGetIcon();
expect(
getIcon({ model: "collection", is_remote_synced: true }),
).toEqual({ name: "synced_collection" });
});
it("should return the correct icon for a remote synced entity", () => {
const getIcon = createGetIcon();
expect(getIcon({ model: "dashboard", is_remote_synced: true })).toEqual(
{ name: "dashboard" },
);
});
it("official collection in search", () => {
const collection = {
id: 101,

View File

@@ -62,8 +62,7 @@ function LibraryPageContent() {
isChildrenLoading,
isLoading,
emptyMessage,
refreshTableCollections,
refreshMetricCollections,
refreshCollections,
} = useLibraryTreeTableInstance({
collections,
isLoadingCollections,
@@ -102,16 +101,13 @@ function LibraryPageContent() {
.otherwise(() => undefined);
const handleActionComplete = useCallback(
(section: LibrarySection, affectedCollectionIds: CollectionId[]) => {
if (section === "data") {
refreshTableCollections(affectedCollectionIds);
} else if (section === "metrics") {
refreshMetricCollections(affectedCollectionIds);
}
// Snippet sections refetch via RTK tag invalidation.
(_section: LibrarySection, affectedCollectionIds: CollectionId[]) => {
// Snippet sections refetch via RTK tag invalidation; library rows are refreshed explicitly
// because their children are held in a local lazy-load cache.
refreshCollections(affectedCollectionIds);
clearSelection();
},
[refreshTableCollections, refreshMetricCollections, clearSelection],
[refreshCollections, clearSelection],
);
return (
@@ -145,6 +141,8 @@ function LibraryPageContent() {
onChange={(e) => setSearchQuery(e.target.value)}
/>
<CreateMenu
libraryCollectionId={libraryCollection?.id}
canWriteToLibrary={!!libraryCollection?.can_write}
metricCollectionId={writableMetricCollection?.id}
canWriteToMetricCollection={!!writableMetricCollection}
dataCollectionId={tableCollection?.id}

View File

@@ -14,12 +14,11 @@ import { RootSnippetsCollectionMenu } from "./RootSnippetsCollectionMenu";
type ActionCellProps = {
treeItem: TreeItem;
refreshTableCollections: (collectionIds: CollectionId[]) => void;
refreshMetricCollections: (collectionIds: CollectionId[]) => void;
refreshCollections: (collectionIds: CollectionId[]) => void;
};
export function ActionCell(props: ActionCellProps) {
const { treeItem, refreshTableCollections, refreshMetricCollections } = props;
const { treeItem, refreshCollections } = props;
const { data, children } = treeItem;
if (isEmptyStateData(data)) {
@@ -27,7 +26,7 @@ export function ActionCell(props: ActionCellProps) {
}
if (isTableData(data)) {
return <TableMoreMenu table={data} onMoved={refreshTableCollections} />;
return <TableMoreMenu table={data} onMoved={refreshCollections} />;
}
if (!isCollectionData(data) || data.model !== "collection") {
@@ -44,17 +43,14 @@ export function ActionCell(props: ActionCellProps) {
return <CollectionRowMenu collection={data} />;
}
const isLibraryCollection =
PLUGIN_LIBRARY.isLibrarySubCollectionType(data.type) &&
!data.is_library_root;
if (isLibraryCollection) {
// Includes the seeded Data/Metrics roots: they can't be renamed or archived, but they can still
// take a new subfolder, a custom icon and permission changes.
if (PLUGIN_LIBRARY.isLibraryCollectionType(data.type)) {
return (
<LibraryCollectionRowMenu
childCount={children?.length ?? 0}
collection={data}
refreshMetricCollections={refreshMetricCollections}
refreshTableCollections={refreshTableCollections}
refreshCollections={refreshCollections}
/>
);
}

View File

@@ -19,11 +19,15 @@ import type { CollectionId, CollectionNamespace } from "metabase-types/api";
import { PublishTableModal } from "./PublishTableModal";
export const CreateMenu = ({
libraryCollectionId,
canWriteToLibrary,
metricCollectionId,
canWriteToMetricCollection,
dataCollectionId,
canWriteToDataCollection,
}: {
libraryCollectionId?: CollectionId;
canWriteToLibrary?: boolean;
metricCollectionId?: CollectionId;
canWriteToMetricCollection?: boolean;
dataCollectionId?: CollectionId;
@@ -47,7 +51,10 @@ export const CreateMenu = ({
const canCreateMetric =
hasDataAccess && metricCollectionId && canWriteToMetricCollection;
const canCreateLibraryFolder = !!(libraryCollectionId && canWriteToLibrary);
const canCreateCollection =
canCreateLibraryFolder ||
(dataCollectionId && canWriteToDataCollection) ||
(metricCollectionId && canWriteToMetricCollection) ||
(hasNativeWrite && PLUGIN_SNIPPET_FOLDERS.isEnabled);
@@ -55,6 +62,7 @@ export const CreateMenu = ({
const collectionNamespaces: CollectionNamespace[] = [];
if (
canCreateLibraryFolder ||
(dataCollectionId && canWriteToDataCollection) ||
(metricCollectionId && canWriteToMetricCollection)
) {
@@ -65,7 +73,9 @@ export const CreateMenu = ({
collectionNamespaces.push("snippets");
}
// Default to a new top-level folder in the Library; the picker lets you nest it instead.
const initialCollectionId =
(canCreateLibraryFolder && libraryCollectionId) ||
(dataCollectionId && canWriteToDataCollection && dataCollectionId) ||
(metricCollectionId && canWriteToMetricCollection && metricCollectionId) ||
null;
@@ -105,23 +115,24 @@ export const CreateMenu = ({
canCreateCollection && (
<Menu.Item
key="collection"
leftSection={<FixedSizeIcon name="folder" />}
leftSection={<FixedSizeIcon name="new_folder" />}
onClick={() =>
dispatch(
setOpenModalWithProps({
id: "collection",
props: {
title: t`New folder`,
initialCollectionId,
namespaces: collectionNamespaces,
pickerOptions: LIBRARY_COLLECTION_PICKER_OPTIONS,
showAuthorityLevelPicker: false,
inDataStudio: true,
showIconPicker: true,
},
}),
)
}
>
{t`Collection`}
{t`Folder`}
</Menu.Item>
),
].filter(Boolean);

View File

@@ -15,6 +15,8 @@ import { CreateMenu } from "./CreateMenu";
interface SetupOptions {
user?: Partial<User>;
libraryCollectionId?: number;
canWriteToLibrary?: boolean;
dataCollectionId?: number;
canWriteToDataCollection?: boolean;
canWriteToMetricCollection?: boolean;
@@ -31,6 +33,8 @@ const fullPermissionsUser: Partial<User> = {
const setup = ({
user,
libraryCollectionId = 7,
canWriteToLibrary = true,
dataCollectionId = 2,
canWriteToDataCollection = true,
canWriteToMetricCollection = true,
@@ -50,6 +54,8 @@ const setup = ({
setupEnterprisePlugins();
const utils = renderWithProviders(
<CreateMenu
libraryCollectionId={libraryCollectionId}
canWriteToLibrary={canWriteToLibrary}
metricCollectionId={1}
dataCollectionId={dataCollectionId}
canWriteToDataCollection={canWriteToDataCollection}
@@ -71,17 +77,17 @@ describe("CreateMenu", () => {
expect(
screen.getAllByRole("menuitem").map((item) => item.textContent),
).toEqual(["Published table", "Metric", "Snippet", "Collection"]);
).toEqual(["Published table", "Metric", "Snippet", "Folder"]);
});
it("renders publish and collection options for data analysts", async () => {
it("renders publish and folder options for data analysts", async () => {
setup({ user: { is_data_analyst: true } });
await userEvent.click(screen.getByRole("button", { name: /New/ }));
expect(
screen.getAllByRole("menuitem").map((item) => item.textContent),
).toEqual(["Published table", "Collection"]);
).toEqual(["Published table", "Folder"]);
});
it("renders publish and metric options if user only has query builder access", async () => {
@@ -91,7 +97,7 @@ describe("CreateMenu", () => {
expect(
screen.getAllByRole("menuitem").map((item) => item.textContent),
).toEqual(["Published table", "Metric", "Collection"]);
).toEqual(["Published table", "Metric", "Folder"]);
});
it("does not render Metric option when canWriteToMetricCollection is false", async () => {
@@ -110,10 +116,10 @@ describe("CreateMenu", () => {
expect(
screen.getAllByRole("menuitem").map((item) => item.textContent),
).toEqual(["Published table", "Snippet", "Collection"]);
).toEqual(["Published table", "Snippet", "Folder"]);
});
it("renders Collection option when only Data collection is writable", async () => {
it("renders Folder option when only Data collection is writable", async () => {
setup({
user: { is_data_analyst: true },
canWriteToDataCollection: true,
@@ -124,12 +130,13 @@ describe("CreateMenu", () => {
expect(
screen.getAllByRole("menuitem").map((item) => item.textContent),
).toEqual(["Published table", "Collection"]);
).toEqual(["Published table", "Folder"]);
});
it("does not render Collection option without writable Library collections or native write", async () => {
it("does not render Folder option without writable Library collections or native write", async () => {
setup({
user: {},
canWriteToLibrary: false,
canWriteToDataCollection: false,
canWriteToMetricCollection: false,
});
@@ -141,20 +148,21 @@ describe("CreateMenu", () => {
).toEqual(["Published table"]);
});
it("opens the collection modal with Library and snippets picker options", async () => {
it("defaults a new folder to the top level of the Library", async () => {
const { store } = setup({
user: fullPermissionsUser,
libraryCollectionId: 7,
dataCollectionId: 42,
});
await userEvent.click(screen.getByRole("button", { name: /New/ }));
await userEvent.click(screen.getByRole("menuitem", { name: /Collection/ }));
await userEvent.click(screen.getByRole("menuitem", { name: /Folder/ }));
expect(store.getState().modal).toEqual({
id: "collection",
props: {
inDataStudio: true,
initialCollectionId: 42,
title: "New folder",
initialCollectionId: 7,
namespaces: [null, "snippets"],
pickerOptions: {
hasLibrary: true,
@@ -166,19 +174,36 @@ describe("CreateMenu", () => {
canCreateCollections: false,
},
showAuthorityLevelPicker: false,
showIconPicker: true,
},
});
});
it("falls back to the Data collection when the Library root is not writable", async () => {
const { store } = setup({
user: fullPermissionsUser,
canWriteToLibrary: false,
dataCollectionId: 42,
});
await userEvent.click(screen.getByRole("button", { name: /New/ }));
await userEvent.click(screen.getByRole("menuitem", { name: /Folder/ }));
expect(store.getState().modal.props).toMatchObject({
initialCollectionId: 42,
});
});
it("opens the collection modal scoped to snippets when only native write is available", async () => {
const { store } = setup({
user: { permissions: { can_create_native_queries: true } },
canWriteToLibrary: false,
canWriteToDataCollection: false,
canWriteToMetricCollection: false,
});
await userEvent.click(screen.getByRole("button", { name: /New/ }));
await userEvent.click(screen.getByRole("menuitem", { name: /Collection/ }));
await userEvent.click(screen.getByRole("menuitem", { name: /Folder/ }));
expect(store.getState().modal.props).toMatchObject({
initialCollectionId: null,

View File

@@ -1,7 +1,10 @@
import { useDisclosure } from "@mantine/hooks";
import { useCallback } from "react";
import { t } from "ttag";
import _ from "underscore";
import { CollectionRowMenu } from "metabase/common/collections/components/CollectionRowMenu";
import CreateCollectionModal from "metabase/common/collections/containers/CreateCollectionModal";
import type { Collection, CollectionId } from "metabase-types/api";
import { getArchiveLibraryCollectionsMessage } from "../utils";
@@ -9,31 +12,16 @@ import { getArchiveLibraryCollectionsMessage } from "../utils";
type LibraryCollectionRowMenuProps = {
childCount: number;
collection: Collection;
refreshMetricCollections: (collectionIds: CollectionId[]) => void;
refreshTableCollections: (collectionIds: CollectionId[]) => void;
refreshCollections: (collectionIds: CollectionId[]) => void;
};
export function LibraryCollectionRowMenu(props: LibraryCollectionRowMenuProps) {
const {
childCount,
collection,
refreshMetricCollections,
refreshTableCollections,
} = props;
const { childCount, collection, refreshCollections } = props;
const [isNewFolderModalOpen, { open: openNewFolder, close: closeNewFolder }] =
useDisclosure(false);
const isLibraryDataCollection =
collection.type === "library-data" && !collection.is_library_root;
const refreshCollections = useCallback(
(collectionIds: CollectionId[]) => {
if (collection.type === "library-metrics") {
refreshMetricCollections(collectionIds);
}
if (collection.type === "library-data") {
refreshTableCollections(collectionIds);
}
},
[refreshMetricCollections, refreshTableCollections, collection.type],
);
const onArchiveSuccess = useCallback(() => {
const parentId = getParentCollectionId(collection);
@@ -45,19 +33,38 @@ export function LibraryCollectionRowMenu(props: LibraryCollectionRowMenuProps) {
refreshCollections([parentId]);
}, [collection, refreshCollections]);
const onFolderCreated = useCallback(() => {
closeNewFolder();
refreshCollections([collection.id]);
}, [closeNewFolder, collection.id, refreshCollections]);
return (
<CollectionRowMenu
collection={collection}
onSave={(details) => {
refreshCollections(getAffectedCollectionIds(details));
}}
customArchiveMessage={
isLibraryDataCollection && childCount > 0
? getArchiveLibraryCollectionsMessage(1)
: undefined
}
onArchiveSuccess={onArchiveSuccess}
/>
<>
<CollectionRowMenu
collection={collection}
onNewFolder={openNewFolder}
onSave={(details) => {
refreshCollections(getAffectedCollectionIds(details));
}}
customArchiveMessage={
isLibraryDataCollection && childCount > 0
? getArchiveLibraryCollectionsMessage(1)
: undefined
}
onArchiveSuccess={onArchiveSuccess}
/>
{isNewFolderModalOpen && (
<CreateCollectionModal
title={t`New folder`}
initialCollectionId={collection.id}
showCollectionPicker={false}
showAuthorityLevelPicker={false}
showIconPicker
onCreate={onFolderCreated}
onClose={closeNewFolder}
/>
)}
</>
);
}

View File

@@ -1,8 +1,10 @@
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import { setupEnterpriseOnlyPlugin } from "__support__/enterprise";
import {
setupCollectionByIdEndpoint,
setupCreateCollectionEndpoint,
setupUpdateCollectionEndpoint,
} from "__support__/server-mocks";
import { mockSettings } from "__support__/settings";
@@ -25,8 +27,7 @@ function setup({
}),
childCount = 0,
}: Partial<Parameters<typeof LibraryCollectionRowMenu>[0]> = {}) {
const refreshMetricCollections = jest.fn();
const refreshTableCollections = jest.fn();
const refreshCollections = jest.fn();
const parentCollection = createMockCollection({
id: 22,
name: "Data",
@@ -36,16 +37,19 @@ function setup({
setupEnterpriseOnlyPlugin("library");
setupEnterpriseOnlyPlugin("remote_sync");
setupUpdateCollectionEndpoint(collection);
setupCollectionByIdEndpoint({ collections: [parentCollection] });
setupCreateCollectionEndpoint(
createMockCollection({ id: 99, name: "Finance", type: "library-data" }),
);
setupCollectionByIdEndpoint({ collections: [parentCollection, collection] });
renderWithProviders(
<LibraryCollectionRowMenu
childCount={childCount}
collection={collection}
refreshMetricCollections={refreshMetricCollections}
refreshTableCollections={refreshTableCollections}
refreshCollections={refreshCollections}
/>,
{
withRouter: true,
storeInitialState: createMockState({
currentUser: createMockUser({ is_superuser: true }),
settings: mockSettings({
@@ -58,16 +62,20 @@ function setup({
},
);
return { refreshMetricCollections, refreshTableCollections };
return { refreshCollections };
}
async function openMenu() {
await userEvent.click(
screen.getByRole("button", { name: "Collection options" }),
);
}
describe("LibraryCollectionRowMenu", () => {
it("refreshes table collections after saving a Library Data collection", async () => {
const { refreshMetricCollections, refreshTableCollections } = setup();
it("refreshes the parent collection after saving", async () => {
const { refreshCollections } = setup();
await userEvent.click(
screen.getByRole("button", { name: "Collection options" }),
);
await openMenu();
await userEvent.click(
screen.getByRole("menuitem", { name: /Edit collection details/ }),
);
@@ -75,42 +83,36 @@ describe("LibraryCollectionRowMenu", () => {
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(refreshTableCollections).toHaveBeenCalledWith([22]);
expect(refreshCollections).toHaveBeenCalledWith([22]);
});
expect(refreshMetricCollections).not.toHaveBeenCalled();
});
it("refreshes metric collections after saving a Library Metrics collection", async () => {
const { refreshMetricCollections, refreshTableCollections } = setup({
collection: createMockCollection({
id: 2,
name: "Library Metrics Collection",
type: "library-metrics",
parent_id: 22,
}),
});
it("creates a subfolder in the clicked collection and refreshes it", async () => {
const { refreshCollections } = setup();
await userEvent.click(
screen.getByRole("button", { name: "Collection options" }),
);
await userEvent.click(
screen.getByRole("menuitem", { name: /Edit collection details/ }),
);
await userEvent.type(screen.getByLabelText("Name"), " Updated");
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await openMenu();
await userEvent.click(screen.getByRole("menuitem", { name: /New folder/ }));
await userEvent.type(screen.getByLabelText("Name"), "Finance");
await userEvent.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() => {
expect(refreshMetricCollections).toHaveBeenCalledWith([22]);
expect(fetchMock.callHistory.called("create-collection")).toBe(true);
});
const call = fetchMock.callHistory.calls("create-collection").at(-1);
expect(JSON.parse(String(call?.options?.body))).toMatchObject({
name: "Finance",
parent_id: 1,
});
await waitFor(() => {
expect(refreshCollections).toHaveBeenCalledWith([1]);
});
expect(refreshTableCollections).not.toHaveBeenCalled();
});
it("shows a table unpublish warning when archiving a non-empty Library Data collection", async () => {
setup({ childCount: 1 });
await userEvent.click(
screen.getByRole("button", { name: "Collection options" }),
);
await openMenu();
await userEvent.click(screen.getByRole("menuitem", { name: /Archive/ }));
expect(
@@ -119,4 +121,27 @@ describe("LibraryCollectionRowMenu", () => {
),
).toBeInTheDocument();
});
it("does not offer rename or archive on a seeded Library section, but still offers a new folder", async () => {
setup({
collection: createMockCollection({
id: 22,
name: "Data",
type: "library-data",
is_library_root: true,
}),
});
await openMenu();
expect(
screen.getByRole("menuitem", { name: /New folder/ }),
).toBeInTheDocument();
expect(
screen.queryByRole("menuitem", { name: /Edit collection details/ }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("menuitem", { name: /Archive/ }),
).not.toBeInTheDocument();
});
});

View File

@@ -6,6 +6,7 @@ import {
skipToken,
useListCollectionItemsQuery,
} from "metabase/api";
import type { IconData, ObjectWithModel } from "metabase/common/utils/icon";
import type {
LibrarySectionType,
TreeItem,
@@ -23,24 +24,29 @@ import type {
CollectionItem,
} from "metabase-types/api";
const LIBRARY_ITEM_MODELS = ["metric", "table", "collection"] as const;
type GetIcon = (item: ObjectWithModel) => IconData;
/** Builds the whole Library subtree from the Library root collection. The root itself is not
* rendered: its children — the seeded Data and Metrics folders plus any folders the user created —
* become the top-level rows. Everything below them is loaded lazily when a row is expanded. */
export function useLibraryCollectionTree(
collection: Collection | undefined,
sectionType: LibrarySectionType,
metricCollectionId?: CollectionId,
libraryCollection: Collection | undefined,
) {
const dispatch = useDispatch();
const getIcon = useGetIcon();
// 1. Fetch top-level items
// 1. Fetch the Library root's children
const {
data: topLevelItems,
isLoading,
error,
} = useListCollectionItemsQuery(
collection
libraryCollection
? {
id: collection.id,
models: ["metric", "table", "collection"],
id: libraryCollection.id,
models: [...LIBRARY_ITEM_MODELS],
archived: false,
}
: skipToken,
@@ -57,7 +63,7 @@ export function useLibraryCollectionTree(
useEffect(() => {
setLoadedCollections(new Map());
loadingIds.current = new Set();
}, [collection]);
}, [libraryCollection]);
const loadCollectionItems = useCallback(
async (collectionId: CollectionId) => {
@@ -71,7 +77,7 @@ export function useLibraryCollectionTree(
collectionApi.endpoints.listCollectionItems.initiate(
{
id: collectionId,
models: ["metric", "table", "collection"],
models: [...LIBRARY_ITEM_MODELS],
archived: false,
},
{ forceRefetch: true },
@@ -86,8 +92,7 @@ export function useLibraryCollectionTree(
const refreshCollections = useCallback(
async (collectionIds: CollectionId[]) => {
for (const id of collectionIds) {
const key = String(id);
loadingIds.current.delete(key);
loadingIds.current.delete(String(id));
}
await Promise.all(collectionIds.map(loadCollectionItems));
},
@@ -96,43 +101,24 @@ export function useLibraryCollectionTree(
// 3. Build tree
const tree = useMemo((): TreeItem[] => {
if (isLoading || !topLevelItems || !collection) {
if (isLoading || !topLevelItems || !libraryCollection) {
return [];
}
const children = buildChildren(
topLevelItems.data,
loadedCollections,
getIcon,
return sortTopLevelRows(
buildChildren(
topLevelItems.data,
loadedCollections,
getIcon,
isRemoteSyncReadOnly,
),
);
const hasItems = children.length > 0;
return [
{
name: collection.name,
id: `collection:${collection.id}`,
icon: getIcon({ ...collection, model: "collection" }).name,
data: { ...collection, model: "collection" as const },
model: "collection",
children: hasItems
? children
: [
createEmptyStateItem(
sectionType,
metricCollectionId,
isRemoteSyncReadOnly,
),
],
},
];
}, [
isLoading,
topLevelItems,
collection,
libraryCollection,
loadedCollections,
getIcon,
sectionType,
metricCollectionId,
isRemoteSyncReadOnly,
]);
@@ -183,7 +169,8 @@ export function useLibraryCollectionTree(
function buildChildren(
items: CollectionItem[],
loadedCollections: Map<CollectionId, CollectionItem[]>,
getIcon: ReturnType<typeof useGetIcon>,
getIcon: GetIcon,
isRemoteSyncReadOnly: boolean,
): TreeItem[] {
const visibleItems = items.filter((i) => !i.archived);
const collections = visibleItems.filter((i) => i.model === "collection");
@@ -195,16 +182,31 @@ function buildChildren(
let children: TreeItem[] | undefined;
if (childItems !== undefined) {
const built = buildChildren(childItems, loadedCollections, getIcon);
const built = buildChildren(
childItems,
loadedCollections,
getIcon,
isRemoteSyncReadOnly,
);
children = built.length > 0 ? built : undefined;
} else if (hasContent(col)) {
children = [];
}
// `children === undefined` means "resolved and empty" — either the fetch came back empty or
// `here`/`below` told us there is nothing to fetch. The seeded Data/Metrics sections show a
// pitch + call to action in that state instead of collapsing to a bare row.
const sectionType = getSeededSectionType(col);
if (children === undefined && sectionType != null) {
children = [
createEmptyStateItem(sectionType, col.id, isRemoteSyncReadOnly),
];
}
return {
name: col.name,
id: `collection:${col.id}`,
icon: "folder",
icon: getIcon({ ...col, model: "collection" }).name,
data: col,
model: "collection",
children,
@@ -215,10 +217,7 @@ function buildChildren(
];
}
function buildItemNode(
item: CollectionItem,
getIcon: ReturnType<typeof useGetIcon>,
): TreeItem {
function buildItemNode(item: CollectionItem, getIcon: GetIcon): TreeItem {
return {
name: item.name,
updatedAt: item["last-edit-info"]?.timestamp,
@@ -235,3 +234,40 @@ function hasContent(item: CollectionItem): boolean {
(item.below != null && item.below.length > 0)
);
}
/** The `LibrarySectionType` of a seeded Library section, or null for user-created folders. */
function getSeededSectionType(item: CollectionItem): LibrarySectionType | null {
if (!item.is_library_root) {
return null;
}
if (item.type === "library-data") {
return "data";
}
if (item.type === "library-metrics") {
return "metrics";
}
return null;
}
/** Sort rank of the seeded sections; everything the user created sorts after them. */
const SEEDED_SECTION_RANK: Record<string, number> = {
"library-data": 0,
"library-metrics": 1,
};
const UNRANKED = 2;
/** Data and Metrics always lead; user-created top-level folders follow, alphabetically. */
function sortTopLevelRows(rows: TreeItem[]): TreeItem[] {
const rankOf = ({ data }: TreeItem) => {
if (isEmptyStateData(data) || !("is_library_root" in data)) {
return UNRANKED;
}
return data.is_library_root
? (SEEDED_SECTION_RANK[String(data.type)] ?? UNRANKED)
: UNRANKED;
};
return [...rows].sort(
(a, b) => rankOf(a) - rankOf(b) || a.name.localeCompare(b.name),
);
}

View File

@@ -62,25 +62,13 @@ export function useLibraryTreeTableInstance({
useLibraryCollections(collections);
const {
tree: tablesTree,
isLoading: loadingTables,
error: tablesError,
watchRows: watchTableRows,
isChildrenLoading: isTableChildrenLoading,
refreshCollections: refreshTableCollections,
} = useLibraryCollectionTree(tableCollection, "data");
const {
tree: metricsTree,
isLoading: loadingMetrics,
error: metricsError,
watchRows: watchMetricRows,
isChildrenLoading: isMetricChildrenLoading,
refreshCollections: refreshMetricCollections,
} = useLibraryCollectionTree(
metricCollection,
"metrics",
metricCollection?.id,
);
tree: libraryTree,
isLoading: loadingLibrary,
error: libraryError,
watchRows: watchLibraryRows,
isChildrenLoading: isLibraryChildrenLoading,
refreshCollections,
} = useLibraryCollectionTree(libraryCollection);
const {
tree: snippetTree,
isLoading: loadingSnippets,
@@ -95,20 +83,16 @@ export function useLibraryTreeTableInstance({
} = useLibrarySearch(searchQuery, libraryCollection?.id, snippetTree);
const combinedTree = useMemo(
() =>
isSearchActive
? searchTree
: [...tablesTree, ...metricsTree, ...snippetTree],
[isSearchActive, searchTree, tablesTree, metricsTree, snippetTree],
() => (isSearchActive ? searchTree : [...libraryTree, ...snippetTree]),
[isSearchActive, searchTree, libraryTree, snippetTree],
);
const isLoading =
isLoadingCollections ||
loadingTables ||
loadingMetrics ||
loadingLibrary ||
loadingSnippets ||
isSearchLoading;
useErrorHandling(tablesError || metricsError || snippetsError);
useErrorHandling(libraryError || snippetsError);
const libraryHasContent = useMemo(
() =>
@@ -213,18 +197,12 @@ export function useLibraryTreeTableInstance({
cell: ({ row }) => (
<ActionCell
treeItem={row.original}
refreshMetricCollections={refreshMetricCollections}
refreshTableCollections={refreshTableCollections}
refreshCollections={refreshCollections}
/>
),
},
],
[
isRemoteSyncReadOnly,
onPublishTableClick,
refreshMetricCollections,
refreshTableCollections,
],
[isRemoteSyncReadOnly, onPublishTableClick, refreshCollections],
);
const snippetRootId = snippetTree[0]?.id;
@@ -315,15 +293,8 @@ export function useLibraryTreeTableInstance({
// Lazy-load subcollection items when expanded
useEffect(() => {
watchTableRows(treeTableInstance.rows);
watchMetricRows(treeTableInstance.rows);
}, [treeTableInstance.rows, watchTableRows, watchMetricRows]);
const isChildrenLoading = useCallback(
(row: Parameters<typeof isTableChildrenLoading>[0]) =>
isTableChildrenLoading(row) || isMetricChildrenLoading(row),
[isTableChildrenLoading, isMetricChildrenLoading],
);
watchLibraryRows(treeTableInstance.rows);
}, [treeTableInstance.rows, watchLibraryRows]);
let emptyMessage = null;
if (!libraryHasContent) {
@@ -337,10 +308,9 @@ export function useLibraryTreeTableInstance({
return {
treeTableInstance,
allRows,
isChildrenLoading,
isChildrenLoading: isLibraryChildrenLoading,
isLoading,
emptyMessage,
refreshTableCollections,
refreshMetricCollections,
refreshCollections,
};
}

View File

@@ -9,6 +9,9 @@ export function DataStudioMetricAboutPage() {
urls={dataStudioMetricUrls}
showAppSwitcher
showDataStudioLink={false}
isInlineEditable
showManagementPanels
titleSize="h3"
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
/>
);

View File

@@ -9,6 +9,7 @@ export function DataStudioMetricDependenciesPage() {
urls={dataStudioMetricUrls}
showAppSwitcher
showDataStudioLink={false}
isInlineEditable
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
/>
);

View File

@@ -9,6 +9,7 @@ export function DataStudioMetricDimensionsPage() {
urls={dataStudioMetricUrls}
showAppSwitcher
showDataStudioLink={false}
isInlineEditable
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
/>
);

View File

@@ -9,6 +9,7 @@ export function DataStudioMetricHistoryPage() {
urls={dataStudioMetricUrls}
showAppSwitcher
showDataStudioLink={false}
isInlineEditable
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
/>
);

View File

@@ -9,6 +9,7 @@ export function DataStudioMetricOverviewPage() {
urls={dataStudioMetricUrls}
showAppSwitcher
showDataStudioLink={false}
isInlineEditable
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
/>
);

View File

@@ -9,6 +9,7 @@ export function DataStudioMetricQueryPage() {
urls={dataStudioMetricUrls}
showAppSwitcher
showDataStudioLink={false}
isInlineEditable
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
/>
);

View File

@@ -10,6 +10,7 @@ import type {
import { allCollectionModels } from "metabase/common/components/Pickers/utils";
import type {
GetEntityPickerSyntheticLibraryItemFunction,
LibraryCollectionType,
LibrarySubCollectionType,
} from "metabase/plugins/oss/library";
import { useGetLibraryCollectionQuery } from "metabase-enterprise/api";
@@ -128,7 +129,11 @@ export function getCollectionPickerItems({
parentItem: OmniPickerItem;
items: CollectionItem[];
}): OmniPickerItem[] | undefined {
if (parentItem.model !== "collection" || parentItem.type !== "library") {
if (
parentItem.model !== "collection" ||
parentItem.type !== "library" ||
!parentItem.is_library_root
) {
return undefined;
}
@@ -137,17 +142,17 @@ export function getCollectionPickerItems({
"library-metrics",
];
return librarySubCollectionType.flatMap((type) => {
const sectionItems = items.filter((item) =>
const sectionItems = librarySubCollectionType.flatMap((type) => {
const typedItems = items.filter((item) =>
isLibrarySectionCollectionItem(item, type),
);
const realRoot = sectionItems.find((item) => item.is_library_root);
const realRoot = typedItems.find((item) => item.is_library_root);
if (realRoot) {
return [realRoot];
}
if (sectionItems.length > 0) {
if (typedItems.length > 0) {
const syntheticItem = getEntityPickerSyntheticLibraryItem({
collectionId: parentItem.id,
type,
@@ -158,6 +163,14 @@ export function getCollectionPickerItems({
return [];
});
// Folders the user created at the top level of the Library are plain `library` collections and
// pass through untouched; the two seeded sections above are the only rewritten rows.
const userFolders = items.filter(
(item) => item.model === "collection" && item.type === "library",
);
return [...sectionItems, ...userFolders];
}
function isLibrarySectionCollectionItem(
@@ -213,6 +226,6 @@ export const isLibraryDataCollectionType = (
export const isLibraryCollectionType = (
type?: string | null,
): type is LibrarySubCollectionType => {
): type is LibraryCollectionType => {
return isLibrarySubCollectionType(type) || type === "library";
};

View File

@@ -8,6 +8,7 @@ const libraryParentItem: OmniPickerItem = {
name: "Library",
model: "collection",
type: "library",
is_library_root: true,
};
describe("getCollectionPickerItems", () => {
@@ -76,6 +77,52 @@ describe("getCollectionPickerItems", () => {
]);
});
it("should pass through folders the user created at the top level", () => {
const dataRoot = createMockCollectionItem({
id: 2,
name: "Data",
model: "collection",
type: "library-data",
is_library_root: true,
});
const userFolder = createMockCollectionItem({
id: 8,
name: "Finance",
model: "collection",
type: "library",
});
expect(
getCollectionPickerItems({
parentItem: libraryParentItem,
items: [dataRoot, userFolder],
}),
).toEqual([dataRoot, userFolder]);
});
it("should return undefined for a user-created Library folder, so its children are listed as-is", () => {
const userFolderParent: OmniPickerItem = {
id: 8,
name: "Finance",
model: "collection",
type: "library",
};
expect(
getCollectionPickerItems({
parentItem: userFolderParent,
items: [
createMockCollectionItem({
id: 9,
name: "Nested",
model: "collection",
type: "library",
}),
],
}),
).toBeUndefined();
});
it("should return undefined for non-Library parent items", () => {
const regularParentItem: OmniPickerItem = {
id: 6,

View File

@@ -7,7 +7,6 @@ import {
} from "react";
import { t } from "ttag";
import { userToColor } from "metabase/admin/people/colors";
import { useListUsersQuery } from "metabase/api";
import { UserAvatar } from "metabase/common/components/UserAvatar";
import {
@@ -203,7 +202,7 @@ export const AddDataAppUsers = ({
p="0.5rem 1rem"
onClick={() => addUser(user)}
>
<UserAvatar bg={userToColor(user)} user={user} />
<UserAvatar user={user} />
<Text fw="bold" size="lg">
{user.common_name}
</Text>

View File

@@ -45,11 +45,13 @@ export function GdriveConnectionModal({
const { data: { email: serviceAccountEmail } = {} } =
useGetServiceAccountQuery(shouldShow ? undefined : skipToken);
const { data: gSheetData, error } = useGetGsheetsFolderQuery(
shouldShow ? undefined : skipToken,
);
const {
data: gSheetData,
error,
isLoading,
} = useGetGsheetsFolderQuery(shouldShow ? undefined : skipToken);
if (!shouldShow) {
if (!shouldShow || isLoading) {
return null;
}

View File

@@ -1,4 +1,5 @@
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import {
findRequests,
@@ -22,9 +23,11 @@ import { GdriveConnectionModal } from "./GdriveConnectionModal";
const setup = ({
status,
isAdmin = true,
folderResponse,
}: {
status: GdrivePayload["status"];
isAdmin?: boolean;
folderResponse?: Promise<Partial<GdrivePayload>>;
}) => {
const settings = createMockSettings({
"show-google-sheets-integration": true,
@@ -39,6 +42,9 @@ const setup = ({
setupGdriveGetFolderEndpoint({
status,
});
if (folderResponse) {
fetchMock.modifyRoute("gdrive-get-folder", { response: folderResponse });
}
setupGdriveServiceAccountEndpoint(
"super-service-account@testing.metabase.com",
);
@@ -62,6 +68,20 @@ describe("Google Drive > Connect / Disconnect modal", () => {
expect(await screen.findByText("Disconnect")).toBeInTheDocument();
});
it("should not show connection modal while the connection status is loading", async () => {
const { promise: folderResponse, resolve } =
Promise.withResolvers<Partial<GdrivePayload>>();
setup({ status: "active", folderResponse });
await waitFor(() =>
expect(fetchMock.callHistory.called("gdrive-get-folder")).toBe(true),
);
expect(screen.queryByText("Import Google Sheets")).not.toBeInTheDocument();
resolve({ status: "active" });
expect(await screen.findByText("Disconnect")).toBeInTheDocument();
});
it("should show connection modal if not connected", async () => {
setup({
status: "not-connected",

View File

@@ -194,7 +194,7 @@ exports[`DataComplexityCards renders the data complexity cards 2`] = `
</div>
<div
data-accordion="true"
data-variant="default"
data-variant="separated"
>
<div>
<button
@@ -345,7 +345,7 @@ exports[`DataComplexityCards renders the data complexity cards 2`] = `
</div>
<div
data-accordion="true"
data-variant="default"
data-variant="separated"
>
<div>
<button

View File

@@ -12,6 +12,7 @@ import {
} from "__support__/ui";
import { URL_UPDATE_DEBOUNCE_DELAY } from "metabase/common/hooks/use-url-state";
import { Route } from "metabase/router";
import { SEARCH_DEBOUNCE_DURATION } from "metabase/utils/constants";
import type { RowValue } from "metabase-types/api";
import {
createMockColumn,
@@ -187,28 +188,41 @@ describe("ErrorOverview", () => {
});
it("recovers from an error when the search changes and the retry succeeds", async () => {
mockGetBoundingClientRect({ width: 100, height: 100 });
jest.useFakeTimers({ advanceTimers: true });
try {
mockGetBoundingClientRect({ width: 100, height: 100 });
let shouldFail = true;
fetchMock.post("path:/api/dataset", () =>
shouldFail
? { status: 500, body: { message: "Audit query failed" } }
: createDatasetResponse([{ id: 1, card_name: "Recovered question" }]),
);
let shouldFail = true;
fetchMock.post("path:/api/dataset", () =>
shouldFail
? { status: 500, body: { message: "Audit query failed" } }
: createDatasetResponse([{ id: 1, card_name: "Recovered question" }]),
);
renderWithProviders(<Route path="/" element={<ErrorOverview />} />, {
withRouter: true,
});
renderWithProviders(<Route path="/" element={<ErrorOverview />} />, {
withRouter: true,
});
expect(await screen.findByText("Audit query failed")).toBeInTheDocument();
const search = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
expect(await screen.findByText("Audit query failed")).toBeInTheDocument();
const search = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
// changing the query is the only way to clear RTK's error and refire
shouldFail = false;
await userEvent.type(search, "recovered");
// changing the query is the only way to clear RTK's error and refire
shouldFail = false;
await userEvent
.setup({ advanceTimers: jest.advanceTimersByTime })
.type(search, "recovered");
act(() => {
jest.advanceTimersByTime(SEARCH_DEBOUNCE_DURATION);
});
expect(await screen.findByText("Recovered question")).toBeInTheDocument();
expect(screen.queryByText("Audit query failed")).not.toBeInTheDocument();
// scoped to the row: TreeTable briefly renders a hidden copy of each
// cell into document.body to measure auto-width columns
const row = await screen.findByTestId("erroring-question");
expect(within(row).getByText("Recovered question")).toBeInTheDocument();
expect(screen.queryByText("Audit query failed")).not.toBeInTheDocument();
} finally {
jest.useRealTimers();
}
});
it("surfaces an internal-query error returned in the dataset body", async () => {

View File

@@ -23,7 +23,7 @@ export const CollectionSyncRow = ({
const icon = getIcon({
model: "collection",
type: collection.type,
is_remote_synced: isChecked,
icon: collection.icon,
});
return (

View File

@@ -1,58 +0,0 @@
import { useCallback, useMemo } from "react";
import { useListCollectionsQuery } from "metabase/api";
import type { CollectionTreeItem } from "metabase/common/collections/utils";
import { Tree } from "metabase/common/components/tree";
import type { ITreeNodeItem } from "metabase/common/components/tree/types";
import { SidebarCollectionLink } from "metabase/nav/containers/MainNavbar/SidebarItems";
import { useRemoteSyncDirtyState } from "../../hooks/use-remote-sync-dirty-state";
import { CollectionSyncStatusBadge } from "../SyncedCollectionsSidebarSection/CollectionSyncStatusBadge";
interface CollectionsNavTreeProps {
collections: CollectionTreeItem[];
selectedId?: number | string;
onSelect?: (item: ITreeNodeItem) => void;
}
export const CollectionsNavTree = ({
collections,
selectedId,
onSelect,
}: CollectionsNavTreeProps) => {
// Fetch flat list to check for remote-synced collections
const { data: collectionsList = [] } = useListCollectionsQuery({
archived: false,
});
const hasRemoteSyncedCollections = useMemo(
() => collectionsList.some((c) => c.is_remote_synced),
[collectionsList],
);
const { isCollectionDirty } = useRemoteSyncDirtyState();
const showChangesBadge = useCallback(
(itemId?: number | string) => {
if (!hasRemoteSyncedCollections) {
return false;
}
return isCollectionDirty(itemId);
},
[hasRemoteSyncedCollections, isCollectionDirty],
);
return (
<Tree
data={collections}
selectedId={selectedId}
onSelect={onSelect}
TreeNode={SidebarCollectionLink}
role="tree"
aria-label="collection-tree"
rightSection={(item) =>
showChangesBadge(item?.id) && <CollectionSyncStatusBadge />
}
/>
);
};

View File

@@ -1,214 +0,0 @@
import fetchMock from "fetch-mock";
import { mockSettings } from "__support__/settings";
import { createMockState } from "__support__/state";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import { Route } from "metabase/router";
import type { Collection } from "metabase-types/api";
import { createMockCollection } from "metabase-types/api/mocks";
// Mock useRemoteSyncDirtyState
const mockIsCollectionDirty = jest.fn(
(_id: number | string | undefined) => false,
);
jest.mock("../../hooks/use-remote-sync-dirty-state", () => ({
useRemoteSyncDirtyState: () => ({
isCollectionDirty: mockIsCollectionDirty,
}),
}));
import { CollectionsNavTree } from "./CollectionsNavTree";
const createMockCollectionTreeItem = (
overrides: Partial<Collection> = {},
): Collection => ({
...createMockCollection({
id: 1,
name: "Test Collection",
is_remote_synced: false,
...overrides,
}),
});
const setupEndpoints = ({
collections = [createMockCollectionTreeItem()],
}: {
collections?: Collection[];
} = {}) => {
fetchMock.get("path:/api/collection", collections);
};
const setup = ({
collections = [createMockCollectionTreeItem()],
collectionsList = [createMockCollectionTreeItem()],
// Unjustified type cast. FIXME
dirtyCollectionIds = [] as number[],
}: {
collections?: Collection[];
collectionsList?: Collection[];
dirtyCollectionIds?: number[];
} = {}) => {
setupEndpoints({ collections: collectionsList });
// Configure mock to return true for dirty collection IDs
mockIsCollectionDirty.mockImplementation(
(id: number | string | undefined) =>
typeof id === "number" && dirtyCollectionIds.includes(id),
);
return renderWithProviders(
<Route
path="/"
element={
<CollectionsNavTree
// Unjustified type cast. FIXME
collections={collections as any}
selectedId={undefined}
onSelect={jest.fn()}
/>
}
/>,
{
storeInitialState: createMockState({
settings: mockSettings({}),
}),
withDND: true,
withRouter: true,
},
);
};
describe("CollectionsNavTree", () => {
beforeEach(() => {
jest.clearAllMocks();
fetchMock.removeRoutes();
fetchMock.clearHistory();
mockIsCollectionDirty.mockReturnValue(false);
});
describe("rendering", () => {
it("should render collections tree", async () => {
const collection = createMockCollectionTreeItem({
name: "My Collection",
});
setup({ collections: [collection] });
await waitFor(() => {
expect(screen.getByText("My Collection")).toBeInTheDocument();
});
});
it("should render multiple collections", async () => {
const collection1 = createMockCollectionTreeItem({
id: 1,
name: "Collection 1",
});
const collection2 = createMockCollectionTreeItem({
id: 2,
name: "Collection 2",
});
setup({ collections: [collection1, collection2] });
await waitFor(() => {
expect(screen.getByText("Collection 1")).toBeInTheDocument();
});
expect(screen.getByText("Collection 2")).toBeInTheDocument();
});
});
describe("dirty state badges", () => {
it("should not show badge when no collections are remote-synced", async () => {
const collection = createMockCollectionTreeItem({
id: 1,
name: "Regular Collection",
is_remote_synced: false,
});
setup({
collections: [collection],
collectionsList: [collection],
dirtyCollectionIds: [1],
});
await waitFor(() => {
expect(screen.getByText("Regular Collection")).toBeInTheDocument();
});
// Badge should not be present since collection is not remote-synced
expect(
screen.queryByTestId("remote-sync-status"),
).not.toBeInTheDocument();
});
it("should show badge when collection has dirty changes and is remote-synced", async () => {
const collection = createMockCollectionTreeItem({
id: 1,
name: "Synced Collection",
is_remote_synced: true,
});
setup({
collections: [collection],
collectionsList: [collection],
dirtyCollectionIds: [1],
});
await waitFor(() => {
expect(screen.getByText("Synced Collection")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByTestId("remote-sync-status")).toBeInTheDocument();
});
});
it("should not show badge for collection without dirty changes", async () => {
const syncedCollection = createMockCollectionTreeItem({
id: 1,
name: "Clean Synced Collection",
is_remote_synced: true,
});
setup({
collections: [syncedCollection],
collectionsList: [syncedCollection],
dirtyCollectionIds: [],
});
await waitFor(() => {
expect(screen.getByText("Clean Synced Collection")).toBeInTheDocument();
});
// Badge should not be present since there are no dirty changes
expect(
screen.queryByTestId("remote-sync-status"),
).not.toBeInTheDocument();
});
it("should show badge only for collections with changes", async () => {
const dirtyCollection = createMockCollectionTreeItem({
id: 1,
name: "Dirty Collection",
is_remote_synced: true,
});
const cleanCollection = createMockCollectionTreeItem({
id: 2,
name: "Clean Collection",
is_remote_synced: true,
});
setup({
collections: [dirtyCollection, cleanCollection],
collectionsList: [dirtyCollection, cleanCollection],
dirtyCollectionIds: [1],
});
await waitFor(() => {
expect(screen.getByText("Dirty Collection")).toBeInTheDocument();
});
expect(screen.getByText("Clean Collection")).toBeInTheDocument();
await waitFor(() => {
// Only one badge should be present
const badges = screen.getAllByTestId("remote-sync-status");
expect(badges).toHaveLength(1);
});
});
});
});

View File

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

View File

@@ -3,22 +3,16 @@
* rules in metabase/ui's Accordion.module.css. The compound selectors are written flat on purpose:
* nesting them would make postcss-nesting wrap them in `:is()` and change the specificity math.
*/
.accordion .item,
.accordion .item + .item,
.accordion .item[data-active] {
border: 0;
border-radius: 0;
}
/* Padding lives on the row around it, which the switch shares. */
.accordion .control {
background-color: transparent;
flex: 1;
min-width: 0;
padding: 0;
}
.accordion .label {
color: var(--mb-color-text-primary);
font-size: inherit;
font-weight: normal;
line-height: inherit;
@@ -26,15 +20,10 @@
}
.accordion .chevron {
border: 0;
color: var(--mb-color-text-secondary);
height: auto;
margin-left: 0;
width: auto;
}
.accordion .content {
border-top: none;
padding-top: 1rem;
padding-left: 3rem;
}

View File

@@ -134,12 +134,11 @@ const RequiredSyncItem = ({
type: row.type,
is_personal: row.personal,
location: "/",
is_remote_synced: isSynced,
is_library_root: row.type === "library",
});
return (
<Accordion.Item value={row.key} mt="sm">
<Accordion.Item value={row.key}>
{/* The switch sits outside the control, so flipping it doesn't also toggle the panel. */}
<Group
gap="sm"

View File

@@ -1,4 +1,3 @@
import { useMemo } from "react";
import { t } from "ttag";
import ErrorBoundary from "metabase/common/components/ErrorBoundary";
@@ -12,9 +11,6 @@ import type { SyncedCollectionsSidebarSectionProps } from "metabase/plugins/type
import { Box, Flex, Group, Text } from "metabase/ui";
import { useGitSyncVisible } from "../../hooks/use-git-sync-visible";
import { useRemoteSyncDirtyState } from "../../hooks/use-remote-sync-dirty-state";
import { CollectionSyncStatusBadge } from "./CollectionSyncStatusBadge";
export const SyncedCollectionsSidebarSection = ({
onItemSelect,
@@ -24,30 +20,6 @@ export const SyncedCollectionsSidebarSection = ({
const hasSyncedCollections = syncedCollections.length > 0;
const { isVisible: isGitSyncVisible } = useGitSyncVisible();
const { dirty, isCollectionDirty } = useRemoteSyncDirtyState();
const hasEntityRemoved = useMemo(
() => dirty.some((entity) => entity.sync_status === "removed"),
[dirty],
);
const showChangesBadge = (itemId?: number | string) => {
if (typeof itemId !== "number") {
return false;
}
const collectionIsUpdated = isCollectionDirty(itemId);
const hasSingleRootCollection = syncedCollections.length === 1;
return (
// Collection was updated
collectionIsUpdated ||
// An item was removed and this is the root synced collection (TODO: this is a workaround before we do UXW-2181)
(hasEntityRemoved &&
hasSingleRootCollection &&
syncedCollections[0].id === itemId)
);
};
if (!isGitSyncVisible) {
return null;
@@ -77,9 +49,6 @@ export const SyncedCollectionsSidebarSection = ({
TreeNode={SidebarCollectionLink}
role="tree"
aria-label="collection-tree"
rightSection={(item) =>
showChangesBadge(item?.id) && <CollectionSyncStatusBadge />
}
/>
</Box>
</ErrorBoundary>

View File

@@ -29,16 +29,6 @@ export function useRemoteSyncDirtyState(): RemoteSyncDirtyState {
[dirty],
);
const isCollectionDirty = useCallback(
(collectionId: number | string | undefined) => {
if (typeof collectionId !== "number") {
return false;
}
return !!changedCollections[collectionId];
},
[changedCollections],
);
const hasAnyCollectionDirty = useCallback(
(collectionIds: Set<number> | number[]) => {
const ids =
@@ -78,7 +68,6 @@ export function useRemoteSyncDirtyState(): RemoteSyncDirtyState {
isDirty,
hasRemovedItems,
isLoading,
isCollectionDirty,
hasAnyCollectionDirty,
hasDirtyInCollectionTree,
refetch,

View File

@@ -92,45 +92,6 @@ describe("useRemoteSyncDirtyState", () => {
});
});
describe("isCollectionDirty", () => {
it("returns false for collection without dirty items", async () => {
const { result } = setup({
dirty: [createMockDirtyEntity({ collection_id: 1 })],
});
await waitFor(() => {
expect(result.current.dirty.length).toBeGreaterThan(0);
});
expect(result.current.isCollectionDirty(2)).toBe(false);
});
it("returns true for collection with dirty items", async () => {
const { result } = setup({
dirty: [createMockDirtyEntity({ collection_id: 1 })],
});
await waitFor(() => {
expect(result.current.dirty.length).toBeGreaterThan(0);
});
expect(result.current.isCollectionDirty(1)).toBe(true);
});
it("returns false for non-numeric collection id", async () => {
const { result } = setup({
dirty: [createMockDirtyEntity({ collection_id: 1 })],
});
await waitFor(() => {
expect(result.current.dirty.length).toBeGreaterThan(0);
});
expect(result.current.isCollectionDirty("root")).toBe(false);
expect(result.current.isCollectionDirty(undefined)).toBe(false);
});
});
describe("hasAnyCollectionDirty", () => {
it("returns false when no collections in set are dirty", async () => {
const { result } = setup({

View File

@@ -6,7 +6,6 @@ import {
import { hasPremiumFeature } from "metabase-enterprise/settings";
import { LibraryNav } from "./LibraryNav";
import { CollectionsNavTree } from "./components/CollectionsNavTree";
import { GitSettingsModal } from "./components/GitSettingsModal";
import { GitSyncControls } from "./components/GitSyncControls";
import { GitSyncSetupMenuItem } from "./components/GitSyncSetupMenuItem";
@@ -38,7 +37,6 @@ export function initializePlugin() {
SyncedCollectionsSidebarSection;
PLUGIN_REMOTE_SYNC.GitSyncAppBarControls = GitSyncControls;
PLUGIN_REMOTE_SYNC.GitSettingsModal = GitSettingsModal;
PLUGIN_REMOTE_SYNC.CollectionsNavTree = CollectionsNavTree;
PLUGIN_REMOTE_SYNC.CollectionSyncStatusBadge = CollectionSyncStatusBadge;
PLUGIN_REMOTE_SYNC.REMOTE_SYNC_INVALIDATION_TAGS =
REMOTE_SYNC_INVALIDATION_TAGS;

View File

@@ -8,7 +8,6 @@ import {
BulkActionButton,
} from "metabase/common/components/BulkActionBar";
import { GenericError } from "metabase/common/components/ErrorPages";
import { useCloseNavbarOnMount } from "metabase/common/hooks/use-close-navbar-on-mount";
import { getUserIsAdmin } from "metabase/current-user";
import { useSelector } from "metabase/redux";
import { useLocation, useParams } from "metabase/router";
@@ -49,8 +48,6 @@ export const EditTableDataContainer = () => {
const { dbId: dbIdParam, tableId: tableIdParam } =
useParams<EditTableDataContainerParams>();
useCloseNavbarOnMount();
const databaseId = parseInt(dbIdParam ?? "", 10);
const tableId = parseInt(tableIdParam ?? "", 10);

View File

@@ -1,10 +1,7 @@
import { useCallback, useMemo, useState } from "react";
import { t } from "ttag";
import {
useCreateCollectionMutation,
useListCollectionsQuery,
} from "metabase/api";
import { useCreateCollectionMutation } from "metabase/api";
import { CreateCollectionForm } from "metabase/common/collections/components/CreateCollectionForm";
import type { CreateCollectionProperties } from "metabase/common/collections/components/CreateCollectionForm/CreateCollectionForm";
import { buildCollectionTree } from "metabase/common/collections/utils";
@@ -23,8 +20,6 @@ import {
tenantSpecificCollections,
tenantUsersPersonalCollections,
} from "metabase/urls";
import { useGetRemoteSyncChangesQuery } from "metabase-enterprise/api";
import { CollectionSyncStatusBadge } from "metabase-enterprise/remote_sync/components/SyncedCollectionsSidebarSection/CollectionSyncStatusBadge";
import type { Collection } from "metabase-types/api";
export const MainNavSharedCollections = ({
@@ -47,23 +42,6 @@ export const MainNavSharedCollections = ({
const isTenantsEnabled = useSetting("use-tenants");
const isAdmin = useSelector(getUserIsAdmin);
// Fetch flat list of tenant collections to check if any are remote-synced
const { data: tenantCollectionsList = [] } = useListCollectionsQuery(
{ namespace: "shared-tenant-collection" },
{ skip: !isTenantsEnabled },
);
// Check if any tenant collections have is_remote_synced=true
const hasRemoteSyncedTenantCollections = useMemo(
() => tenantCollectionsList.some((c) => c.is_remote_synced),
[tenantCollectionsList],
);
const { data: dirtyData } = useGetRemoteSyncChangesQuery(undefined, {
skip: !hasRemoteSyncedTenantCollections,
refetchOnFocus: true,
});
const [createCollection] = useCreateCollectionMutation();
const sharedTenantCollectionTree = useMemo(
@@ -71,26 +49,6 @@ export const MainNavSharedCollections = ({
[sharedTenantCollections],
);
const changedCollections = useMemo(
() => dirtyData?.changedCollections ?? {},
[dirtyData?.changedCollections],
);
const showChangesBadge = useCallback(
(itemId?: number | string) => {
if (
!hasRemoteSyncedTenantCollections ||
!changedCollections ||
typeof itemId !== "number"
) {
return false;
}
return !!changedCollections[itemId];
},
[hasRemoteSyncedTenantCollections, changedCollections],
);
const handleCreateTenantCollection = useCallback(
async (values: CreateCollectionProperties) => {
await createCollection({
@@ -142,9 +100,6 @@ export const MainNavSharedCollections = ({
TreeNode={SidebarCollectionLink}
role="tree"
aria-label="tenant-collection-tree"
rightSection={(item) =>
showChangesBadge(item?.id) && <CollectionSyncStatusBadge />
}
/>
{canAccessTenantSpecificCollections && (
<PaddedSidebarLink icon="group" url={tenantSpecificCollections()}>

View File

@@ -21,7 +21,6 @@ import {
Text,
UnstyledButton,
} from "metabase/ui";
import { tenantIdToColor } from "metabase-enterprise/tenants/utils/colors";
import * as Urls from "metabase-enterprise/urls";
import type { Tenant } from "metabase-types/api";
@@ -99,10 +98,7 @@ export const TenantsListing = ({
className={CS.link}
gap="lg"
>
<UserAvatar
user={{ name: tenant.name }}
bg={tenantIdToColor(tenant.id)}
/>
<UserAvatar user={{ name: tenant.name }} />
<Box component="span" fw={700} c="core-brand">
{tenant.name}
</Box>

View File

@@ -1,14 +0,0 @@
import { color } from "metabase/ui/colors";
import type { Tenant } from "metabase-types/api";
const groupColorPalette = [
color("feedback-negative"),
color("accent2"),
color("core-brand"),
color("accent4"),
color("accent1"),
];
export const tenantIdToColor = (tenantId: Tenant["id"]) => {
return groupColorPalette[tenantId % groupColorPalette.length];
};

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