Compare commits

...

19 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
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
297 changed files with 5643 additions and 5928 deletions

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",
@@ -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=="],
@@ -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=="],
@@ -5063,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=="],
@@ -5681,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=="],
@@ -6801,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=="],
@@ -7099,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=="],
@@ -7299,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=="],
@@ -7807,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=="],
@@ -7991,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=="],
@@ -8393,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=="],
@@ -8493,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=="],
@@ -8675,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

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

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

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

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

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

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

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

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

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

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

@@ -134,7 +134,6 @@ const RequiredSyncItem = ({
type: row.type,
is_personal: row.personal,
location: "/",
is_remote_synced: isSynced,
is_library_root: row.type === "library",
});

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];
};

View File

@@ -9,6 +9,7 @@ import type {
import type { CardId, CardType } from "./card";
import type { DatabaseId } from "./database";
import type { IconName } from "./icon";
import type { SortDirection } from "./sorting";
import type { TableId } from "./table";
import type { UserId, UserInfo } from "./user";
@@ -67,6 +68,9 @@ export interface Collection {
children?: Collection[];
authority_level?: CollectionAuthorityLevel;
type?: CollectionType;
// User-chosen icon override for the icon that would otherwise be derived
// from the collection's type/identity. Only surfaced for library folders.
icon?: IconName | null;
is_remote_synced?: boolean;
namespace: CollectionNamespace | null;
@@ -135,6 +139,7 @@ export interface CollectionItem {
can_restore?: boolean;
can_delete?: boolean;
is_library_root?: boolean;
icon?: IconName | null;
"last-edit-info"?: LastEditInfo;
location?: string | null;
effective_location?: string;
@@ -214,6 +219,7 @@ export interface UpdateCollectionRequest {
authority_level?: CollectionAuthorityLevel;
type?: CollectionType;
is_remote_synced?: boolean;
icon?: IconName | null;
}
export interface CreateCollectionRequest {
@@ -223,6 +229,7 @@ export interface CreateCollectionRequest {
namespace?: CollectionNamespace;
authority_level?: CollectionAuthorityLevel;
is_shared_tenant_collection?: boolean;
icon?: IconName | null;
}
export type ListCollectionsRequest = {

View File

@@ -1,8 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import { APP_BAR_HEIGHT } from "metabase/nav/constants";
export const AppContainer = styled.div`
display: flex;
flex-direction: column;
@@ -27,11 +25,25 @@ export const AppContentContainer = styled.div<{
}
`;
export const AppMain = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
height: 100%;
overflow: hidden;
@media print {
height: 100%;
overflow: visible !important;
}
`;
export const AppContent = styled.main`
width: 100%;
height: 100%;
flex: 1;
min-height: 0;
overflow: auto;
scroll-margin-top: ${APP_BAR_HEIGHT};
@media print {
overflow: visible !important;

View File

@@ -1,14 +1,14 @@
import { useEffect, useState } from "react";
import { AppBarContainer } from "metabase/app/nav/AppBar";
import { AppPageHeader } from "metabase/app/nav/AppPageHeader";
import { Navbar } from "metabase/app/nav/Navbar";
import {
getIsAdminApp,
getIsAppBarVisible,
getIsDataApp,
getIsDataStudioApp,
getIsMonitorApp,
getIsNavBarEnabled,
getIsPageHeaderVisible,
} from "metabase/app/selectors";
import { AppBanner } from "metabase/common/components/AppBanner";
import ErrorBoundary from "metabase/common/components/ErrorBoundary";
@@ -34,7 +34,12 @@ import { useGetSettingsQuery, useTokenRefresh } from "metabase/settings";
import { StatusListing } from "metabase/status/components/StatusListing";
import { initializeIframeResizer } from "metabase/utils/dom";
import { AppContainer, AppContent, AppContentContainer } from "./App.styled";
import {
AppContainer,
AppContent,
AppContentContainer,
AppMain,
} from "./App.styled";
import { AppKBarProvider } from "./AppKBarProvider";
import ScrollToTop from "./ScrollToTop";
import { trackPageView } from "./analytics";
@@ -76,8 +81,8 @@ export function App() {
getIsMonitorApp(state, routerProps),
);
const isDataApp = useSelector((state) => getIsDataApp(state, routerProps));
const isAppBarVisible = useSelector((state) =>
getIsAppBarVisible(state, routerProps),
const isPageHeaderVisible = useSelector((state) =>
getIsPageHeaderVisible(state, routerProps),
);
const isNavBarEnabled = useSelector((state) =>
getIsNavBarEnabled(state, routerProps),
@@ -108,16 +113,18 @@ export function App() {
<KeyboardTriggeredErrorModal />
<AppContainer className={CS.spread}>
<AppBanner />
{isAppBarVisible && <AppBarContainer />}
<AppContentContainer isAdminApp={isAdminApp}>
{isNavBarEnabled && <Navbar />}
<AppContent ref={setViewportElement}>
<ContentViewportContext.Provider
value={viewportElement ?? null}
>
{errorPage ? getErrorComponent(errorPage) : <Outlet />}
</ContentViewportContext.Provider>
</AppContent>
<AppMain>
{isPageHeaderVisible && <AppPageHeader />}
<AppContent ref={setViewportElement}>
<ContentViewportContext.Provider
value={viewportElement ?? null}
>
{errorPage ? getErrorComponent(errorPage) : <Outlet />}
</ContentViewportContext.Provider>
</AppContent>
</AppMain>
<UndoListing />
<StatusListing />
<NewModals />

View File

@@ -1,12 +1,10 @@
import cx from "classnames";
import { useMemo } from "react";
import { t } from "ttag";
import { Breadcrumbs } from "metabase/common/components/Breadcrumbs";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import CS from "metabase/css/core/index.css";
import { Revision } from "metabase/segments";
import { assignUserColors } from "metabase/ui/colors/formatting-colors";
import * as Urls from "metabase/urls";
import type {
Revision as RevisionType,
@@ -21,17 +19,6 @@ interface Props {
}
export function RevisionHistory({ revisions, segment, user }: Props) {
const userColorAssignments = useMemo(() => {
if (!revisions) {
return {};
}
return assignUserColors(
revisions.map((revision) => String(revision.user.id)),
String(user.id),
);
}, [revisions, user]);
return (
<LoadingAndErrorWrapper
loading={!segment || !revisions}
@@ -70,7 +57,6 @@ export function RevisionHistory({ revisions, segment, user }: Props) {
objectName={segment.name}
revision={revision}
tableId={segment.table_id}
userColor={userColorAssignments[revision.user.id]}
/>
))}
</ol>

View File

@@ -1,18 +0,0 @@
import { color } from "metabase/ui/colors";
import type { GroupId, User } from "metabase-types/api";
export const userToColor = (user: User) => {
return user.is_superuser ? color("accent2") : color("core-brand");
};
const groupColorPalette = [
color("feedback-negative"),
color("accent2"),
color("core-brand"),
color("accent4"),
color("accent1"),
];
export const groupIdToColor = (groupId: GroupId) => {
return groupColorPalette[groupId % groupColorPalette.length];
};

View File

@@ -7,8 +7,6 @@ import { PLUGIN_TENANTS } from "metabase/plugins";
import { Flex, Pill, Popover, Text, UnstyledButton } from "metabase/ui";
import type { Group, Member, User } from "metabase-types/api";
import { userToColor } from "../colors";
import { AddRow } from "./AddRow";
interface AddMemberRowProps {
@@ -116,7 +114,7 @@ export function AddMemberRow({
p="0.5rem 1rem"
onClick={() => handleAddUser(user)}
>
<UserAvatar bg={userToColor(user)} user={user} />
<UserAvatar user={user} />
<Text fw="bold" size="lg">
{user.common_name}
</Text>

View File

@@ -30,7 +30,6 @@ import { KEYCODE_ENTER } from "metabase/utils/keyboard";
import type { ApiKey, GroupInfo } from "metabase-types/api";
import { useListApiKeysQuery } from "../../settings/api/api-key";
import { groupIdToColor } from "../colors";
import { AddRow } from "./AddRow";
import { SearchFilter } from "./SearchFilter";
@@ -233,7 +232,7 @@ function GroupNameCell({ group }: { group: GroupInfo }) {
className={CS.link}
gap="md"
>
<UserAvatar user={{ name }} bg={groupIdToColor(group.id)} />
<UserAvatar user={{ name }} />
<Box component="span" fw={700} c="core-brand">
{name}
</Box>

View File

@@ -26,8 +26,6 @@ import type {
User,
} from "metabase-types/api";
import { userToColor } from "../colors";
import { MembershipSelect } from "./MembershipSelect";
import { ReactivateUserButton } from "./ReactivateUserButton";
@@ -75,7 +73,7 @@ export const PeopleListRow = ({
return (
<tr key={user.id}>
<Flex component="td" align="center" gap="lg" c="text-primary-inverse">
<UserAvatar bg={userToColor(user)} user={user} />
<UserAvatar user={user} />
<Text fw="700">{getFullName(user) ?? "-"}</Text>
</Flex>
<td>

View File

@@ -1,152 +0,0 @@
import { skipToken, useGetDashboardQuery } from "metabase/api";
import { QuestionLineage } from "metabase/app/nav/QuestionLineage";
import {
getCollectionId,
getIsAppSwitcherVisible,
getIsCollectionPathVisible,
getIsLogoVisible,
getIsMetricsViewer,
getIsNavBarEnabled,
getIsNewButtonVisible,
getIsQuestionLineageVisible,
getIsSearchVisible,
} from "metabase/app/selectors";
import { useInitialCollectionId } from "metabase/common/collections/hooks";
import { getUser } from "metabase/current-user";
import { getIsSidebarOpen } from "metabase/documents/selectors";
import { getMetabotVisible } from "metabase/metabot/state";
import { AppBar as AppBarView } from "metabase/nav/components/AppBar";
import { CollectionBreadcrumbs } from "metabase/nav/containers/CollectionBreadcrumbs";
import { isQuestionPath } from "metabase/nav/containers/MainNavbar/getSelectedItems";
import { getOriginalQuestion, getQuestion } from "metabase/query_builder";
import { useDispatch, useSelector } from "metabase/redux";
import { closeNavbar, toggleNavbar } from "metabase/redux/app";
import { useLocation, useNavigate, useParams } from "metabase/router";
import { getDetailViewState, getIsNavbarOpen } from "metabase/selectors/app";
import { modelToUrl } from "metabase/urls";
import { isWithinIframe } from "metabase/utils/iframe";
import type { SearchResult } from "metabase-types/api";
type SearchResultSelection =
| { type: "zoom"; objectId: SearchResult["id"] }
| { type: "navigate"; url: string };
/**
* Decides what should happen when a search result is selected from the app bar.
* When the result is an indexed-entity row that belongs to the model we're
* already viewing, we zoom into that row in place instead of navigating away.
*/
export function getSearchResultSelection(
result: SearchResult,
currentCardId: number | undefined,
): SearchResultSelection {
if (result.model === "indexed-entity" && result.model_id === currentCardId) {
return { type: "zoom", objectId: result.id };
}
return { type: "navigate", url: modelToUrl(result) };
}
export function AppBarContainer() {
const dispatch = useDispatch();
const navigate = useNavigate();
// These selectors derive app-bar visibility from the URL, so they take the
// router props rather than reading them from the store.
const location = useLocation();
const params = useParams();
const routerProps = { location };
const collectionId =
useInitialCollectionId({ location, params }) ?? undefined;
const currentUser = useSelector(getUser);
const isNavBarOpen = useSelector(getIsNavbarOpen);
const isNavBarEnabled = useSelector((state) =>
getIsNavBarEnabled(state, routerProps),
);
const isMetabotVisible = useSelector((state) =>
getMetabotVisible(state, "omnibot"),
);
const isDocumentSidebarOpen = useSelector(getIsSidebarOpen);
const isLogoVisible = useSelector(getIsLogoVisible);
const isSearchVisible = useSelector(getIsSearchVisible);
const isEmbeddingIframe = isWithinIframe();
const isNewButtonVisible = useSelector(getIsNewButtonVisible);
const isAppSwitcherVisible = useSelector(getIsAppSwitcherVisible);
const isCollectionPathVisible = useSelector((state) =>
getIsCollectionPathVisible(state, routerProps),
);
const isQuestionLineageVisible = useSelector((state) =>
getIsQuestionLineageVisible(state, routerProps),
);
const detailView = useSelector(getDetailViewState);
const isMetricsViewer = useSelector((state) =>
getIsMetricsViewer(state, routerProps),
);
const question = useSelector(getQuestion);
const originalQuestion = useSelector(getOriginalQuestion);
// The breadcrumbs' current collection is derived from the active
// dashboard/question/document state. CollectionBreadcrumbs used to read this
// itself, but getCollectionId orchestrates feature state and now lives in the
// app tier, so the app-tier AppBar resolves it and passes it down.
const breadcrumbCollectionId = useSelector(getCollectionId);
const { pathname } = location;
const isOnQuestionPage = pathname && isQuestionPath(pathname);
const dashboardId = isOnQuestionPage ? question?.dashboard()?.id : undefined;
const { data: dashboard } = useGetDashboardQuery(
dashboardId != null ? { id: dashboardId } : skipToken,
);
// Unjustified type cast. FIXME
const locationState = location.state as { cardId?: number } | undefined;
const onSearchItemSelect = async (result: SearchResult) => {
const selection = getSearchResultSelection(result, locationState?.cardId);
if (selection.type === "zoom") {
// Reached through an import so the app bar, which every page mounts, does
// not put the query builder's actions in the initial bundle. Only a search
// selection on an object-detail page gets here, and the query builder it
// zooms within is already loaded by then.
const { zoomInRow } = await import("metabase/query_builder");
dispatch(zoomInRow({ objectId: selection.objectId }));
} else {
navigate(selection.url);
}
};
return (
<AppBarView
currentUser={currentUser}
isNavBarOpen={isNavBarOpen}
isNavBarEnabled={isNavBarEnabled}
isMetabotVisible={isMetabotVisible}
isDocumentSidebarOpen={isDocumentSidebarOpen}
isLogoVisible={isLogoVisible}
isSearchVisible={isSearchVisible}
isEmbeddingIframe={isEmbeddingIframe}
isNewButtonVisible={isNewButtonVisible}
isAppSwitcherVisible={isAppSwitcherVisible}
isCollectionPathVisible={isCollectionPathVisible}
isQuestionLineageVisible={isQuestionLineageVisible}
detailView={detailView}
isMetricsViewer={isMetricsViewer}
onToggleNavbar={() => dispatch(toggleNavbar())}
onCloseNavbar={() => dispatch(closeNavbar())}
collectionId={collectionId}
collectionBreadcrumbs={
<CollectionBreadcrumbs
dashboard={dashboardId != null ? dashboard : undefined}
collectionId={breadcrumbCollectionId ?? undefined}
/>
}
questionLineage={
<QuestionLineage
question={question}
originalQuestion={originalQuestion}
/>
}
onSearchItemSelect={onSearchItemSelect}
/>
);
}

View File

@@ -1,427 +0,0 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
setupCardEndpoints,
setupCollectionByIdEndpoint,
setupCollectionsEndpoints,
setupDashboardEndpoints,
setupUserMetabotPermissionsEndpoint,
} from "__support__/server-mocks";
import {
createMockAppState,
createMockDashboardState,
createMockEmbedOptions,
createMockEmbedState,
createMockQueryBuilderState,
createMockStoreDashboard,
} from "__support__/state";
import { renderWithProviders } from "__support__/ui";
import { DEFAULT_INTERACTIVE_EMBEDDING_OPTIONS } from "metabase/redux/embed";
import type {
DashboardState,
InteractiveEmbeddingOptions,
} from "metabase/redux/store";
import { Route } from "metabase/router";
import { modelToUrl } from "metabase/urls";
import type { Card } from "metabase-types/api";
import {
createMockCard,
createMockCollection,
createMockDashboard,
createMockSearchResult,
} from "metabase-types/api/mocks";
import { AppBarContainer, getSearchResultSelection } from "./AppBar";
const FOO_COLLECTION = createMockCollection({
id: 3,
name: "Foo Collection",
});
const BAR_DASHBOARD = createMockDashboard({
id: 4,
name: "Bar Dashboard",
collection_id: 3,
});
const CARD_IN_COLLECTION = createMockCard({ id: 2, collection_id: 3 });
const CARD_IN_DASHBOARD = createMockCard({
id: 3,
collection_id: 3,
dashboard_id: 4,
dashboard: BAR_DASHBOARD,
});
const CARD_IN_RENAMED_DASHBOARD = createMockCard({
id: 4,
collection_id: 3,
dashboard_id: 4,
dashboard: createMockDashboard({
id: 4,
name: "Stale Dashboard Name",
collection_id: 3,
}),
});
const CARD_WITH_DASHBOARD_ID = createMockCard({
id: 5,
collection_id: 3,
dashboard_id: 4,
dashboard: undefined,
});
describe("AppBar", () => {
const matchMediaSpy = jest.spyOn(window, "matchMedia");
describe("full-app embedding", () => {
beforeEach(async () => {
mockEmbedding();
});
afterEach(() => {
jest.clearAllMocks();
restoreMockEmbedding();
});
describe("large screens", () => {
beforeEach(() => {
matchMediaSpy.mockReturnValue(getMediaQuery({ matches: false }));
});
it("should be able to toggle side nav", async () => {
setup({
embedOptions: {
side_nav: true,
},
});
expect(await screen.findByText(/Our analytics/)).toBeVisible();
expect(screen.getByTestId("main-logo")).toBeVisible();
await userEvent.click(screen.getByTestId("sidebar-toggle"));
expect(screen.getByText(/Our analytics/)).not.toBeVisible();
expect(screen.getByTestId("main-logo")).toBeVisible();
});
it("should hide side nav toggle icon", async () => {
setup({
embedOptions: {
side_nav: false,
},
});
expect(await screen.findByText(/Our analytics/)).toBeVisible();
expect(screen.getByTestId("main-logo")).toBeVisible();
expect(screen.queryByTestId("sidebar-toggle")).not.toBeInTheDocument();
});
it("should always show side nav toggle icon when logo is hidden", async () => {
setup({
embedOptions: {
side_nav: true,
logo: false,
},
});
expect(await screen.findByText(/Our analytics/)).toBeVisible();
expect(screen.queryByTestId("main-logo")).not.toBeInTheDocument();
expect(screen.getByTestId("sidebar-toggle")).toBeVisible();
await userEvent.click(screen.getByTestId("sidebar-toggle"));
expect(screen.getByText(/Our analytics/)).not.toBeVisible();
expect(screen.queryByTestId("main-logo")).not.toBeInTheDocument();
expect(screen.getByTestId("sidebar-toggle")).toBeVisible();
});
it("should not show either logo or side nav toggle button at all", async () => {
setup({
embedOptions: {
side_nav: false,
logo: false,
},
});
expect(await screen.findByText(/Our analytics/)).toBeVisible();
expect(screen.queryByTestId("main-logo")).not.toBeInTheDocument();
expect(screen.queryByTestId("sidebar-toggle")).not.toBeInTheDocument();
});
it("should take you home when clicking the logo", async () => {
const { router } = setup({});
if (!router) {
throw new Error("history should be available from test setup");
}
expect(router.location.pathname).toBe("/question/1");
await userEvent.click(screen.getByTestId("main-logo"));
expect(router.location.pathname).toBe("/");
});
});
describe("small screens", () => {
beforeEach(() => {
matchMediaSpy.mockReturnValue(getMediaQuery({ matches: true }));
});
it("should be able to toggle side nav", async () => {
setup({
embedOptions: {
side_nav: true,
},
});
expect(await screen.findByText(/Our analytics/)).toBeVisible();
await userEvent.click(screen.getByTestId("sidebar-toggle"));
expect(screen.queryByText(/Our analytics/)).not.toBeInTheDocument();
expect(screen.getByTestId("main-logo")).toBeVisible();
});
it("should hide side nav toggle icon", async () => {
setup({
embedOptions: {
side_nav: false,
},
});
expect(await screen.findByText(/Our analytics/)).toBeVisible();
expect(screen.getByTestId("main-logo")).toBeVisible();
expect(screen.queryByTestId("sidebar-toggle")).not.toBeInTheDocument();
});
it("should always show side nav toggle icon when logo is hidden", async () => {
setup({
embedOptions: {
side_nav: true,
logo: false,
},
});
expect(await screen.findByText(/Our analytics/)).toBeVisible();
expect(screen.queryByTestId("main-logo")).not.toBeInTheDocument();
expect(screen.getByTestId("sidebar-toggle")).toBeVisible();
await userEvent.click(screen.getByTestId("sidebar-toggle"));
expect(screen.queryByText(/Our analytics/)).not.toBeInTheDocument();
expect(screen.queryByTestId("main-logo")).not.toBeInTheDocument();
expect(screen.getByTestId("sidebar-toggle")).toBeVisible();
});
it("should not show either logo or side nav toggle button at all", async () => {
setup({
embedOptions: {
side_nav: false,
logo: false,
},
});
expect(await screen.findByText(/Our analytics/)).toBeVisible();
expect(screen.queryByTestId("main-logo")).not.toBeInTheDocument();
expect(screen.queryByTestId("sidebar-toggle")).not.toBeInTheDocument();
});
it("should take you home when clicking the logo", async () => {
const { router } = setup({});
if (!router) {
throw new Error("history should be available from test setup");
}
expect(router.location.pathname).toBe("/question/1");
await userEvent.click(screen.getByTestId("main-logo"));
expect(router.location.pathname).toBe("/");
});
});
describe("breadcrumbs", () => {
it("should work for questions in collections", async () => {
setup({
embedOptions: {
breadcrumbs: true,
},
card: CARD_IN_COLLECTION,
});
expect(await screen.findByText("Foo Collection")).toBeInTheDocument();
});
it("should work for questions in dashboards", async () => {
setup({
embedOptions: {
breadcrumbs: true,
},
card: CARD_IN_DASHBOARD,
});
expect(await screen.findByText("Foo Collection")).toBeInTheDocument();
expect(await screen.findByText("Bar Dashboard")).toBeInTheDocument();
});
it("should use the latest dashboard name for question breadcrumbs (#75184)", async () => {
setup({
embedOptions: {
breadcrumbs: true,
},
card: CARD_IN_RENAMED_DASHBOARD,
});
expect(await screen.findByText("Bar Dashboard")).toBeInTheDocument();
expect(
screen.queryByText("Stale Dashboard Name"),
).not.toBeInTheDocument();
});
it("should not show dashboard breadcrumbs without dashboard context", async () => {
setup({
embedOptions: {
breadcrumbs: true,
},
card: CARD_WITH_DASHBOARD_ID,
});
expect(await screen.findByText("Foo Collection")).toBeInTheDocument();
expect(screen.queryByText("Bar Dashboard")).not.toBeInTheDocument();
});
it("should work for dashboards", async () => {
setup({
initialRoute: `/dashboard/${BAR_DASHBOARD.id}`,
embedOptions: {
breadcrumbs: true,
},
dashboardState: {
dashboard: createMockDashboardState({
dashboardId: BAR_DASHBOARD.id,
dashboards: {
[BAR_DASHBOARD.id]: createMockStoreDashboard({
id: BAR_DASHBOARD.id,
collection_id: FOO_COLLECTION.id,
}),
},
}),
},
});
expect(await screen.findByText("Foo Collection")).toBeInTheDocument();
});
it("should work for collection pages (UXW-249)", async () => {
setup({
initialRoute: `/collection/${FOO_COLLECTION.id}-foo-collection`,
embedOptions: {
breadcrumbs: true,
},
});
expect(await screen.findByText("Foo Collection")).toBeInTheDocument();
});
});
});
});
describe("getSearchResultSelection", () => {
const indexedEntity = createMockSearchResult({
id: 42,
model: "indexed-entity",
model_id: 7,
model_name: "People",
});
it("zooms into the row for an indexed-entity result on the current model", () => {
expect(getSearchResultSelection(indexedEntity, 7)).toEqual({
type: "zoom",
objectId: 42,
});
});
it("navigates for an indexed-entity result on a different model", () => {
expect(getSearchResultSelection(indexedEntity, 9)).toEqual({
type: "navigate",
url: modelToUrl(indexedEntity),
});
});
it("navigates when there is no current card", () => {
expect(getSearchResultSelection(indexedEntity, undefined)).toEqual({
type: "navigate",
url: modelToUrl(indexedEntity),
});
});
it("navigates for a non indexed-entity result even when ids match", () => {
const card = createMockSearchResult({ id: 7, model: "card", model_id: 7 });
expect(getSearchResultSelection(card, 7)).toEqual({
type: "navigate",
url: modelToUrl(card),
});
});
});
function setup({
embedOptions,
card = createMockCard(),
dashboardState,
initialRoute = "/question/1",
}: {
embedOptions?: Partial<InteractiveEmbeddingOptions>;
card?: Card;
dashboardState?: { dashboard: DashboardState };
initialRoute?: string;
}) {
// Need to set the location because CollectionBreadcrumbs uses the useLocation()
window.history.pushState({}, "", initialRoute);
setupUserMetabotPermissionsEndpoint();
setupCollectionsEndpoints({
collections: [FOO_COLLECTION],
});
setupCollectionByIdEndpoint({ collections: [FOO_COLLECTION] });
setupCardEndpoints(card);
setupDashboardEndpoints(BAR_DASHBOARD);
return renderWithProviders(<Route path="*" element={<AppBarContainer />} />, {
withRouter: true,
initialRoute,
storeInitialState: {
app: createMockAppState({ isNavbarOpen: false }),
embed: createMockEmbedState({
options: createMockEmbedOptions({
...DEFAULT_INTERACTIVE_EMBEDDING_OPTIONS,
...embedOptions,
}),
}),
qb: createMockQueryBuilderState({
card,
}),
...dashboardState,
},
});
}
function getMediaQuery(opts?: Partial<MediaQueryList>): MediaQueryList {
return {
media: "",
matches: false,
onchange: jest.fn(),
dispatchEvent: jest.fn(),
addListener: jest.fn(),
addEventListener: jest.fn(),
removeListener: jest.fn(),
removeEventListener: jest.fn(),
...opts,
};
}
const windowSelf = window.self;
function mockEmbedding() {
Object.defineProperty(window, "self", {
value: {},
});
}
function restoreMockEmbedding() {
Object.defineProperty(window, "self", {
value: windowSelf,
});
}

View File

@@ -0,0 +1,71 @@
import { skipToken, useGetDashboardQuery } from "metabase/api";
import {
getCollectionId,
getIsCollectionPathVisible,
} from "metabase/app/selectors";
import { MetabotAppBarButton } from "metabase/metabot/components/MetabotAppBarButton";
import { AppSwitcher } from "metabase/nav/components/AppSwitcher";
import { SearchButton } from "metabase/nav/components/search/SearchButton/SearchButton";
import { CollectionBreadcrumbs } from "metabase/nav/containers/CollectionBreadcrumbs";
import { isQuestionPath } from "metabase/nav/containers/MainNavbar/getSelectedItems";
import { getQuestion } from "metabase/query_builder";
import { useSelector } from "metabase/redux";
import { useLocation } from "metabase/router";
import { getPageFrame } from "metabase/selectors/app";
import { Box, Flex, Group, rem } from "metabase/ui";
import { isWithinIframe } from "metabase/utils/iframe";
/**
* The page's own header, in place of the old app bar: where you are on the left, and the
* app-wide controls — search, Metabot and the account switcher — on the right. It sits above the
* scrolling content rather than spanning the window, so the rail owns the full height of the app.
*/
export function AppPageHeader() {
const location = useLocation();
const isCollectionPathVisible = useSelector((state) =>
getIsCollectionPathVisible(state, { location }),
);
const breadcrumbCollectionId = useSelector(getCollectionId);
const pageFrame = useSelector(getPageFrame);
const question = useSelector(getQuestion);
const dashboardId = isQuestionPath(location.pathname)
? question?.dashboard()?.id
: undefined;
const { data: dashboard } = useGetDashboardQuery(
dashboardId != null ? { id: dashboardId } : skipToken,
);
return (
<Flex
component="header"
align="center"
justify="space-between"
gap="lg"
px={pageFrame?.gutter ?? "xl"}
// Centers the row on the nav rail's Library/Playground switcher text: the
// rail's 8px top padding + the switcher's 8px padding + half its 38px height
// puts that text at 35px, and this row's 32px controls need 19px above them.
pt={rem(19)}
pb="md"
wrap="nowrap"
bg={`background_page-${pageFrame?.background ?? "primary"}`}
data-testid="app-page-header"
>
<Box miw={0} style={{ overflow: "hidden" }}>
{isCollectionPathVisible && (
<CollectionBreadcrumbs
dashboard={dashboardId != null ? dashboard : undefined}
collectionId={breadcrumbCollectionId ?? undefined}
showIcons={false}
/>
)}
</Box>
<Group gap="md" wrap="nowrap" c="text-primary">
<SearchButton />
{!isWithinIframe() && <MetabotAppBarButton />}
<AppSwitcher />
</Group>
</Flex>
);
}

View File

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

View File

@@ -9,18 +9,15 @@ import { connect } from "metabase/redux";
import type { AdminPath, State, StoreDashboard } from "metabase/redux/store";
import { useLocation, useParams } from "metabase/router";
import { getAdminPaths } from "metabase/selectors/admin";
import { getIsNavbarOpen } from "metabase/selectors/app";
import type { User } from "metabase-types/api";
type NavbarProps = {
isOpen: boolean;
user: User | null;
adminPaths: AdminPath[];
dashboard?: StoreDashboard;
};
const mapStateToProps = (state: State) => ({
isOpen: getIsNavbarOpen(state),
user: getUser(state),
adminPaths: getAdminPaths(state),
// Can't use the dashboard entity loader instead.
@@ -29,7 +26,7 @@ const mapStateToProps = (state: State) => ({
dashboard: getDashboard(state),
});
function NavbarInner({ isOpen, user, adminPaths, dashboard }: NavbarProps) {
function NavbarInner({ user, adminPaths, dashboard }: NavbarProps) {
const location = useLocation();
const params = useParams();
useListDatabasesQuery();
@@ -45,12 +42,7 @@ function NavbarInner({ isOpen, user, adminPaths, dashboard }: NavbarProps) {
return isAdminApp ? (
<AdminNavbar path={location.pathname} adminPaths={adminPaths} />
) : (
<MainNavbar
isOpen={isOpen}
location={location}
params={params}
dashboard={dashboard}
/>
<MainNavbar location={location} params={params} dashboard={dashboard} />
);
}

View File

@@ -1,305 +0,0 @@
import type { Store } from "@reduxjs/toolkit";
import fetchMock from "fetch-mock";
import {
setupCollectionItemsEndpoint,
setupCollectionsEndpoints,
setupDatabasesEndpoints,
setupGdriveGetFolderEndpoint,
setupSearchEndpoints,
setupSettingEndpoint,
} from "__support__/server-mocks";
import {
createMockAppState,
createMockEmbedOptions,
createMockEmbedState,
createMockState,
} from "__support__/state";
import {
act,
renderWithProviders,
screen,
waitForLoaderToBeRemoved,
} from "__support__/ui";
import { ROOT_COLLECTION } from "metabase/common/collections/constants";
import {
CLOSE_NAVBAR,
OPEN_NAVBAR,
isNavbarOpenForPathname,
} from "metabase/redux/app";
import type { State } from "metabase/redux/store";
import { Route } from "metabase/router";
import * as iframeUtils from "metabase/utils/iframe";
import type { User } from "metabase-types/api";
import {
createMockCollection,
createMockDatabase,
createMockUser,
} from "metabase-types/api/mocks";
import { Navbar } from "./Navbar";
type SetupOpts = {
isOpen?: boolean;
route?: string;
pathname?: string;
user?: User | null;
embedOptions?: Record<string, string | boolean>;
};
async function setup({
isOpen,
pathname = "/",
route = pathname,
user = createMockUser(),
embedOptions = {},
}: SetupOpts = {}) {
setupCollectionsEndpoints({ collections: [] });
setupDatabasesEndpoints([createMockDatabase()]);
setupSearchEndpoints([]);
setupCollectionItemsEndpoint({
collection: createMockCollection(ROOT_COLLECTION),
collectionItems: [],
});
setupGdriveGetFolderEndpoint({ status: "active" });
fetchMock.get("path:/api/bookmark", []);
setupSettingEndpoint({
settingKey: "version-info",
settingValue: {},
});
const storeInitialState = createMockState({
app: createMockAppState({
isNavbarOpen: isOpen ?? isNavbarOpenForPathname(pathname, true),
}),
embed: createMockEmbedState({
options: createMockEmbedOptions(embedOptions),
}),
currentUser: user,
});
const { store } = renderWithProviders(
<Route path={route} element={<Navbar />} />,
{
storeInitialState,
initialRoute: pathname,
withRouter: true,
withDND: true,
},
);
// manually dispatch the location event that the router history sync would otherwise dispatch for us
dispatchLocationChange({ store, initialRoute: true, pathname });
await waitForLoaderToBeRemoved();
return store;
}
describe("nav > containers > Navbar > Core App", () => {
it("should be open when isOpen is true", async () => {
await setup({ isOpen: true });
await expectNavbarOpen();
});
it("should be hidden when isOpen is false", async () => {
await setup({ isOpen: false });
await expectNavbarClosed();
});
it("should not render when signed out", async () => {
await setup({ user: null });
expect(screen.queryByTestId("main-navbar-root")).not.toBeInTheDocument();
});
it("should not render when in the admin app", async () => {
await setup({ pathname: "/admin/" });
expect(screen.queryByTestId("main-navbar-root")).not.toBeInTheDocument();
});
["question/1", "model/1", "dashboard/1"].forEach((pathname) => {
it(`should be hidden on initial load for a ${pathname}`, async () => {
await setup({ pathname: `/${pathname}` });
await expectNavbarClosed();
});
});
it("should hide when visiting a question", async () => {
const store = await setup({ pathname: "/" });
await expectNavbarOpen();
dispatchLocationChange({ store, pathname: "/question/1" });
await expectNavbarClosed();
});
it("should stay open when navigating to the database reference questions page (metabase#72001)", async () => {
const store = await setup({ pathname: "/reference/databases/1" });
await expectNavbarOpen();
dispatchLocationChange({
store,
pathname: "/reference/databases/1/tables/2/questions",
});
await expectNavbarOpen();
});
it("should hide when visiting a question and stay hidden when returning to collection", async () => {
const store = await setup({ pathname: "/collection/1" });
await expectNavbarOpen();
dispatchLocationChange({ store, pathname: "/question/1" });
await expectNavbarClosed();
dispatchLocationChange({ store, pathname: "/collection/1" });
await expectNavbarClosed();
});
it("should not close navbar when preserveNavbarState is set", async () => {
const store = await setup({ isOpen: true });
await expectNavbarOpen();
// act(...) flushes the connected Navbar re-render caused by the dispatch
act(() => {
store.dispatch({
type: "@@router/LOCATION_CHANGE",
payload: {
pathname: "/question/1",
state: { preserveNavbarState: true },
},
});
});
await expectNavbarOpen();
});
it("should preserve state when navigating collections", async () => {
const store = await setup({ pathname: "/collection/1" });
await expectNavbarOpen();
dispatchLocationChange({ store, pathname: "/collection/2" });
await expectNavbarOpen();
dispatchLocationChange({ store, pathname: "/question/1" });
await expectNavbarClosed();
dispatchLocationChange({ store, pathname: "/collection/3" });
await expectNavbarClosed();
dispatchLocationChange({ store, pathname: "/collection/4" });
await expectNavbarClosed();
// act(...) flushes the connected Navbar re-render caused by the dispatch
act(() => {
store.dispatch({ type: OPEN_NAVBAR });
});
await expectNavbarOpen();
dispatchLocationChange({ store, pathname: "/collection/5" });
await expectNavbarOpen();
act(() => {
store.dispatch({ type: CLOSE_NAVBAR });
});
dispatchLocationChange({ store, pathname: "/collection/6" });
await expectNavbarClosed();
});
describe("embedded", () => {
let isWithinIframeSpy: jest.SpyInstance;
beforeAll(() => {
isWithinIframeSpy = jest.spyOn(iframeUtils, "isWithinIframe");
isWithinIframeSpy.mockReturnValue(true);
});
afterAll(() => {
isWithinIframeSpy.mockRestore();
});
const normallyHiddenRoutes = ["/model/1", "/dashboard/1", "/question/1"];
const normallyVisibleRoutes = ["/"];
const allRoutes = [...normallyHiddenRoutes, ...normallyVisibleRoutes];
allRoutes.forEach((route) => {
it(`should be visible when embedded and on ${route} top_nav=false&side_nav=true`, async () => {
await setup({
pathname: route,
isOpen: false, // this should be ignored and overridden by the embedding params
embedOptions: { top_nav: false, side_nav: true },
});
await expectNavbarOpen();
});
});
normallyVisibleRoutes.forEach((route) => {
it(`should be visible when embedded and on ${route} top_nav=true&side_nav=true`, async () => {
await setup({
pathname: route,
isOpen: true,
embedOptions: { top_nav: true, side_nav: true },
});
await expectNavbarOpen();
});
});
normallyHiddenRoutes.forEach((route) => {
it(`should not be visible when embedded and on ${route} top_nav=true&side_nav=true`, async () => {
await setup({
pathname: route,
isOpen: false,
embedOptions: { top_nav: true, side_nav: true },
});
await expectNavbarClosed();
});
});
normallyHiddenRoutes.forEach((route) => {
it(`should not be visible when embedded and on ${route} top_nav=true&side_nav=true`, async () => {
await setup({
pathname: route,
isOpen: false,
embedOptions: { top_nav: true, side_nav: true },
});
await expectNavbarClosed();
});
});
// the current state of AppComponent.tsx is such that this should never even happen because we don't even render the parent component
// but this test will cover any future changes in the component tree
allRoutes.forEach((route) => {
it(`should not be visible when embedded and on ${route} with side_nav=false`, async () => {
await setup({
pathname: route,
isOpen: false,
embedOptions: { side_nav: false },
});
await expectNavbarClosed();
});
});
});
});
async function expectNavbarOpen() {
const navbar = await screen.findByTestId("main-navbar-root");
expect(navbar).toBeVisible();
expect(navbar).toHaveAttribute("aria-hidden", "false");
}
async function expectNavbarClosed() {
const navbar = await screen.findByTestId("main-navbar-root");
expect(navbar).not.toBeVisible();
expect(navbar).toHaveAttribute("aria-hidden", "true");
}
interface DispatchLocationChangeParams {
store: Store<State>;
initialRoute?: boolean;
pathname: string;
}
function dispatchLocationChange({
store,
initialRoute = false,
pathname,
}: DispatchLocationChangeParams) {
// The dispatch synchronously re-renders the connected Navbar; wrap it in
// act(...) so React's state updates are flushed within the test instead
// of leaking past it.
act(() => {
store.dispatch({
type: "@@router/LOCATION_CHANGE",
payload: {
pathname,
action: initialRoute ? "POP" : "PUSH",
},
});
});
}

View File

@@ -1,37 +0,0 @@
import { t } from "ttag";
import { Link } from "metabase/common/components/Link/Link";
import CS from "metabase/css/core/index.css";
import { Ellipsified } from "metabase/ui";
import * as Urls from "metabase/urls";
import type Question from "metabase-lib/v1/Question";
export interface QuestionLineageProps {
question?: Question;
originalQuestion?: Question;
}
export const QuestionLineage = ({
question,
originalQuestion,
}: QuestionLineageProps): JSX.Element | null => {
if (!question || !originalQuestion) {
return null;
}
return (
<Ellipsified
c="text-secondary"
fw="bold"
lh="normal"
miw={0}
fz="0.875em"
showTooltip={false}
>
{t`Started from`}{" "}
<Link className={CS.link} to={Urls.question(originalQuestion)}>
{originalQuestion.displayName()}
</Link>
</Ellipsified>
);
};

View File

@@ -1 +0,0 @@
export * from "./QuestionLineage";

View File

@@ -9,9 +9,9 @@ import {
import { getCurrentDocument } from "metabase/documents/selectors";
import { getEmbedOptions } from "metabase/embedding/interactive-embedding";
import { getCurrentExploration } from "metabase/explorations/selectors";
import { getIsSavedQuestionChanged, getQuestion } from "metabase/query_builder";
import { getQuestion } from "metabase/query_builder";
import type { State } from "metabase/redux/store";
import { type RouterProps, getDetailViewState } from "metabase/selectors/app";
import { type RouterProps, getPageCollection } from "metabase/selectors/app";
import * as Urls from "metabase/urls";
import { selectIsWithinIframe } from "metabase/utils/iframe";
@@ -41,36 +41,6 @@ export const getIsDataApp = createSelector([getRouterPath], (path) => {
return path.startsWith(`${Urls.DATA_APP_ROOT_URL}/`);
});
export const getIsMetricsViewer = createSelector([getRouterPath], (path) => {
return path.startsWith("/explore");
});
export const getIsLogoVisible = createSelector(
[selectIsWithinIframe, getEmbedOptions],
(isEmbeddingIframe, embedOptions) => {
return !isEmbeddingIframe || embedOptions.logo;
},
);
export const getIsSearchVisible = createSelector(
[selectIsWithinIframe, getEmbedOptions],
(isEmbeddingIframe, embedOptions) => {
return !isEmbeddingIframe || embedOptions.search;
},
);
export const getIsNewButtonVisible = createSelector(
[selectIsWithinIframe, getEmbedOptions],
(isEmbeddingIframe, embedOptions) => {
return !isEmbeddingIframe || embedOptions.new_button;
},
);
export const getIsAppSwitcherVisible = createSelector(
[selectIsWithinIframe],
(isEmbeddingIframe) => !isEmbeddingIframe,
);
const PATHS_WITHOUT_NAVBAR = [
/^\/setup/,
/^\/auth/,
@@ -99,13 +69,13 @@ const PATHS_WITH_COLLECTION_BREADCRUMBS = [
// Paths where collection identity comes from the URL itself, so breadcrumbs
// can render without needing a question/dashboard/document in redux state.
const STANDALONE_COLLECTION_BREADCRUMB_PATHS = [/\/collection\//];
const PATHS_WITH_QUESTION_LINEAGE = [/\/question/, /\/model/];
export const getIsCollectionPathVisible = createSelector(
[
getQuestion,
getDashboard,
getCurrentDocument,
getPageCollection,
getRouterPath,
selectIsWithinIframe,
getEmbedOptions,
@@ -115,6 +85,7 @@ export const getIsCollectionPathVisible = createSelector(
question,
dashboard,
document,
pageCollection,
path,
isEmbedded,
embedOptions,
@@ -124,8 +95,8 @@ export const getIsCollectionPathVisible = createSelector(
return false;
}
const isModelDetail = /\/model\/.*\/detail\/.*/.test(path);
if (isModelDetail) {
// A page that names its own collection gets breadcrumbs wherever it lives.
if (pageCollection) {
return true;
}
@@ -147,13 +118,6 @@ export const getIsCollectionPathVisible = createSelector(
},
);
export const getIsQuestionLineageVisible = createSelector(
[getIsSavedQuestionChanged, getRouterPath],
(isSavedQuestionChanged, path) =>
isSavedQuestionChanged &&
PATHS_WITH_QUESTION_LINEAGE.some((pattern) => pattern.test(path)),
);
export const getIsNavBarEnabled = createSelector(
[
getUser,
@@ -174,31 +138,16 @@ export const getIsNavBarEnabled = createSelector(
},
);
const getIsEmbeddedAppBarVisible = createSelector(
[
getEmbedOptions,
getIsQuestionLineageVisible,
getIsCollectionPathVisible,
getIsNavBarEnabled,
],
(
embedOptions,
isQuestionLineageVisible,
isCollectionPathVisible,
isNavBarEnabled,
) => {
const anyEmbeddedAppBarElementVisible =
isNavBarEnabled ||
embedOptions.search ||
embedOptions.new_button ||
embedOptions.logo ||
isQuestionLineageVisible ||
isCollectionPathVisible;
return embedOptions.top_nav && anyEmbeddedAppBarElementVisible;
const getIsEmbeddedPageHeaderVisible = createSelector(
[getEmbedOptions, getIsCollectionPathVisible, getIsNavBarEnabled],
(embedOptions, isCollectionPathVisible, isNavBarEnabled) => {
const anyEmbeddedHeaderElementVisible =
isNavBarEnabled || embedOptions.search || isCollectionPathVisible;
return embedOptions.top_nav && anyEmbeddedHeaderElementVisible;
},
);
export const getIsAppBarVisible = createSelector(
export const getIsPageHeaderVisible = createSelector(
[
getUser,
getRouterPath,
@@ -208,7 +157,7 @@ export const getIsAppBarVisible = createSelector(
getIsMonitorApp,
getIsEditingDashboard,
selectIsWithinIframe,
getIsEmbeddedAppBarVisible,
getIsEmbeddedPageHeaderVisible,
],
(
currentUser,
@@ -219,13 +168,13 @@ export const getIsAppBarVisible = createSelector(
isMonitorApp,
isEditingDashboard,
isEmbedded,
isEmbeddedAppBarVisible,
isEmbeddedPageHeaderVisible,
) => {
const isFullscreen = hash.includes("fullscreen");
if (
!currentUser ||
(isEmbedded && !isEmbeddedAppBarVisible) ||
(isEmbedded && !isEmbeddedPageHeaderVisible) ||
isAdminApp ||
isDataStudioApp ||
isMonitorApp ||
@@ -244,7 +193,7 @@ export const getCollectionId = createSelector(
getDashboard,
getDashboardId,
getCurrentDocument,
getDetailViewState,
getPageCollection,
getRouterPath,
getCurrentExploration,
],
@@ -253,12 +202,12 @@ export const getCollectionId = createSelector(
dashboard,
dashboardId,
document,
detailView,
pageCollection,
path,
exploration,
) => {
if (detailView) {
return detailView.collectionId;
if (pageCollection) {
return pageCollection.collectionId;
}
if (document) {

View File

@@ -4,9 +4,9 @@ import type { RouterProps } from "metabase/selectors/app";
import { createMockUser } from "metabase-types/api/mocks";
import {
getIsAppBarVisible,
getIsCollectionPathVisible,
getIsNavBarEnabled,
getIsPageHeaderVisible,
} from "./selectors";
const createLocation = (pathname: string): Location =>
@@ -48,11 +48,11 @@ describe("getIsCollectionPathVisible", () => {
});
});
describe("NavBar / AppBar visibility", () => {
describe("NavBar / page header visibility", () => {
const stateWithUser = () =>
createMockState({ currentUser: createMockUser() });
it("hides the navbar and app bar within Monitor, Data Studio", () => {
it("hides the navbar and page header within Monitor, Data Studio", () => {
const state = stateWithUser();
expect(getIsNavBarEnabled(state, createRouterProps("/monitor"))).toBe(
@@ -62,22 +62,22 @@ describe("NavBar / AppBar visibility", () => {
false,
);
expect(getIsAppBarVisible(state, createRouterProps("/monitor"))).toBe(
false,
);
expect(getIsAppBarVisible(state, createRouterProps("/data-studio"))).toBe(
expect(getIsPageHeaderVisible(state, createRouterProps("/monitor"))).toBe(
false,
);
expect(
getIsPageHeaderVisible(state, createRouterProps("/data-studio")),
).toBe(false);
});
it("keeps the navbar and app bar on a regular page", () => {
it("keeps the navbar and page header on a regular page", () => {
const state = stateWithUser();
expect(
getIsNavBarEnabled(state, createRouterProps("/browse/databases")),
).toBe(true);
expect(
getIsAppBarVisible(state, createRouterProps("/browse/databases")),
getIsPageHeaderVisible(state, createRouterProps("/browse/databases")),
).toBe(true);
});
});

View File

@@ -0,0 +1,5 @@
/* Applied after `.browseHeader`, whose `3rem` of bottom padding is too much now that the header
sits directly above a bordered panel. `/browse/databases` still uses the original. */
.header {
padding-bottom: var(--mantine-spacing-lg);
}

View File

@@ -0,0 +1,89 @@
import cx from "classnames";
import type { ReactNode } from "react";
import { Flex, Group, Icon, Stack, Text, Title } from "metabase/ui";
import type { IconName } from "metabase-types/api";
import S from "./BrowseContainer.module.css";
import Layout from "./BrowsePageLayout.module.css";
export type BrowsePageLayoutProps = {
icon: IconName;
title: string;
titleId: string;
/** Dot-separated facts under the title. Falsy entries are dropped. */
meta?: (string | false | null | undefined)[];
/** One explanatory paragraph under the meta row. */
description?: string;
/** Right-aligned header controls. */
actions?: ReactNode;
testId: string;
children: ReactNode;
};
export function BrowsePageLayout({
icon,
title,
titleId,
meta,
description,
actions,
testId,
children,
}: BrowsePageLayoutProps) {
const facts = meta?.filter((fact): fact is string => Boolean(fact)) ?? [];
return (
<Flex
className={S.browseContainer}
flex={1}
direction="column"
wrap="nowrap"
pt="lg"
aria-labelledby={titleId}
>
<Flex
className={cx(S.browseHeader, Layout.header)}
direction="column"
role="heading"
data-testid={testId}
>
<Stack maw="64rem" mx="auto" w="100%" gap="sm">
<Flex w="100%" mih="2.25rem" justify="space-between" align="center">
<Title order={1} c="text-primary" id={titleId}>
<Group gap="sm">
<Icon size={24} c="icon-brand" name={icon} />
{title}
</Group>
</Title>
<Group gap="xxs">{actions}</Group>
</Flex>
{facts.length > 0 && (
<Group gap="xs">
{facts.map((fact, index) => (
<Group gap="xs" key={fact}>
{index > 0 && <Text c="text-tertiary">·</Text>}
<Text size="sm" c="text-secondary">
{fact}
</Text>
</Group>
))}
</Group>
)}
{description && (
<Text size="sm" c="text-secondary" maw="48rem">
{description}
</Text>
)}
</Stack>
</Flex>
<Flex className={S.browseMain} direction="column" wrap="nowrap" flex={1}>
<Flex maw="64rem" mx="auto" w="100%">
<Stack mb="xl" gap="lg" w="100%">
{children}
</Stack>
</Flex>
</Flex>
</Flex>
);
}

View File

@@ -4,6 +4,7 @@ import _ from "underscore";
import NoResults from "assets/img/metrics_bot.svg";
import { skipToken } from "metabase/api";
import { DetailPanel } from "metabase/common/components/DetailPanel";
import { EmptyState } from "metabase/common/components/EmptyState";
import { ExternalLink } from "metabase/common/components/ExternalLink";
import { ForwardRefLink, Link } from "metabase/common/components/Link";
@@ -11,6 +12,7 @@ import { DelayedLoadingAndErrorWrapper } from "metabase/common/components/Loadin
import { trackMetricCreateStarted } from "metabase/common/data-studio/analytics";
import { useDocsUrl } from "metabase/common/hooks";
import { canUserCreateQueries } from "metabase/current-user";
import { useNavSection } from "metabase/nav/containers/MainNavbar/use-nav-section";
import { PLUGIN_CONTENT_VERIFICATION, PLUGIN_LIBRARY } from "metabase/plugins";
import { useSelector } from "metabase/redux";
import {
@@ -18,17 +20,15 @@ import {
Box,
Button,
Flex,
Group,
Icon,
Stack,
Text,
Title,
Tooltip,
} from "metabase/ui";
import * as Urls from "metabase/urls";
import { isWithinIframe } from "metabase/utils/iframe";
import S from "../components/BrowseContainer.module.css";
import { BrowsePageLayout } from "../components/BrowsePageLayout";
import { partitionByAuthority } from "../utils";
import { MetricsTable } from "./MetricsTable";
import { trackNewMetricInitiated } from "./analytics";
@@ -49,6 +49,14 @@ export function BrowseMetrics() {
const isEmpty = !isLoading && !error && !metrics?.length;
const titleId = useMemo(() => _.uniqueId("browse-metrics"), []);
const { section, setSection } = useNavSection();
const { official, unofficial } = useMemo(
() => partitionByAuthority(metrics ?? []),
[metrics],
);
const isOfficial = section === "official";
const shown = isOfficial ? official : unofficial;
const { data: libraryMetricCollection } =
PLUGIN_LIBRARY.useGetLibraryChildCollectionByType({
type: "library-metrics",
@@ -64,84 +72,80 @@ export function BrowseMetrics() {
const canCreateMetric = !isEmbeddingIframe && hasDataAccess;
return (
<Flex
className={S.browseContainer}
flex={1}
direction="column"
wrap="nowrap"
pt="lg"
aria-labelledby={titleId}
>
<Flex
className={S.browseHeader}
direction="column"
role="heading"
data-testid="browse-metrics-header"
>
<Flex maw="64rem" mx="auto" w="100%">
<Flex
w="100%"
h="2.25rem"
direction="row"
justify="space-between"
align="center"
<BrowsePageLayout
icon="metric"
title={t`Metrics`}
titleId={titleId}
testId="browse-metrics-header"
meta={[
t`Showing ${shown.length} of ${official.length + unofficial.length} metrics`,
isOfficial ? t`Official only` : t`Unofficial only`,
]}
description={t`The numbers your team has agreed on. Official metrics live in the Library or in a collection marked official; the rest are in the Playground.`}
actions={
<>
<Button
variant="subtle"
size="compact-sm"
onClick={() => setSection(isOfficial ? "unofficial" : "official")}
>
<Title order={1} c="text-primary" id={titleId}>
<Group gap="sm">
<Icon size={24} c="icon-brand" name="metric" />
{t`Metrics`}
</Group>
</Title>
<Group gap="xxs">
{canCreateMetric && (
<Tooltip label={t`Create a new metric`} position="bottom">
<ActionIcon
aria-label={t`Create a new metric`}
size={32}
variant="viewHeader"
component={ForwardRefLink}
to={newMetricLink}
onClick={() => {
trackNewMetricInitiated();
trackMetricCreateStarted("browse_metrics");
}}
>
<Icon name="add" />
</ActionIcon>
</Tooltip>
)}
{hasVerifiedMetrics && (
<MetricFilterControls
metricFilters={metricFilters}
setMetricFilters={setMetricFilters}
/>
)}
</Group>
</Flex>
</Flex>
</Flex>
<Flex className={S.browseMain} direction="column" wrap="nowrap" flex={1}>
<Flex maw="64rem" mx="auto" w="100%">
<Stack mb="xl" gap="lg" w="100%">
{isEmpty ? (
<MetricsEmptyState
canCreateMetric={canCreateMetric}
newMetricLink={newMetricLink}
/>
) : (
<DelayedLoadingAndErrorWrapper
error={error}
loading={isLoading}
style={{ flex: 1 }}
loader={<MetricsTable skeleton />}
{isOfficial ? t`Show unofficial` : t`Show official`}
</Button>
{canCreateMetric && (
<Tooltip label={t`Create a new metric`} position="bottom">
<ActionIcon
aria-label={t`Create a new metric`}
size={32}
variant="viewHeader"
component={ForwardRefLink}
to={newMetricLink}
onClick={() => {
trackNewMetricInitiated();
trackMetricCreateStarted("browse_metrics");
}}
>
<MetricsTable metrics={metrics} />
</DelayedLoadingAndErrorWrapper>
<Icon name="add" />
</ActionIcon>
</Tooltip>
)}
{hasVerifiedMetrics && (
<MetricFilterControls
metricFilters={metricFilters}
setMetricFilters={setMetricFilters}
/>
)}
</>
}
>
{isEmpty ? (
<MetricsEmptyState
canCreateMetric={canCreateMetric}
newMetricLink={newMetricLink}
/>
) : (
<DetailPanel
flush
title={isOfficial ? t`Official metrics` : t`Unofficial metrics`}
>
<DelayedLoadingAndErrorWrapper
error={error}
loading={isLoading}
style={{ flex: 1 }}
loader={<MetricsTable skeleton />}
>
{shown.length === 0 ? (
<Text p="lg" c="text-secondary">
{isOfficial
? t`No official metrics yet. Publish a metric to the Library, or mark its collection official.`
: t`Every metric is official.`}
</Text>
) : (
<MetricsTable metrics={shown} />
)}
</Stack>
</Flex>
</Flex>
</Flex>
</DelayedLoadingAndErrorWrapper>
</DetailPanel>
)}
</BrowsePageLayout>
);
}

View File

@@ -22,6 +22,7 @@ import { Columns } from "metabase/common/components/ItemsTable/Columns";
import type { ResponsiveProps } from "metabase/common/components/ItemsTable/utils";
import { Link } from "metabase/common/components/Link";
import { MarkdownPreview } from "metabase/common/components/MarkdownPreview";
import { UserAvatar } from "metabase/common/components/UserAvatar";
import CS from "metabase/css/core/index.css";
import { useNavigate } from "metabase/router";
import {
@@ -32,6 +33,7 @@ import {
Menu,
Repeat,
Skeleton,
Text,
} from "metabase/ui";
import * as Urls from "metabase/urls";
import type { IconName, SortingOptions } from "metabase-types/api";
@@ -72,11 +74,17 @@ const collectionProps: ResponsiveProps = {
hideAtContainerBreakpoint: "sm",
};
const ownerProps: ResponsiveProps = {
...sharedProps,
hideAtContainerBreakpoint: "md",
};
const menuProps = {
...sharedProps,
};
const DOTMENU_WIDTH = 34;
const OWNER_WIDTH = 180;
export function MetricsTable({
skeleton = false,
@@ -89,7 +97,7 @@ export function MetricsTable({
const handleSortingOptionsChange = skeleton ? undefined : setSortingOptions;
/** The name column has an explicitly set width. The remaining columns divide the remaining width. This is the percentage allocated to the collection column */
const collectionWidth = 30;
const collectionWidth = 26;
const descriptionWidth = 100 - collectionWidth;
return (
@@ -99,6 +107,7 @@ export function MetricsTable({
<col className={BrowseTableS.nameColumn} />
<TableColumn {...collectionProps} width={`${collectionWidth}%`} />
<TableColumn {...descriptionProps} width={`${descriptionWidth}%`} />
<TableColumn {...ownerProps} width={OWNER_WIDTH} />
<TableColumn {...menuProps} width={DOTMENU_WIDTH} />
<Columns.RightEdge.Col />
</colgroup>
@@ -142,6 +151,10 @@ export function MetricsTable({
>
{t`Description`}
</SortableColumnHeader>
{/* Not sortable: `SortColumn` only covers name/collection/description. */}
<ColumnHeader {...ownerProps} style={{ paddingInline: ".5rem" }}>
{t`Owner`}
</ColumnHeader>
<ColumnHeader
style={{
paddingInline: ".5rem",
@@ -208,6 +221,7 @@ function MetricRow({ metric }: { metric?: MetricResult }) {
<NameCell metric={metric} />
<CollectionCell metric={metric} />
<DescriptionCell metric={metric} />
<OwnerCell metric={metric} />
<MenuCell metric={metric} />
<Columns.RightEdge.Cell />
</tr>
@@ -310,6 +324,27 @@ function DescriptionCell({ metric }: { metric?: MetricResult }) {
);
}
function OwnerCell({ metric }: { metric?: MetricResult }) {
const name = metric
? (metric.last_editor_common_name ?? metric.creator_common_name)
: null;
return (
<td className={cx(BrowseTableS.cell, BrowseTableS.hideAtMd)}>
{!metric ? (
<SkeletonText />
) : name ? (
<Flex align="center" gap="sm">
<UserAvatar user={{ common_name: name }} size="1.25rem" decorative />
<Text size="sm" truncate>
{name}
</Text>
</Flex>
) : null}
</td>
);
}
type MetricAction = {
key: string;
title: string;

View File

@@ -1,9 +1,7 @@
import { t } from "ttag";
import { getCollectionPathAsString } from "metabase/common/collections/utils";
import { formatValue } from "metabase/value-formatting";
import { isDate } from "metabase-lib/v1/types/utils/isa";
import type { Dataset, SortingOptions } from "metabase-types/api";
import type { SortingOptions } from "metabase-types/api";
import type { MetricResult, SortColumn } from "./types";
@@ -53,99 +51,3 @@ export function sortMetrics(
return sort_direction === "asc" ? result : -result;
});
}
export function isDatasetScalar(dataset: Dataset) {
if (dataset.error) {
return false;
}
return dataset.data.cols.length === 1 && dataset.data.rows.length === 1;
}
export function isDatasetTemporalMetric(dataset: Dataset) {
if (dataset.error) {
return false;
}
const cols = dataset.data.cols;
if (cols.length !== 2) {
return false;
}
const col = cols[0];
// TODO(romeovs): isDate should not be used but there is not easy alternative for now
if (!isDate(col)) {
return false;
}
return true;
}
export function getDatasetValueForMetric(dataset: Dataset) {
if (isDatasetScalar(dataset)) {
return getMetricValueForScalarMetric(dataset);
}
if (isDatasetTemporalMetric(dataset)) {
return getMetricValueForTemporalMetric(dataset);
}
return null;
}
export function getMetricValueForScalarMetric(dataset: Dataset) {
const { cols, rows } = dataset.data;
const lastRow = rows?.at(-1) ?? [];
const [value] = lastRow;
const [valueColumn] = cols;
if (value === undefined) {
return null;
}
return {
label: t`Overall`,
value: formatValue(value, {
jsx: true,
rich: true,
column: valueColumn,
}),
};
}
export function getMetricValueForTemporalMetric(dataset: Dataset) {
// This returns the last row of the dataset, which usually represents the latest
// value of a metric with a temporal breakout.
// However, if the metric has values in the future, or a sort clause that changes
// the order of the rows, this will not return the latest value per se.
const { cols, rows } = dataset.data;
if (!rows || rows.length < 1) {
return null;
}
const lastRow = rows?.at(-1);
if (!lastRow) {
return null;
}
const [label, value] = lastRow;
const [labelColumn, valueColumn] = cols;
if (value === undefined) {
return null;
}
return {
label: formatValue(label, {
jsx: true,
rich: true,
column: labelColumn,
}),
value: formatValue(value, {
jsx: true,
rich: true,
column: valueColumn,
}),
};
}

View File

@@ -1,17 +1,8 @@
import {
createMockCollection,
createMockColumn,
createMockDataset,
createMockDatasetData,
} from "metabase-types/api/mocks";
import { createMockCollection } from "metabase-types/api/mocks";
import { createMockMetricResult } from "./test-utils";
import type { MetricResult } from "./types";
import {
getDatasetValueForMetric,
isDatasetScalar,
sortMetrics,
} from "./utils";
import { sortMetrics } from "./utils";
describe("sortMetrics", () => {
let id = 0;
@@ -133,153 +124,3 @@ describe("sortMetrics", () => {
});
});
});
describe("isDatasetScalar", () => {
it("should return true for a dataset with a single column and a single row", () => {
const dataset = createMockDataset({
data: createMockDatasetData({
cols: [createMockColumn({ name: "col1" })],
rows: [[1]],
}),
});
expect(isDatasetScalar(dataset)).toBe(true);
});
it("should return false for a dataset with more than one column", () => {
const dataset = createMockDataset({
data: createMockDatasetData({
cols: [
createMockColumn({ name: "col1" }),
createMockColumn({ name: "col2" }),
],
rows: [[1, 2]],
}),
});
expect(isDatasetScalar(dataset)).toBe(false);
});
it("should return false for a dataset with more than one row", () => {
const dataset = createMockDataset({
data: createMockDatasetData({
cols: [createMockColumn({ name: "col1" })],
rows: [[1], [2]],
}),
});
expect(isDatasetScalar(dataset)).toBe(false);
});
it("should return false for a dataset with errors", () => {
const dataset = createMockDataset({
error: "error",
data: createMockDatasetData({
cols: [createMockColumn({ name: "col1" })],
rows: [[1]],
}),
});
expect(isDatasetScalar(dataset)).toBe(false);
});
});
describe("getDatasetValueForMetric", () => {
describe("scalar metric", () => {
it("should return null if the dataset is not scalar", () => {
const dataset = createMockDataset({
data: createMockDatasetData({
cols: [createMockColumn({ name: "col1" })],
rows: [[1], [2]],
}),
});
expect(getDatasetValueForMetric(dataset)).toBe(null);
});
it("should return null for a scalar dataset with errors", () => {
const dataset = createMockDataset({
error: "error",
data: createMockDatasetData({
cols: [createMockColumn({ name: "col1" })],
rows: [[1]],
}),
});
expect(getDatasetValueForMetric(dataset)).toBe(null);
});
it("should return the value if the dataset is scalar", () => {
const value = 42;
const column = createMockColumn({ name: "col1" });
const dataset = createMockDataset({
data: createMockDatasetData({
cols: [column],
rows: [[value]],
}),
});
expect(getDatasetValueForMetric(dataset)).toEqual({
value: "42",
label: "Overall",
});
});
it("should return null for a scalar dataset with no value", () => {
const dataset = createMockDataset({
data: createMockDatasetData({
cols: [createMockColumn({ name: "col2" })],
rows: [],
}),
});
expect(getDatasetValueForMetric(dataset)).toEqual(null);
});
});
describe("temporal metric", () => {
it("should return null for a timeseries dataset with errors", () => {
const dataset = createMockDataset({
error: "error",
data: createMockDatasetData({
cols: [
createMockColumn({ name: "col1", base_type: "type/DateTime" }),
createMockColumn({ name: "col2" }),
],
rows: [["2024-01-01T00:00:00.000Z", 1]],
}),
});
expect(getDatasetValueForMetric(dataset)).toBe(null);
});
it("should return the last row value a timeseries dataset", () => {
const dataset = createMockDataset({
data: createMockDatasetData({
cols: [
createMockColumn({ name: "col1", base_type: "type/DateTime" }),
createMockColumn({ name: "col2" }),
],
rows: [["2024-01-01T00:00:00.000Z", 1]],
}),
});
expect(getDatasetValueForMetric(dataset)).toEqual({
value: "1",
label: "January 1, 2024, 12:00 AM",
});
});
it("should return null for a temporal dataset with no value", () => {
const dataset = createMockDataset({
data: createMockDatasetData({
cols: [
createMockColumn({ name: "col1", base_type: "type/DateTime" }),
createMockColumn({ name: "col2" }),
],
rows: [],
}),
});
expect(getDatasetValueForMetric(dataset)).toEqual(null);
});
});
});

View File

@@ -3,6 +3,7 @@ import { t } from "ttag";
import _ from "underscore";
import { skipToken, useListRecentsQuery } from "metabase/api";
import { DetailPanel } from "metabase/common/components/DetailPanel";
import { ExternalLink } from "metabase/common/components/ExternalLink";
import { ForwardRefLink } from "metabase/common/components/Link";
import { DelayedLoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper";
@@ -11,6 +12,7 @@ import {
canUserCreateNativeQueries,
canUserCreateQueries,
} from "metabase/current-user";
import { useNavSection } from "metabase/nav/containers/MainNavbar/use-nav-section";
import {
PLUGIN_COLLECTIONS,
PLUGIN_CONTENT_VERIFICATION,
@@ -20,8 +22,6 @@ import {
ActionIcon,
Box,
Button,
Flex,
Group,
Icon,
Stack,
Text,
@@ -30,7 +30,8 @@ import {
} from "metabase/ui";
import { isWithinIframe } from "metabase/utils/iframe";
import S from "../components/BrowseContainer.module.css";
import { BrowsePageLayout } from "../components/BrowsePageLayout";
import { partitionByAuthority } from "../utils";
import { ModelsVideo } from "./EmptyStates";
import { ModelExplanationBanner } from "./ModelExplanationBanner";
@@ -57,6 +58,14 @@ export const BrowseModels = () => {
const isEmpty = !isLoading && !error && models.length === 0;
const titleId = useMemo(() => _.uniqueId("browse-models"), []);
const { section, setSection } = useNavSection();
const { official, unofficial } = useMemo(
() => partitionByAuthority(models),
[models],
);
const isOfficial = section === "official";
const shown = isOfficial ? official : unofficial;
const hasDataAccess = useSelector(canUserCreateQueries);
const hasNativeWrite = useSelector(canUserCreateNativeQueries);
const isEmbeddingIframe = isWithinIframe();
@@ -65,112 +74,108 @@ export const BrowseModels = () => {
!isEmbeddingIframe && hasDataAccess && hasNativeWrite;
return (
<Flex
className={S.browseContainer}
flex={1}
direction="column"
wrap="nowrap"
pt="lg"
aria-labelledby={titleId}
>
<Flex
className={S.browseHeader}
direction="column"
role="heading"
data-testid="browse-models-header"
>
<Flex maw="64rem" mx="auto" w="100%">
<Flex
w="100%"
h="2.25rem"
direction="row"
justify="space-between"
align="center"
<BrowsePageLayout
icon="model"
title={t`Models`}
titleId={titleId}
testId="browse-models-header"
meta={[
t`Showing ${shown.length} of ${official.length + unofficial.length} models`,
isOfficial ? t`Official only` : t`Unofficial only`,
]}
description={t`Cleaned-up, combined tables ready to query. Official models live in the Library or in a collection marked official; the rest are in the Playground.`}
actions={
<>
<Button
variant="subtle"
size="compact-sm"
onClick={() => setSection(isOfficial ? "unofficial" : "official")}
>
<Title order={1} c="text-primary" id={titleId}>
<Group gap="sm">
<Icon size={24} c="icon-brand" name="model" />
{t`Models`}
</Group>
</Title>
<Group gap="xxs">
{canCreateNewModel && (
<Tooltip label={t`Create a new model`} position="bottom">
<ActionIcon
aria-label={t`Create a new model`}
size={32}
variant="viewHeader"
component={ForwardRefLink}
to="/model/new"
onClick={() => trackNewModelInitiated()}
>
<Icon name="add" />
</ActionIcon>
</Tooltip>
)}
{hasVerifiedModels && (
<ModelFilterControls
modelFilters={modelFilters}
setModelFilters={setModelFilters}
/>
)}
</Group>
</Flex>
</Flex>
</Flex>
<Flex className={S.browseMain} direction="column" wrap="nowrap" flex={1}>
<Flex maw="64rem" mx="auto" w="100%">
<Stack mb="xl" gap="lg" w="100%">
{isEmpty ? (
<Stack gap="xl" align="center" data-testid="empty-state">
{showMetabaseLinks && (
<Box maw="45rem" w="100%">
<ModelsVideo autoplay={0} />
</Box>
)}
<Stack gap="xxs" maw="28rem">
<Title
order={2}
ta="center"
>{t`Create models to clean up and combine tables to make your data easier to explore`}</Title>
<Text
ta="center"
lh="1.25rem"
>{t`Models are somewhat like virtual tables: do all your joins and custom columns once, save it as a model, then query it like a table.`}</Text>
</Stack>
{showMetabaseLinks && (
<Button
component={ExternalLink}
href={url}
variant="subtle"
>{t`Read the docs`}</Button>
)}
</Stack>
) : (
<>
<ModelExplanationBanner />
<DelayedLoadingAndErrorWrapper
error={error}
loading={isLoading}
style={{ flex: 1 }}
loader={<RecentModels skeleton />}
>
<RecentModels models={recentModels} />
</DelayedLoadingAndErrorWrapper>
<DelayedLoadingAndErrorWrapper
error={error}
loading={isLoading}
style={{ flex: 1 }}
loader={<ModelsTable skeleton />}
>
<ModelsTable models={models} />
</DelayedLoadingAndErrorWrapper>
</>
)}
{isOfficial ? t`Show unofficial` : t`Show official`}
</Button>
{canCreateNewModel && (
<Tooltip label={t`Create a new model`} position="bottom">
<ActionIcon
aria-label={t`Create a new model`}
size={32}
variant="viewHeader"
component={ForwardRefLink}
to="/model/new"
onClick={() => trackNewModelInitiated()}
>
<Icon name="add" />
</ActionIcon>
</Tooltip>
)}
{hasVerifiedModels && (
<ModelFilterControls
modelFilters={modelFilters}
setModelFilters={setModelFilters}
/>
)}
</>
}
>
{isEmpty ? (
<Stack gap="xl" align="center" data-testid="empty-state">
{showMetabaseLinks && (
<Box maw="45rem" w="100%">
<ModelsVideo autoplay={0} />
</Box>
)}
<Stack gap="xxs" maw="28rem">
<Title
order={2}
ta="center"
>{t`Create models to clean up and combine tables to make your data easier to explore`}</Title>
<Text
ta="center"
lh="1.25rem"
>{t`Models are somewhat like virtual tables: do all your joins and custom columns once, save it as a model, then query it like a table.`}</Text>
</Stack>
</Flex>
</Flex>
</Flex>
{showMetabaseLinks && (
<Button
component={ExternalLink}
href={url}
variant="subtle"
>{t`Read the docs`}</Button>
)}
</Stack>
) : (
<>
<ModelExplanationBanner />
<DelayedLoadingAndErrorWrapper
error={error}
loading={isLoading}
style={{ flex: 1 }}
loader={<RecentModels skeleton />}
>
<RecentModels models={recentModels} />
</DelayedLoadingAndErrorWrapper>
<DetailPanel
flush
title={isOfficial ? t`Official models` : t`Unofficial models`}
>
<DelayedLoadingAndErrorWrapper
error={error}
loading={isLoading}
style={{ flex: 1 }}
loader={<ModelsTable skeleton />}
>
{shown.length === 0 ? (
<Text p="lg" c="text-secondary">
{isOfficial
? t`No official models yet. Publish a model to the Library, or mark its collection official.`
: t`Every model is official.`}
</Text>
) : (
<ModelsTable models={shown} />
)}
</DelayedLoadingAndErrorWrapper>
</DetailPanel>
</>
)}
</BrowsePageLayout>
);
};

View File

@@ -490,7 +490,7 @@ describe("BrowseModels", () => {
expect(recentModelsGrid).not.toBeInTheDocument();
});
it("should render links that point directly to /model/{id}-{slug} (metabase#55166)", async () => {
it("links recents to the model and table rows to its detail page (metabase#55166)", async () => {
const { router } = setup({ modelCount: 25 });
const recentModelsGrid = await screen.findByRole("grid", {
name: /Recents/,
@@ -508,10 +508,10 @@ describe("BrowseModels", () => {
expect(
within(modelsTable).getByRole("link", { name: /Model 20/ }),
).toHaveAttribute("href", "/model/20-model-20");
).toHaveAttribute("href", "/model/20-model-20/detail");
expect(
within(modelsTable).getByRole("link", { name: /Model 21/ }),
).toHaveAttribute("href", "/model/21-model-21");
).toHaveAttribute("href", "/model/21-model-21/detail");
expect(screen.queryByTestId("model-detail-page")).not.toBeInTheDocument();
await userEvent.click(within(recentModelsGrid).getByText("Model 1"));

View File

@@ -8,6 +8,7 @@ import { EntityIcon } from "metabase/common/components/EntityIcon";
import { EntityItemName } from "metabase/common/components/EntityItemName";
import { SortableColumnHeader } from "metabase/common/components/ItemsTable/BaseItemsTable";
import {
ColumnHeader,
ItemNameCell,
TBody,
Table,
@@ -17,6 +18,7 @@ import { Columns } from "metabase/common/components/ItemsTable/Columns";
import type { ResponsiveProps } from "metabase/common/components/ItemsTable/utils";
import { Link } from "metabase/common/components/Link";
import { MarkdownPreview } from "metabase/common/components/MarkdownPreview";
import { UserAvatar } from "metabase/common/components/UserAvatar";
import { useGetIcon } from "metabase/hooks/use-icon";
import { useNavigate } from "metabase/router";
import {
@@ -25,6 +27,7 @@ import {
Flex,
Repeat,
Skeleton,
Text,
} from "metabase/ui";
import * as Urls from "metabase/urls";
import type { SortingOptions } from "metabase-types/api";
@@ -53,6 +56,13 @@ const collectionProps: ResponsiveProps = {
containerName: itemsTableContainerName,
};
const ownerProps: ResponsiveProps = {
hideAtContainerBreakpoint: "md",
containerName: itemsTableContainerName,
};
const OWNER_WIDTH = 180;
const DEFAULT_SORTING_OPTIONS: SortingOptions<SortColumn> = {
sort_column: "collection",
sort_direction: "asc",
@@ -67,7 +77,7 @@ export const ModelsTable = ({
const sortedModels = sortModels(models, sortingOptions);
/** The name column has an explicitly set width. The remaining columns divide the remaining width. This is the percentage allocated to the collection column */
const collectionWidth = 38.5;
const collectionWidth = 34;
const descriptionWidth = 100 - collectionWidth;
const handleUpdateSortOptions = skeleton
@@ -88,6 +98,9 @@ export const ModelsTable = ({
{/* <col> for Description column */}
<TableColumn {...descriptionProps} width={`${descriptionWidth}%`} />
{/* <col> for Owner column */}
<TableColumn {...ownerProps} width={OWNER_WIDTH} />
<Columns.RightEdge.Col />
</colgroup>
<thead>
@@ -127,6 +140,10 @@ export const ModelsTable = ({
>
{t`Description`}
</SortableColumnHeader>
{/* Not sortable: `SortColumn` only covers name/collection/description. */}
<ColumnHeader {...ownerProps} style={{ paddingInline: ".5rem" }}>
{t`Owner`}
</ColumnHeader>
<Columns.RightEdge.Header />
</tr>
</thead>
@@ -174,7 +191,7 @@ const ModelRow = ({ model }: { model?: ModelResult }) => {
}
const { id, name } = model;
const url = Urls.model({ id, name, type: "model" });
const url = Urls.modelDetail({ id, name, type: "model" });
const subpathSafeUrl = Urls.getSubpathSafeUrl(url);
trackModelClick(model.id);
@@ -199,6 +216,7 @@ const ModelRow = ({ model }: { model?: ModelResult }) => {
<NameCell model={model} />
<CollectionCell model={model} />
<DescriptionCell model={model} />
<OwnerCell model={model} />
<Columns.RightEdge.Cell />
</tr>
);
@@ -220,7 +238,11 @@ function NameCell({ model }: { model?: ModelResult }) {
/>
{model ? (
<Link
to={Urls.model({ id: model.id, name: model.name, type: "model" })}
to={Urls.modelDetail({
id: model.id,
name: model.name,
type: "model",
})}
onClick={preventDefault}
style={{ overflow: "hidden" }}
>
@@ -288,3 +310,24 @@ function DescriptionCell({ model }: { model?: ModelResult }) {
</td>
);
}
function OwnerCell({ model }: { model?: ModelResult }) {
const name = model
? (model.last_editor_common_name ?? model.creator_common_name)
: null;
return (
<td className={cx(BrowseTableS.cell, BrowseTableS.hideAtMd)}>
{!model ? (
<SkeletonText />
) : name ? (
<Flex align="center" gap="sm">
<UserAvatar user={{ common_name: name }} size="1.25rem" decorative />
<Text size="sm" truncate>
{name}
</Text>
</Flex>
) : null}
</td>
);
}

View File

@@ -0,0 +1,35 @@
import { PLUGIN_LIBRARY } from "metabase/plugins";
import type {
CollectionAuthorityLevel,
CollectionEssentials,
} from "metabase-types/api";
type AuthorityBearing = {
collection_authority_level?: CollectionAuthorityLevel;
collection?: CollectionEssentials | null;
};
/**
* Official means the item's own collection is marked official, or the item is in the Library.
* Authority level does not inherit in Metabase, so neither does this.
*/
export function isOfficialItem(item: AuthorityBearing): boolean {
return (
item.collection_authority_level === "official" ||
item.collection?.authority_level === "official" ||
PLUGIN_LIBRARY.isLibraryCollectionType(item.collection?.type)
);
}
export function partitionByAuthority<T extends AuthorityBearing>(
items: T[],
): { official: T[]; unofficial: T[] } {
const official: T[] = [];
const unofficial: T[] = [];
for (const item of items) {
(isOfficialItem(item) ? official : unofficial).push(item);
}
return { official, unofficial };
}

View File

@@ -9,13 +9,13 @@ import {
} from "metabase/common/collections/utils";
import { EditableDescription } from "metabase/common/components/EditableDescription";
import { EditableText } from "metabase/common/components/EditableText";
import { getIsTenantUser, getUser } from "metabase/current-user";
import { getUser } from "metabase/current-user";
import {
PLUGIN_COLLECTIONS,
PLUGIN_COLLECTION_COMPONENTS,
} from "metabase/plugins";
import { useSelector } from "metabase/redux";
import { Box, Flex, Icon, rem } from "metabase/ui";
import { Box, Flex, Icon, isValidIconName, rem } from "metabase/ui";
import type { Collection } from "metabase-types/api";
import S from "./CollectionCaption.module.css";
@@ -90,8 +90,6 @@ export const CollectionCaption = ({
};
const CollectionCaptionIcon = ({ collection }: { collection: Collection }) => {
const isTenantUser = useSelector(getIsTenantUser);
if (isInstanceAnalyticsCollection(collection)) {
return (
<PLUGIN_COLLECTION_COMPONENTS.CollectionInstanceAnalyticsIcon
@@ -103,11 +101,6 @@ const CollectionCaptionIcon = ({ collection }: { collection: Collection }) => {
);
}
if (PLUGIN_COLLECTIONS.isSyncedCollection(collection) && !isTenantUser) {
// external users should see the normal icon, they should not know about what synced collections are
return <Icon name="synced_collection" size={24} c="core-brand" />;
}
if (isRootTrashCollection(collection)) {
return <Icon name="trash" size={24} c="text-disabled" />;
}
@@ -119,6 +112,10 @@ const CollectionCaptionIcon = ({ collection }: { collection: Collection }) => {
return <Icon name="folder" size={24} c="text-disabled" />;
}
if (collection.icon && isValidIconName(collection.icon)) {
return <Icon name={collection.icon} size={24} c="core-brand" />;
}
return (
<PLUGIN_COLLECTION_COMPONENTS.CollectionAuthorityLevelIcon
collection={collection}

View File

@@ -9,10 +9,11 @@ import {
} from "metabase/api";
import type { CommentExtraRenderer } from "metabase/comments/types";
import { getCommentNodeId } from "metabase/comments/utils";
import { UserAvatar } from "metabase/common/components/UserAvatar";
import { useToast } from "metabase/common/hooks";
import { getUser } from "metabase/current-user";
import { useSelector } from "metabase/redux";
import { Avatar, Stack, Timeline, rem } from "metabase/ui";
import { Stack, Timeline, rem } from "metabase/ui";
import type {
Comment,
CommentEntityType,
@@ -217,7 +218,7 @@ export const Discussion = ({
{!comments[0]?.is_resolved && currentUser && (
<Timeline.Item
className={S.commentRoot}
bullet={<Avatar name={currentUser.common_name} />}
bullet={<UserAvatar user={currentUser} size="1.5rem" decorative />}
>
<CommentEditor
active={false}

View File

@@ -7,9 +7,10 @@ import { t } from "ttag";
import type { CommentExtraRenderer } from "metabase/comments/types";
import { formatCommentDate, getCommentNodeId } from "metabase/comments/utils";
import ErrorBoundary from "metabase/common/components/ErrorBoundary";
import { UserAvatar } from "metabase/common/components/UserAvatar";
import { getUser } from "metabase/current-user";
import { useSelector } from "metabase/redux";
import { Avatar, Box, Group, Icon, Text, Timeline, Tooltip } from "metabase/ui";
import { Box, Group, Icon, Text, Timeline, Tooltip } from "metabase/ui";
import type { Comment, DocumentContent } from "metabase-types/api";
import { CommentEditor } from "../CommentEditor";
@@ -114,7 +115,7 @@ export function DiscussionComment({
className={cx(S.commentRoot, {
[S.target]: isTarget,
})}
bullet={<Avatar name={comment.creator?.common_name} />}
bullet={<UserAvatar user={comment.creator} size="1.5rem" decorative />}
aria-current={isTarget ? "location" : undefined}
data-testid="discussion-comment"
id={commentNodeId}

View File

@@ -22,11 +22,13 @@ const IRREGULAR_ICON_PROPS = {
type CollectionBadgeInnerProps = {
collection: CollectionType;
onClick?: () => void;
showIcon: boolean;
};
const CollectionBadgeInner = ({
collection,
onClick,
showIcon,
}: CollectionBadgeInnerProps) => {
const tc = useTranslateContent();
const getIcon = useGetIcon();
@@ -43,8 +45,8 @@ const CollectionBadgeInner = ({
return (
<Breadcrumb
icon={icon.name}
iconColor={icon.color}
icon={showIcon ? icon.name : undefined}
iconColor={showIcon ? icon.color : undefined}
to={
onClick ? undefined : modelToUrl({ model: "collection", ...collection })
}
@@ -58,11 +60,13 @@ const CollectionBadgeInner = ({
type CollectionBadgeProps = {
collectionId?: CollectionId;
onClick?: () => void;
showIcon?: boolean;
};
export const CollectionBadge = ({
collectionId,
onClick,
showIcon = true,
}: CollectionBadgeProps) => {
const { data: collection } = useGetCollectionQuery({
id: collectionId || "root",
@@ -70,5 +74,11 @@ export const CollectionBadge = ({
if (!collection) {
return null;
}
return <CollectionBadgeInner collection={collection} onClick={onClick} />;
return (
<CollectionBadgeInner
collection={collection}
onClick={onClick}
showIcon={showIcon}
/>
);
};

View File

@@ -38,10 +38,18 @@ type CollectionRowMenuProps = {
}) => void;
onArchiveSuccess?: () => void;
customArchiveMessage?: string;
/** When supplied, the menu offers a "New folder" entry that invokes this. */
onNewFolder?: () => void;
};
export function CollectionRowMenu(props: CollectionRowMenuProps) {
const { collection, onArchiveSuccess, onSave, customArchiveMessage } = props;
const {
collection,
onArchiveSuccess,
onSave,
customArchiveMessage,
onNewFolder,
} = props;
const dispatch = useDispatch();
const isAdmin = useSelector(getUserIsAdmin);
@@ -60,7 +68,10 @@ export function CollectionRowMenu(props: CollectionRowMenuProps) {
(PLUGIN_LIBRARY.isEnabled || PLUGIN_SNIPPET_FOLDERS.isEnabled) &&
collection.namespace !== "transforms";
const isRoot = isRootCollection(collection);
// The seeded Library/Data/Metrics collections are renamed, moved and archived by the backend only;
// `isRootCollection` covers the "Our analytics" sentinel.
const isImmutable =
isRootCollection(collection) || !!collection.is_library_root;
if (!collection.can_write || remoteSyncReadOnly) {
return null;
@@ -161,7 +172,15 @@ export function CollectionRowMenu(props: CollectionRowMenuProps) {
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
{!isRoot && (
{onNewFolder && (
<Menu.Item
leftSection={<Icon name="new_folder" />}
onClick={onNewFolder}
>
{t`New folder`}
</Menu.Item>
)}
{!isImmutable && (
<Menu.Item
leftSection={<Icon name="pencil" />}
onClick={toggleEditModal}
@@ -179,7 +198,7 @@ export function CollectionRowMenu(props: CollectionRowMenuProps) {
{t`Change permissions`}
</Menu.Item>
)}
{!isRoot && (
{!isImmutable && (
<Menu.Item
leftSection={<Icon name="archive" />}
onClick={onArchiveClick}

View File

@@ -16,11 +16,13 @@ import { isItemInCollectionOrItsDescendants } from "metabase/common/components/P
import {
Form,
FormErrorMessage,
FormIconPicker,
FormProvider,
FormSubmitButton,
FormTextInput,
FormTextarea,
} from "metabase/forms";
import { PLUGIN_LIBRARY } from "metabase/plugins";
import { Button, Group, Modal, Stack } from "metabase/ui";
import type {
Collection,
@@ -53,6 +55,7 @@ export function EditCollectionModal(props: EditCollectionModalProps) {
return {
name: collection.name ?? "",
description: collection.description ?? null,
icon: collection.icon ?? null,
parent_id: typeof parentId === "number" ? parentId : null,
};
}, [collection]);
@@ -63,6 +66,7 @@ export function EditCollectionModal(props: EditCollectionModalProps) {
id: collection.id,
name: values.name,
description: values.description ?? undefined,
icon: values.icon,
parent_id: values.parent_id,
}).unwrap();
onSave?.({
@@ -84,6 +88,9 @@ export function EditCollectionModal(props: EditCollectionModalProps) {
(e: KeyboardEvent) => e.stopPropagation(),
[],
);
const isLibraryCollection = PLUGIN_LIBRARY.isLibraryCollectionType(
collection.type,
);
return (
<Modal
@@ -108,6 +115,9 @@ export function EditCollectionModal(props: EditCollectionModalProps) {
placeholder={t`My collection`}
data-autofocus
/>
{isLibraryCollection && (
<FormIconPicker name="icon" label={t`Icon`} />
)}
<FormTextarea
name="description"
label={t`Description`}

View File

@@ -7,6 +7,8 @@ import {
} from "__support__/server-mocks";
import { createMockState } from "__support__/state";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import { PLUGIN_LIBRARY, reinitialize } from "metabase/plugins";
import type { LibraryCollectionType } from "metabase/plugins/oss/library";
import type { Collection, CollectionItem } from "metabase-types/api";
import {
createMockCollection,
@@ -81,6 +83,7 @@ describe("EditCollectionModal", () => {
expect(await request?.json()).toEqual({
name: "Updated name",
description: "Updated description",
icon: null,
parent_id: parentCollection.id,
});
});
@@ -109,7 +112,53 @@ describe("EditCollectionModal", () => {
)?.request;
expect(await request?.json()).toEqual({
name: "Updated item name",
icon: null,
parent_id: itemParentCollection.id,
});
});
describe("library collections", () => {
beforeEach(() => {
PLUGIN_LIBRARY.isLibraryCollectionType = (
type?: string | null,
): type is LibraryCollectionType =>
type === "library" ||
type === "library-data" ||
type === "library-metrics";
});
afterEach(() => {
reinitialize();
});
it("submits the icon picked for a library folder", async () => {
const collection = createMockCollection({
id: 3,
name: "Finance",
type: "library",
parent_id: parentCollection.id,
});
setup(collection);
await userEvent.click(screen.getByRole("button", { name: "Icon" }));
await userEvent.click(
await screen.findByRole("button", { name: "star" }),
);
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(
fetchMock.callHistory.lastCall("update-collection-3"),
).toBeTruthy(),
);
const request = fetchMock.callHistory.lastCall(
"update-collection-3",
)?.request;
expect(await request?.json()).toEqual({
name: "Finance",
icon: "star",
parent_id: parentCollection.id,
});
});
});
});

View File

@@ -14,6 +14,7 @@ import type {
import {
Form,
FormErrorMessage,
FormIconPicker,
FormProvider,
FormSubmitButton,
FormTextInput,
@@ -21,9 +22,13 @@ import {
} from "metabase/forms";
import { PLUGIN_TENANTS } from "metabase/plugins";
import { useLocation, useParams } from "metabase/router";
import { Button, Flex } from "metabase/ui";
import { Box, Button, Flex } from "metabase/ui";
import * as Errors from "metabase/utils/errors";
import type { Collection, CollectionNamespace } from "metabase-types/api";
import type {
Collection,
CollectionNamespace,
IconName,
} from "metabase-types/api";
import { FormAuthorityLevelField } from "../../containers/FormAuthorityLevelFieldContainer";
@@ -33,6 +38,7 @@ const COLLECTION_SCHEMA = Yup.object({
.max(100, Errors.maxLength)
.default(""),
description: Yup.string().nullable().max(255, Errors.maxLength).default(null),
icon: Yup.mixed<IconName>().nullable().default(null),
authority_level: Yup.mixed().oneOf(["official", null]).default(null),
parent_id: Yup.number().nullable(),
@@ -41,6 +47,7 @@ const COLLECTION_SCHEMA = Yup.object({
export interface CreateCollectionProperties {
name: string;
description: string | null;
icon: IconName | null;
parent_id: Collection["id"] | null;
namespace?: CollectionNamespace;
}
@@ -55,6 +62,7 @@ export interface CreateCollectionFormOwnProps {
pickerOptions?: EntityPickerOptions;
namespaces?: CollectionNamespace[];
showAuthorityLevelPicker?: boolean;
showIconPicker?: boolean;
}
type Props = CreateCollectionFormOwnProps;
@@ -69,6 +77,7 @@ function CreateCollectionForm({
pickerOptions,
namespaces,
showAuthorityLevelPicker = true,
showIconPicker = false,
}: Props) {
const location = useLocation();
const params = useParams();
@@ -135,6 +144,11 @@ function CreateCollectionForm({
data-autofocus
mb="lg"
/>
{showIconPicker && (
<Box mb="lg">
<FormIconPicker name="icon" label={t`Icon`} />
</Box>
)}
<FormTextarea
name="description"
label={t`Description`}

View File

@@ -20,12 +20,14 @@ export interface CreateCollectionModalOwnProps extends Omit<
onCreate?: (collection: Collection) => void;
onClose: () => void;
shouldNavigateOnCreate?: boolean;
title?: string;
}
function CreateCollectionModal({
onCreate,
onClose,
shouldNavigateOnCreate = true,
title = t`New collection`,
...props
}: CreateCollectionModalOwnProps) {
const navigate = useNavigate();
@@ -69,7 +71,7 @@ function CreateCollectionModal({
size="lg"
data-testid="new-collection-modal"
padding="40px"
title={t`New collection`}
title={title}
>
<CreateCollectionForm
{...props}

View File

@@ -1,6 +1,7 @@
import * as Yup from "yup";
import * as Errors from "metabase/utils/errors";
import type { IconName } from "metabase-types/api";
export const COLLECTION_FORM_SCHEMA = Yup.object({
name: Yup.string()
@@ -8,6 +9,7 @@ export const COLLECTION_FORM_SCHEMA = Yup.object({
.max(100, Errors.maxLength)
.default(""),
description: Yup.string().nullable().max(255, Errors.maxLength).default(null),
icon: Yup.mixed<IconName>().nullable().default(null),
parent_id: Yup.number().nullable().default(null),
});

View File

@@ -1,13 +1,14 @@
import { t } from "ttag";
import {
type CollectionPlacementTarget,
canPlaceEntityInCollection as canPlaceEntityInCollectionImpl,
canPlaceEntityInCollectionOrDescendants as canPlaceEntityInCollectionOrDescendantsImpl,
} from "metabase/common/data-studio/collection-utils";
import { getUserPersonalCollectionId } from "metabase/current-user";
import { PLUGIN_COLLECTIONS, PLUGIN_LIBRARY } from "metabase/plugins";
import type { State } from "metabase/redux/store";
import type { IconProps } from "metabase/ui";
import { type IconProps, isValidIconName } from "metabase/ui";
import { color } from "metabase/ui/colors";
import type { ColorName } from "metabase/ui/colors/types";
import {
@@ -19,7 +20,6 @@ import {
type CollectionId,
type CollectionItem,
type CollectionItemModel,
type CollectionType,
type IconName,
type User,
isBaseEntityID,
@@ -130,17 +130,15 @@ export function isInstanceAnalyticsCustomCollection(
);
}
export function isSyncedCollection(collection: Partial<Collection>): boolean {
return PLUGIN_COLLECTIONS.isSyncedCollection(collection);
}
export function isLibraryCollection(
collection: Pick<Collection, "type">,
): boolean {
return PLUGIN_LIBRARY.isLibraryCollectionType(collection.type);
}
export function isExamplesCollection(collection: Collection): boolean {
export function isExamplesCollection(
collection: Pick<Collection, "is_sample" | "name">,
): boolean {
return !!collection.is_sample && collection.name === "Examples";
}
@@ -298,19 +296,16 @@ export function canCopyItem(item: CollectionItem): item is CopyableItem {
export function canPlaceEntityInCollection(
entityType: EntityType,
collectionType: CollectionType | null | undefined,
target: CollectionPlacementTarget,
): boolean {
return canPlaceEntityInCollectionImpl(entityType, collectionType);
return canPlaceEntityInCollectionImpl(entityType, target);
}
export function canPlaceEntityInCollectionOrDescendants(
entityType: EntityType,
collectionType: CollectionType | null | undefined,
target: CollectionPlacementTarget,
): boolean {
return canPlaceEntityInCollectionOrDescendantsImpl(
entityType,
collectionType,
);
return canPlaceEntityInCollectionOrDescendantsImpl(entityType, target);
}
export function coerceCollectionId(
@@ -395,7 +390,7 @@ export const getCollectionPathAsArray = (collection: Collection): string[] => {
export function getCollectionIcon(
collection: Partial<Collection>,
{ tooltip = "default", isTenantUser = false } = {},
{ tooltip = "default" } = {},
): {
name: IconName;
color?: ColorName;
@@ -413,9 +408,8 @@ export function getCollectionIcon(
return { name: "person" };
}
if (isSyncedCollection(collection) && !isTenantUser) {
// tenant users see the normal icon, they don't know what a synced collection is
return { name: "synced_collection" };
if (collection.icon && isValidIconName(collection.icon)) {
return { name: collection.icon };
}
if (collection.is_library_root) {
@@ -452,7 +446,12 @@ export function getCollectionType(
return collectionId !== undefined ? "other" : null;
}
export interface CollectionTreeItem extends Collection {
// `icon` is narrowed to the resolved icon (never null) since the tree builder
// always derives one via `getCollectionIcon`.
export interface CollectionTreeItem extends Omit<
Collection,
"icon" | "children"
> {
icon: IconName | IconProps;
children: CollectionTreeItem[];
schemaName?: string;
@@ -463,10 +462,8 @@ export function buildCollectionTree(
collections: Collection[] = [],
{
modelFilter,
isTenantUser = false,
}: {
modelFilter?: (model: CollectionContentModel) => boolean;
isTenantUser?: boolean;
} = {},
): CollectionTreeItem[] {
return collections.flatMap((collection) => {
@@ -484,7 +481,7 @@ export function buildCollectionTree(
const children = !isRootTrashCollection(collection)
? buildCollectionTree(
collection.children?.filter((child) => !child.archived) || [],
{ modelFilter, isTenantUser },
{ modelFilter },
)
: [];
@@ -495,7 +492,7 @@ export function buildCollectionTree(
return {
...collection,
schemaName: collection.originalName || collection.name,
icon: getCollectionIcon(collection, { isTenantUser }),
icon: getCollectionIcon(collection),
children,
};
});

View File

@@ -4,12 +4,12 @@ import styled from "@emotion/styled";
import { NAV_SIDEBAR_WIDTH } from "metabase/nav/constants";
import { Card } from "metabase/ui";
export const BulkActionsToast = styled.div<{ isNavbarOpen: boolean }>`
export const BulkActionsToast = styled.div`
position: fixed;
bottom: 0;
left: 50%;
margin-left: ${(props) =>
props.isNavbarOpen ? `${parseInt(NAV_SIDEBAR_WIDTH) / 2}px` : "0"};
/* The nav rail is always shown, so the toast is always centred on the content, not the window. */
margin-left: ${parseInt(NAV_SIDEBAR_WIDTH) / 2}px;
margin-bottom: var(--mantine-spacing-lg);
transform: translateX(-50%);
z-index: 150;

View File

@@ -2,8 +2,6 @@ import cx from "classnames";
import type { HTMLAttributes } from "react";
import Animation from "metabase/css/core/animation.module.css";
import { useSelector } from "metabase/redux";
import { getIsNavbarOpen } from "metabase/selectors/app";
import { type BoxProps, Flex, Portal, Text } from "metabase/ui";
import { BulkActionsToast, ToastCard } from "./BulkActionBar.styled";
@@ -13,7 +11,6 @@ type BulkActionsProps = {
message: string;
children: React.ReactNode | React.ReactNode[];
className?: string;
isNavbarOpen?: boolean;
};
/**
@@ -27,8 +24,7 @@ type BulkActionsProps = {
* @returns
*/
export const BulkActionBar = (props: BulkActionsProps) => {
const isNavbarOpen = useSelector(getIsNavbarOpen);
return <BulkActionBarPortal {...props} isNavbarOpen={isNavbarOpen} />;
return <BulkActionBarPortal {...props} />;
};
export const BulkActionBarPortal = ({
@@ -36,7 +32,6 @@ export const BulkActionBarPortal = ({
message,
children,
className,
isNavbarOpen = true,
...props
}: BulkActionsProps & BoxProps & HTMLAttributes<HTMLDivElement>) => {
if (!opened) {
@@ -44,10 +39,7 @@ export const BulkActionBarPortal = ({
}
return (
<Portal>
<BulkActionsToast
isNavbarOpen={isNavbarOpen}
className={cx(className, Animation.popToast)}
>
<BulkActionsToast className={cx(className, Animation.popToast)}>
<ToastCard data-testid="toast-card" {...props}>
{message && <Text c="tooltip-text">{message}</Text>}
<Flex gap="sm" align="center">

View File

@@ -0,0 +1,4 @@
.rail {
position: sticky;
top: 0;
}

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import { Box, Flex, Stack } from "metabase/ui";
import S from "./DetailPageLayout.module.css";
export type DetailPageLayoutProps = {
/** Sticky right-hand fact rail. */
rail: ReactNode;
children: ReactNode;
};
export function DetailPageLayout({ rail, children }: DetailPageLayoutProps) {
return (
<Flex align="flex-start" gap="lg">
<Stack flex={1} miw={0} gap="lg">
{children}
</Stack>
<Box className={S.rail} flex="0 0 20rem" w="20rem">
{rail}
</Box>
</Flex>
);
}

View File

@@ -0,0 +1,15 @@
.header {
border-bottom: 1px solid var(--mb-color-border);
min-height: 2.5rem;
&[data-collapsed] {
border-bottom: 0;
}
}
.toggle {
flex: 1;
align-self: stretch;
display: flex;
align-items: center;
}

View File

@@ -0,0 +1,91 @@
import { useDisclosure } from "@mantine/hooks";
import { type ReactNode, useId } from "react";
import {
Box,
Card,
Collapse,
Group,
Icon,
Text,
UnstyledButton,
} from "metabase/ui";
import S from "./DetailPanel.module.css";
export type DetailPanelProps = {
title: string;
/** Right side of the title row: a single link or button out to the full editor/view. */
actions?: ReactNode;
/** Drop body padding. Tables and the embedded graph draw their own. */
flush?: boolean;
/** Let the title row expand and collapse the body, accordion-style. */
collapsible?: boolean;
/** Start collapsed. Only applies when `collapsible` is set. */
defaultCollapsed?: boolean;
children: ReactNode;
"data-testid"?: string;
};
export function DetailPanel({
title,
actions,
flush,
collapsible = false,
defaultCollapsed = false,
children,
"data-testid": dataTestId,
}: DetailPanelProps) {
const [isOpened, { toggle }] = useDisclosure(
!(collapsible && defaultCollapsed),
);
const bodyId = useId();
const titleText = (
<Text fw="bold" size="sm">
{title}
</Text>
);
const body = <Box p={flush ? 0 : "lg"}>{children}</Box>;
return (
<Card withBorder shadow="none" p={0} data-testid={dataTestId}>
<Group
justify="space-between"
align="center"
wrap="nowrap"
px="lg"
py="sm"
className={S.header}
data-collapsed={!isOpened || undefined}
>
{collapsible ? (
<UnstyledButton
className={S.toggle}
aria-expanded={isOpened}
aria-controls={bodyId}
onClick={toggle}
>
<Group gap="xs" wrap="nowrap">
<Icon
name={isOpened ? "chevrondown" : "chevronright"}
size={12}
aria-hidden
/>
{titleText}
</Group>
</UnstyledButton>
) : (
titleText
)}
{actions}
</Group>
{collapsible ? (
<Collapse in={isOpened} id={bodyId}>
{body}
</Collapse>
) : (
body
)}
</Card>
);
}

View File

@@ -0,0 +1,5 @@
export { DetailPanel, type DetailPanelProps } from "./DetailPanel";
export {
DetailPageLayout,
type DetailPageLayoutProps,
} from "./DetailPageLayout";

View File

@@ -4,13 +4,13 @@ import { Link } from "metabase/common/components/Link/Link";
import { Center, Group, Icon, Paper, Stack, Text, rem } from "metabase/ui";
import type { IconName } from "metabase-types/api";
import S from "./MetadataCard.module.css";
import S from "./FactCard.module.css";
interface MetadataCardProps {
export interface FactCardProps {
children: ReactNode;
}
export function MetadataCard({ children }: MetadataCardProps) {
export function FactCard({ children }: FactCardProps) {
return (
<Paper className={S.card}>
<Stack gap={rem(12)} className={S.rows}>
@@ -20,14 +20,14 @@ export function MetadataCard({ children }: MetadataCardProps) {
);
}
interface MetadataRowProps {
export interface FactRowProps {
icon: IconName;
children: ReactNode;
/** Empty-state styling: muted icon and text. */
muted?: boolean;
}
export function MetadataRow({ icon, children, muted }: MetadataRowProps) {
export function FactRow({ icon, children, muted }: FactRowProps) {
const color = muted ? "text-secondary" : "text-primary";
return (
<Group gap={rem(10)} wrap="nowrap" align="center">
@@ -46,13 +46,12 @@ export function MetadataRow({ icon, children, muted }: MetadataRowProps) {
);
}
export function MetadataRowLink({
to,
children,
}: {
export interface FactRowLinkProps {
to: string;
children: ReactNode;
}) {
}
export function FactRowLink({ to, children }: FactRowLinkProps) {
return (
<Link to={to} className={S.link}>
<Text component="span" fw={600} className={S.text}>

View File

@@ -2,17 +2,13 @@ import type { ReactNode } from "react";
import { Stack, type StackProps, Text, rem } from "metabase/ui";
interface MetricSubSectionProps {
export interface FactSectionProps {
title: string;
children: ReactNode;
mt?: StackProps["mt"];
}
export function MetricSubSection({
title,
children,
mt,
}: MetricSubSectionProps) {
export function FactSection({ title, children, mt }: FactSectionProps) {
return (
<Stack gap="sm" mt={mt}>
<Text fz="sm" lh={rem(16)} fw={400} c="text-secondary">

View File

@@ -0,0 +1,56 @@
import { c, t } from "ttag";
import { DateTime } from "metabase/common/components/DateTime";
import { UserAvatar } from "metabase/common/components/UserAvatar";
import { Group, Text } from "metabase/ui";
import { getUserName } from "metabase/utils/user";
import type { LastEditInfo, UserInfo } from "metabase-types/api";
import { FactCard, FactRow } from "./FactCard";
import { FactSection } from "./FactSection";
export type MaintainerFactProps = {
creator?: UserInfo;
createdAt?: string | null;
lastEditInfo?: LastEditInfo;
};
export function MaintainerFact({
creator,
createdAt,
lastEditInfo,
}: MaintainerFactProps) {
if (!creator && !lastEditInfo) {
return null;
}
return (
<FactSection title={t`Maintainer`}>
<FactCard>
{creator && (
<Group gap="sm" wrap="nowrap">
<UserAvatar user={creator} size="1.5rem" decorative />
<Text size="sm" truncate>
{getUserName(creator)}
</Text>
</Group>
)}
{createdAt && (
<FactRow icon="ai" muted>
{c("Describes when an entity was created. {0} is a date.")
.jt`Created ${<DateTime unit="day" value={createdAt} key="date" />}`}
</FactRow>
)}
{lastEditInfo && (
<FactRow icon="pencil" muted>
{c(
"Describes when an entity was last edited. {0} is a date and {1} is a person's name.",
).jt`${(
<DateTime unit="day" value={lastEditInfo.timestamp} key="date" />
)} by ${getUserName(lastEditInfo)}`}
</FactRow>
)}
</FactCard>
</FactSection>
);
}

View File

@@ -0,0 +1,10 @@
export {
FactCard,
type FactCardProps,
FactRow,
type FactRowProps,
FactRowLink,
type FactRowLinkProps,
} from "./FactCard";
export { FactSection, type FactSectionProps } from "./FactSection";
export { MaintainerFact, type MaintainerFactProps } from "./MaintainerFact";

View File

@@ -0,0 +1,4 @@
.grid {
max-height: 14rem;
overflow-y: auto;
}

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