Compare commits

...

17 Commits

Author SHA1 Message Date
Raphael Krut-Landau
fff72b78d8 Fix imports 2024-05-03 14:11:16 -04:00
Raphael Krut-Landau
4bf933b250 fix type issue 2024-05-03 14:11:16 -04:00
Raphael Krut-Landau
6b9f842d0f Sorting works 2024-05-03 14:11:16 -04:00
Raphael Krut-Landau
52725b7abe wip 2024-05-03 14:11:16 -04:00
Raphael Krut-Landau
6d2830d00f wip: supporting sorting in Browse models table 2024-05-03 14:11:16 -04:00
Raphael Krut-Landau
e464f8685e Fix type issue' 2024-05-03 14:11:16 -04:00
Raphael Krut-Landau
6e85ae45e0 Fix c for i18n check 2024-05-03 14:11:15 -04:00
Raphael Krut-Landau
0f7258bd07 refactor 2024-05-03 14:11:15 -04:00
Raphael Krut-Landau
1b44329707 Squashed branch 2024-05-03 14:11:15 -04:00
Raphael Krut-Landau
0a731767c3 Squashed branch 2024-05-03 14:11:15 -04:00
Raphael Krut-Landau
910b6cc0db Squashed branch 2024-05-03 14:11:15 -04:00
Raphael Krut-Landau
f8de941e7a Remove tests that are no longer relevant 2024-05-03 14:11:15 -04:00
Raphael Krut-Landau
37f97ed701 Squashed branch 2024-05-03 14:11:15 -04:00
Raphael Krut-Landau
5f6c89f31e fix e2e test 2024-05-03 14:11:15 -04:00
Raphael Krut-Landau
b074b74790 Fix browse() func 2024-05-03 14:11:15 -04:00
Raphael Krut-Landau
4ec636c7ad fix e2e test 2024-05-03 14:11:14 -04:00
Raphael Krut-Landau
48a888a354 Squashed branch 2024-05-03 14:11:14 -04:00
61 changed files with 1353 additions and 1040 deletions

View File

@@ -64,9 +64,8 @@ export function closeNavigationSidebar() {
appBar().findByTestId("sidebar-toggle").click();
}
export function browse() {
// takes you to `/browse` (reflecting changes made in `0.38-collection-redesign)
return navigationSidebar().findByText("Browse data");
export function browseData() {
return navigationSidebar().findByLabelText("Browse data");
}
/**

View File

@@ -276,7 +276,7 @@ describe("scenarios > admin > databases > sample database", () => {
cy.wait("@loadDatabases");
cy.findByTestId("main-navbar-root").within(() => {
cy.findByText("Browse data").should("not.exist");
cy.findByLabelText("Browse data").should("not.exist");
});
cy.visit("/admin/databases");
@@ -293,8 +293,8 @@ describe("scenarios > admin > databases > sample database", () => {
cy.wait("@loadDatabases");
cy.findByTestId("main-navbar-root").within(() => {
cy.findByText("Browse data").should("exist");
cy.findByText("Browse data").click();
cy.findByLabelText("Browse data").should("exist");
cy.findByLabelText("Browse data").click();
});
cy.findByRole("tab", { name: "Databases" }).click();

View File

@@ -163,7 +163,7 @@ describeEE("scenarios > embedding > full app", () => {
describe("browse data", () => {
it("should hide the top nav when nothing is shown", () => {
visitFullAppEmbeddingUrl({
url: "/browse",
url: "/browse/databases",
qs: { side_nav: false, logo: false },
});
cy.findByRole("heading", { name: /Browse data/ }).should("be.visible");

View File

@@ -106,6 +106,6 @@ export function selectFromDropdown(option, clickOpts) {
export function startQuestionFromModel(modelName) {
cy.findByTestId("app-bar").findByText("New").click();
popover().findByText("Question").should("be.visible").click();
cy.findByText("Models").click();
cy.findByLabelText("Browse models").click();
cy.findByText(modelName).click();
}

View File

@@ -21,7 +21,7 @@ describe("issue 25537", () => {
cy.createQuestion(questionDetails);
startNewQuestion();
cy.icon("model").click();
cy.findByRole("menuitem", { name: /Modelle/i }).click();
cy.wait("@getCollectionContent");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage

View File

@@ -23,7 +23,7 @@ describeWithSnowplow("scenarios > browse data", () => {
it("can browse to a model", () => {
cy.visit("/");
cy.findByRole("listitem", { name: "Browse data" }).click();
cy.findByRole("listitem", { name: "Browse models" }).click();
cy.location("pathname").should("eq", "/browse/models");
cy.findByTestId("browse-app").findByText("Browse data");
cy.findByRole("heading", { name: "Orders Model" }).click();
@@ -34,6 +34,7 @@ describeWithSnowplow("scenarios > browse data", () => {
model_id: ORDERS_MODEL_ID,
});
});
it("can browse to a table", () => {
cy.visit("/");
cy.findByRole("listitem", { name: "Browse data" }).click();
@@ -60,29 +61,9 @@ describeWithSnowplow("scenarios > browse data", () => {
cy.findByRole("heading", { name: "Products" }).click();
cy.findByRole("gridcell", { name: "Rustic Paper Wallet" });
});
it("the Browse data page shows the last-used tab by default", () => {
cy.visit("/");
cy.findByRole("listitem", { name: "Browse data" }).click();
cy.log(
"/browse/ defaults to /browse/models/ because no tabs have been visited yet and there are some models to show",
);
cy.location("pathname").should("eq", "/browse/models");
cy.findByRole("tab", { name: "Databases" }).click();
cy.findByRole("listitem", { name: "Browse data" }).click();
cy.log(
"/browse/ now defaults to /browse/databases/ because it was the last tab visited",
);
cy.location("pathname").should("eq", "/browse/databases");
cy.findByRole("tab", { name: "Models" }).click();
cy.findByRole("listitem", { name: "Browse data" }).click();
cy.log(
"/browse/ now defaults to /browse/models/ because it was the last tab visited",
);
cy.location("pathname").should("eq", "/browse/models");
});
it("/browse/models has no switch for controlling the 'only show verified models' filter, on an open-source instance", () => {
cy.visit("/");
cy.findByRole("listitem", { name: "Browse data" }).click();
cy.findByRole("listitem", { name: "Browse models" }).click();
cy.findByRole("switch", { name: /Only show verified models/ }).should(
"not.exist",
);
@@ -102,7 +83,7 @@ describeWithSnowplowEE("scenarios > browse data (EE)", () => {
cy.findByRole("switch", { name: /Only show verified models/ });
setTokenFeatures("all");
cy.visit("/");
cy.findByRole("listitem", { name: "Browse data" }).click();
cy.findByRole("listitem", { name: "Browse models" }).click();
cy.findByRole("heading", { name: "Our analytics" }).should("not.exist");
cy.findByRole("heading", { name: "Orders Model" }).should("not.exist");
toggle().next("label").click();

View File

@@ -23,7 +23,6 @@ describe("scenarios > question > bookmarks", () => {
navigationSidebar().within(() => {
getSectionTitle(/Bookmarks/);
cy.findByText("Orders");
cy.icon("model").should("not.exist");
});
// Rename bookmarked question
@@ -49,6 +48,7 @@ describe("scenarios > question > bookmarks", () => {
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Turn back to saved question").click();
openNavigationSidebar();
navigationSidebar().within(() => {
cy.icon("model").should("not.exist");
});

View File

@@ -26,7 +26,7 @@ describe("11914, 18978, 18977", () => {
it("should not display query editing controls and 'Browse Data' link", () => {
cy.log("Make sure we don't prompt user to browse data from the sidebar");
openNavigationSidebar();
cy.findByTestId("main-navbar-root").should("not.contain", "Browse data");
cy.findByLabelText("Browse data").should("not.exist");
cy.log("Make sure we don't prompt user to create a new query");
appBar().icon("add").click();

View File

@@ -1,7 +1,7 @@
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import {
browse,
browseData,
restore,
openOrdersTable,
openNavigationSidebar,
@@ -455,8 +455,8 @@ describe("scenarios > question > settings", () => {
// create a new question to see if the "add to a dashboard" modal is still there
openNavigationSidebar();
browse().click();
cy.findByRole("tab", { name: "Databases" }).click();
browseData().click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.contains("Sample Database").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage

View File

@@ -1,46 +1,81 @@
import { useCallback } from "react";
import { t } from "ttag";
import _ from "underscore";
import type { ModelFilterControlsProps } from "metabase/browse/utils";
import { Switch, Text } from "metabase/ui";
import type {
ActualModelFilters,
ModelFilterControlsProps,
} from "metabase/browse/utils";
import { useUserSetting } from "metabase/common/hooks";
import { Button, Icon, Popover, Switch, Text } from "metabase/ui";
export const ModelFilterControls = ({
actualModelFilters,
handleModelFilterChange,
setActualModelFilters,
}: ModelFilterControlsProps) => {
const [__, setVerifiedFilterStatus] = useUserSetting(
"browse-filter-only-verified-models",
{ shouldRefresh: false },
);
const setVerifiedFilterStatusDebounced = _.debounce(
setVerifiedFilterStatus,
200,
);
const handleModelFilterChange = useCallback(
(modelFilterName: string, active: boolean) => {
// For now, only one filter is supported
setVerifiedFilterStatusDebounced(active);
setActualModelFilters((prev: ActualModelFilters) => {
return { ...prev, [modelFilterName]: active };
});
},
[setActualModelFilters, setVerifiedFilterStatusDebounced],
);
const checked = actualModelFilters.onlyShowVerifiedModels;
return (
<Switch
label={
<Text
align="end"
weight="bold"
lh="1rem"
px=".75rem"
>{t`Only show verified models`}</Text>
}
role="switch"
checked={checked}
aria-checked={checked}
onChange={e => {
handleModelFilterChange("onlyShowVerifiedModels", e.target.checked);
}}
size="sm"
labelPosition="left"
styles={{
root: {
marginInlineStart: "auto",
display: "flex",
alignItems: "center",
},
body: {
alignItems: "center",
// Align with tab labels:
position: "relative",
top: "-.5px",
},
labelWrapper: { justifyContent: "center", padding: 0 },
track: { marginTop: "-1.5px" },
}}
/>
<Popover position="bottom-end">
<Popover.Target>
<Button p="sm" variant="subtle" color="text-dark">
<Icon name="filter" />
</Button>
</Popover.Target>
<Popover.Dropdown p="lg">
<Switch
label={
<Text
align="end"
weight="bold"
lh="1rem"
px=".75rem"
>{t`Only show verified models`}</Text>
}
role="switch"
checked={checked}
aria-checked={checked}
onChange={e => {
handleModelFilterChange("onlyShowVerifiedModels", e.target.checked);
}}
size="sm"
labelPosition="left"
styles={{
root: {
marginInlineStart: "auto",
display: "flex",
alignItems: "center",
},
body: {
alignItems: "center",
// Align with tab labels:
position: "relative",
top: "-.5px",
},
labelWrapper: { justifyContent: "center", padding: 0 },
track: { marginTop: "-1.5px" },
}}
/>
</Popover.Dropdown>
</Popover>
);
};

View File

@@ -7,6 +7,7 @@ import {
availableModelFilters,
sortCollectionsByVerification,
sortModelsByVerification,
useModelFilterSettings,
} from "./utils";
if (hasPremiumFeature("content_verification")) {
@@ -16,5 +17,6 @@ if (hasPremiumFeature("content_verification")) {
availableModelFilters,
sortModelsByVerification,
sortCollectionsByVerification,
useModelFilterSettings,
});
}

View File

@@ -1,4 +1,10 @@
import type { AvailableModelFilters } from "metabase/browse/utils";
import { useMemo } from "react";
import type {
ActualModelFilters,
AvailableModelFilters,
} from "metabase/browse/utils";
import { useUserSetting } from "metabase/common/hooks";
import type { CollectionEssentials, SearchResult } from "metabase-types/api";
export const sortCollectionsByVerification = (
@@ -34,3 +40,16 @@ export const availableModelFilters: AvailableModelFilters = {
activeByDefault: true,
},
};
export const useModelFilterSettings = (): ActualModelFilters => {
const [initialVerifiedFilterStatus, __] = useUserSetting(
"browse-filter-only-verified-models",
{ shouldRefresh: false },
);
return useMemo(
() => ({
onlyShowVerifiedModels: initialVerifiedFilterStatus ?? false,
}),
[initialVerifiedFilterStatus],
);
};

View File

@@ -59,7 +59,7 @@ export interface Collection {
location: string | null;
effective_location?: string; // location path containing only those collections that the user has permission to access
effective_ancestors?: Collection[];
effective_ancestors?: Pick<Collection, "id" | "name">[];
here?: CollectionContentModel[];
below?: CollectionContentModel[];
@@ -110,6 +110,7 @@ export interface CollectionItem {
setPinned?: (isPinned: boolean) => void;
setCollection?: (collection: Pick<Collection, "id">) => void;
setCollectionPreview?: (isEnabled: boolean) => void;
collection_ancestors?: Pick<Collection, "id" | "name">[];
}
export interface CollectionListQuery {

View File

@@ -166,6 +166,8 @@ export const createMockSettings = (
"enable-enhancements?": false,
"enable-nested-queries": true,
"enable-query-caching": undefined,
"expand-browse-in-nav": true,
"expand-bookmarks-in-nav": true,
"query-caching-ttl-ratio": 10,
"query-caching-min-ttl": 60,
"enable-password-login": true,

View File

@@ -72,7 +72,8 @@ export type SearchResponse<
export type CollectionEssentials = Pick<
Collection,
"id" | "name" | "authority_level" | "type"
>;
> &
Partial<Collection>;
export type SearchResultId =
| CollectionId
@@ -119,6 +120,11 @@ export interface SearchResult<
can_write: boolean | null;
}
export enum SortDirection {
Asc = "asc",
Desc = "desc",
}
export type SearchRequest = {
q?: string;
archived?: boolean;
@@ -132,6 +138,12 @@ export type SearchRequest = {
last_edited_by?: UserId[];
search_native_query?: boolean | null;
verified?: boolean | null;
model_ancestors?: boolean | null;
// TODO: Support these in the BE. Note that sorting on collection_path is tricky because this string is assembled in the FE
// Is it reaaaaally necessary to do this via the BE or can we just sort in the FE? Will there be a real performance hit? =
// And are we supporting pagination?
sort_column?: string;
sort_direction?: SortDirection;
// this should be in ListCollectionItemsRequest but legacy code expects them here
collection?: CollectionId;

View File

@@ -315,13 +315,19 @@ interface PublicSettings {
"version-info-last-checked": string | null;
}
export interface UserSettings {
export interface BrowseFilterSettings {
"browse-filter-only-verified-models"?: boolean;
}
export type UserSettings = {
"dismissed-browse-models-banner"?: boolean;
"dismissed-custom-dashboard-toast"?: boolean;
"last-used-native-database-id"?: number | null;
"notebook-native-preview-shown"?: boolean;
"notebook-native-preview-sidebar-width"?: number | null;
}
"expand-browse-in-nav"?: boolean;
"expand-bookmarks-in-nav"?: boolean;
} & BrowseFilterSettings;
export type Settings = InstanceSettings &
PublicSettings &

View File

@@ -422,19 +422,9 @@ const MultiplierFieldSubtitle = () => (
<div>
{t`To determine how long each cached result should stick around, we take that query's average execution time and multiply that by what you input here. The result is how many seconds the cache should remain valid for.`}{" "}
<Tooltip
events={{
hover: true,
focus: true,
touch: true,
}}
variant="multiline"
inline={true}
styles={{
tooltip: {
whiteSpace: "normal",
},
}}
label={t`If a query takes on average 120 seconds (2 minutes) to run, and you input 10 for your multiplier, its cache entry will persist for 1,200 seconds (20 minutes).`}
maw="20rem"
>
<Text tabIndex={0} lh="1" display="inline" c="brand">
{t`Example`}

View File

@@ -6,10 +6,10 @@ import { provideSearchItemListTags } from "./tags";
export const searchApi = Api.injectEndpoints({
endpoints: builder => ({
search: builder.query<SearchResponse, SearchRequest>({
query: ({ limit, offset, ...body }) => ({
query: ({ limit, offset, sort_column, sort_direction, ...body }) => ({
method: "GET",
url: "/api/search",
params: { limit, offset },
params: { limit, offset, sort_column, sort_direction },
body,
}),
providesTags: (response, error, { models }) =>

View File

@@ -6,7 +6,7 @@ import {
breakpointMinMedium,
breakpointMinSmall,
} from "metabase/styled-components/theme";
import { Grid, Icon, Tabs } from "metabase/ui";
import { Flex, Grid, Icon, Tabs } from "metabase/ui";
export const BrowseAppRoot = styled.div`
flex: 1;
@@ -38,27 +38,35 @@ export const BrowseTab = styled(Tabs.Tab)`
}
`;
export const BrowseTabsPanel = styled(Tabs.Panel)`
display: flex;
flex-flow: column nowrap;
flex: 1;
height: 100%;
padding: 0 2.5rem;
`;
export const BrowseContainer = styled.div`
display: flex;
flex: 1;
flex-flow: column nowrap;
height: 100%;
margin-top: 1rem;
container-name: ItemsTableContainer;
container-type: inline-size;
`;
export const BrowseDataHeader = styled.header`
export const BrowseSection = styled(Flex)`
max-width: 64rem;
margin: 0 auto;
width: 100%;
`;
export const BrowseHeader = styled.header`
display: flex;
padding: 1rem 2.5rem;
padding-bottom: 0.375rem;
flex-direction: column;
padding: 1rem 2.5rem 3rem 2.5rem;
color: ${color("dark")};
background-color: ${color("white")};
`;
export const BrowseMain = styled.main`
display: flex;
flex-flow: column nowrap;
flex: 1;
padding: 0 2.5rem;
padding-bottom: 2rem;
`;
export const BrowseGrid = styled(Grid)`

View File

@@ -1,186 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { push } from "react-router-redux";
import { t } from "ttag";
import _ from "underscore";
import {
useDatabaseListQuery,
useSearchListQuery,
} from "metabase/common/hooks";
import Link from "metabase/core/components/Link";
import { useDispatch } from "metabase/lib/redux";
import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins";
import type { FlexProps } from "metabase/ui";
import { Flex, Text } from "metabase/ui";
import type { SearchResult } from "metabase-types/api";
import type { ActualModelFilters, BrowseTabId } from "../utils";
import { filterModels, isValidBrowseTab } from "../utils";
import {
BrowseAppRoot,
BrowseContainer,
BrowseDataHeader,
BrowseTab,
BrowseTabs,
BrowseTabsList,
BrowseTabsPanel,
LearnAboutDataIcon,
} from "./BrowseApp.styled";
import { BrowseDatabases } from "./BrowseDatabases";
import { BrowseHeaderIconContainer } from "./BrowseHeader.styled";
import { BrowseModels } from "./BrowseModels";
const availableModelFilters = PLUGIN_CONTENT_VERIFICATION.availableModelFilters;
export const BrowseApp = ({
tab,
children,
}: {
tab: BrowseTabId;
children?: React.ReactNode;
}) => {
const dispatch = useDispatch();
const modelsResult = useSearchListQuery<SearchResult>({
query: {
models: ["dataset"],
filter_items_in_personal_collection: "exclude",
},
});
const databasesResult = useDatabaseListQuery();
useEffect(() => {
localStorage.setItem("defaultBrowseTab", tab);
}, [tab]);
const getInitialModelFilters = () => {
return _.reduce(
availableModelFilters,
(acc, filter, filterName) => {
const storedFilterStatus = localStorage.getItem(
`browseFilters.${filterName}`,
);
const shouldFilterBeActive =
storedFilterStatus === null
? filter.activeByDefault
: storedFilterStatus === "on";
return {
...acc,
[filterName]: shouldFilterBeActive,
};
},
{},
);
};
const [actualModelFilters, setActualModelFilters] =
useState<ActualModelFilters>(getInitialModelFilters);
const { data: unfilteredModels = [] } = modelsResult;
const filteredModels = useMemo(
() =>
filterModels(unfilteredModels, actualModelFilters, availableModelFilters),
[unfilteredModels, actualModelFilters],
);
const filteredModelsResult = { ...modelsResult, data: filteredModels };
const handleModelFilterChange = useCallback(
(modelFilterName: string, active: boolean) => {
localStorage.setItem(
`browseFilters.${modelFilterName}`,
active ? "on" : "off",
);
setActualModelFilters((prev: ActualModelFilters) => {
return { ...prev, [modelFilterName]: active };
});
},
[setActualModelFilters],
);
return (
<BrowseAppRoot data-testid="browse-app">
<BrowseContainer>
<BrowseDataHeader>
<BrowseSection>
<h2>{t`Browse data`}</h2>
</BrowseSection>
</BrowseDataHeader>
<BrowseTabs
value={tab}
onTabChange={value => {
if (isValidBrowseTab(value)) {
dispatch(push(`/browse/${value}`));
}
}}
>
<BrowseTabsList>
<BrowseSection>
<BrowseTab key={"models"} value={"models"}>
{t`Models`}
</BrowseTab>
<BrowseTab key={"databases"} value={"databases"}>
{t`Databases`}
</BrowseTab>
{tab === "models" && (
<PLUGIN_CONTENT_VERIFICATION.ModelFilterControls
actualModelFilters={actualModelFilters}
handleModelFilterChange={handleModelFilterChange}
/>
)}
{tab === "databases" && <LearnAboutDataLink />}
</BrowseSection>
</BrowseTabsList>
<BrowseTabsPanel key={tab} value={tab}>
<BrowseSection direction="column">
<BrowseTabContent
tab={tab}
modelsResult={filteredModelsResult}
databasesResult={databasesResult}
>
{children}
</BrowseTabContent>
</BrowseSection>
</BrowseTabsPanel>
</BrowseTabs>
</BrowseContainer>
</BrowseAppRoot>
);
};
const BrowseTabContent = ({
tab,
children,
modelsResult,
databasesResult,
}: {
tab: BrowseTabId;
children?: React.ReactNode;
modelsResult: ReturnType<typeof useSearchListQuery<SearchResult>>;
databasesResult: ReturnType<typeof useDatabaseListQuery>;
}) => {
if (children) {
return <>{children}</>;
}
if (tab === "models") {
return <BrowseModels modelsResult={modelsResult} />;
} else {
return <BrowseDatabases databasesResult={databasesResult} />;
}
};
const LearnAboutDataLink = () => (
<Flex ml="auto" justify="right" align="center" style={{ flexBasis: "40.0%" }}>
<Link to="reference">
<BrowseHeaderIconContainer>
<LearnAboutDataIcon size={14} name="reference" />
<Text size="md" lh="1" fw="bold" ml=".5rem" c="inherit">
{t`Learn about our data`}
</Text>
</BrowseHeaderIconContainer>
</Link>
</Flex>
);
const BrowseSection = (props: FlexProps) => (
<Flex h="100%" maw="64rem" m="0 auto" w="100%" {...props} />
);

View File

@@ -1,26 +1,32 @@
import { Link } from "react-router";
import { t } from "ttag";
import NoResults from "assets/img/no_results.svg";
import type { useDatabaseListQuery } from "metabase/common/hooks";
import { useListDatabasesQuery } from "metabase/api";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import CS from "metabase/css/core/index.css";
import { color } from "metabase/lib/colors";
import * as Urls from "metabase/lib/urls";
import { Box, Icon, Title } from "metabase/ui";
import { Box, Flex, Group, Icon, Text, Title } from "metabase/ui";
import { CenteredEmptyState } from "./BrowseApp.styled";
import {
BrowseContainer,
BrowseHeader,
BrowseMain,
BrowseSection,
CenteredEmptyState,
LearnAboutDataIcon,
} from "./BrowseApp.styled";
import {
DatabaseCard,
DatabaseCardLink,
DatabaseGrid,
} from "./BrowseDatabases.styled";
import { BrowseHeaderIconContainer } from "./BrowseHeader.styled";
export const BrowseDatabases = ({
databasesResult,
}: {
databasesResult: ReturnType<typeof useDatabaseListQuery>;
}) => {
const { data: databases, error, isLoading } = databasesResult;
export const BrowseDatabases = () => {
const { data, isLoading, error } = useListDatabasesQuery();
const databases = data?.data;
if (error) {
return <LoadingAndErrorWrapper error />;
@@ -30,34 +36,76 @@ export const BrowseDatabases = ({
return <LoadingAndErrorWrapper loading />;
}
return databases?.length ? (
<DatabaseGrid data-testid="database-browser">
{databases.map(database => (
<div key={database.id}>
<DatabaseCardLink to={Urls.browseDatabase(database)}>
<DatabaseCard>
<Icon
name="database"
color={color("accent2")}
className={CS.mb3}
size={32}
/>
<Title order={2} size="1rem" lh="1rem" color="inherit">
{database.name}
</Title>
</DatabaseCard>
</DatabaseCardLink>
</div>
))}
</DatabaseGrid>
) : (
<CenteredEmptyState
title={<Box mb=".5rem">{t`No databases here yet`}</Box>}
illustrationElement={
<Box mb=".5rem">
<img src={NoResults} />
</Box>
}
/>
if (!databases?.length) {
return (
<CenteredEmptyState
title={<Box mb=".5rem">{t`No databases here yet`}</Box>}
illustrationElement={
<Box mb=".5rem">
<img src={NoResults} />
</Box>
}
/>
);
}
return (
<BrowseContainer>
<BrowseHeader>
<BrowseSection>
<Flex w="100%" direction="row" justify="space-between" align="center">
<Title order={1} color="text-dark">
<Group spacing="sm">
<Icon color={color("brand")} name="database" />
{t`Databases`}
</Group>
</Title>
<LearnAboutDataLink />
</Flex>
</BrowseSection>
</BrowseHeader>
<BrowseMain>
<BrowseSection>
<DatabaseGrid data-testid="database-browser">
{databases.map(database => (
<div key={database.id}>
<DatabaseCardLink to={Urls.browseDatabase(database)}>
<DatabaseCard>
<Icon
name="database"
color={color("accent2")}
className={CS.mb3}
size={32}
/>
<Title order={2} size="1rem" lh="1rem" color="inherit">
{database.name}
</Title>
</DatabaseCard>
</DatabaseCardLink>
</div>
))}
</DatabaseGrid>
</BrowseSection>
</BrowseMain>
</BrowseContainer>
);
};
const LearnAboutDataLink = () => (
<Flex
ml="auto"
p=".75rem"
justify="right"
align="center"
style={{ flexBasis: "40.0%" }}
>
<Link to="reference">
<BrowseHeaderIconContainer>
<LearnAboutDataIcon size={14} name="reference" />
<Text size="md" lh="1" fw="bold" ml=".5rem" c="inherit">
{t`Learn about our data`}
</Text>
</BrowseHeaderIconContainer>
</Link>
</Flex>
);

View File

@@ -1,21 +1,17 @@
import { setupDatabasesEndpoints } from "__support__/server-mocks";
import { renderWithProviders, screen } from "__support__/ui";
import type Database from "metabase-lib/v1/metadata/Database";
import { createMockDatabase } from "metabase-types/api/mocks";
import { BrowseDatabases } from "./BrowseDatabases";
const renderBrowseDatabases = (modelCount: number) => {
const databases = mockDatabases.slice(0, modelCount);
return renderWithProviders(
<BrowseDatabases
databasesResult={{ data: databases, isLoading: false, error: false }}
/>,
);
setupDatabasesEndpoints(databases);
return renderWithProviders(<BrowseDatabases />);
};
const mockDatabases = [...Array(100)].map(
(_, index) =>
createMockDatabase({ id: index, name: `Database ${index}` }) as Database,
const mockDatabases = [...Array(100)].map((_, index) =>
createMockDatabase({ id: index, name: `Database ${index}` }),
);
describe("BrowseDatabases", () => {

View File

@@ -1,119 +1,55 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { t } from "ttag";
import NoResults from "assets/img/no_results.svg";
import type { useSearchListQuery } from "metabase/common/hooks";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import { useSelector } from "metabase/lib/redux";
import { getLocale } from "metabase/setup/selectors";
import { Box } from "metabase/ui";
import type { SearchResult, CollectionId } from "metabase-types/api";
import { color } from "metabase/lib/colors";
import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins";
import { Flex, Group, Icon, Title } from "metabase/ui";
import { BROWSE_MODELS_LOCALSTORAGE_KEY } from "../constants";
import { getCollectionViewPreferences, groupModels } from "../utils";
import type { ActualModelFilters } from "../utils";
import { CenteredEmptyState } from "./BrowseApp.styled";
import { ModelGrid } from "./BrowseModels.styled";
import { ModelExplanationBanner } from "./ModelExplanationBanner";
import { ModelGroup } from "./ModelGroup";
import {
BrowseContainer,
BrowseHeader,
BrowseMain,
BrowseSection,
} from "./BrowseApp.styled";
import { BrowseModelsBody } from "./BrowseModelsBody";
export const BrowseModels = ({
modelsResult,
}: {
modelsResult: ReturnType<typeof useSearchListQuery<SearchResult>>;
}) => {
const { data: models = [], error, isLoading } = modelsResult;
const locale = useSelector(getLocale);
const localeCode: string | undefined = locale?.code;
const [collectionViewPreferences, setCollectionViewPreferences] = useState(
getCollectionViewPreferences,
);
const { useModelFilterSettings } = PLUGIN_CONTENT_VERIFICATION;
if (error || isLoading) {
return (
<LoadingAndErrorWrapper
error={error}
loading={isLoading}
style={{ display: "flex", flex: 1 }}
/>
);
}
export const BrowseModels = () => {
const initialModelFilters = useModelFilterSettings();
const handleToggleCollectionExpand = (collectionId: CollectionId) => {
const newPreferences = {
...collectionViewPreferences,
[collectionId]: {
expanded: !(
collectionViewPreferences?.[collectionId]?.expanded ?? true
),
showAll: !!collectionViewPreferences?.[collectionId]?.showAll,
},
};
setCollectionViewPreferences(newPreferences);
localStorage.setItem(
BROWSE_MODELS_LOCALSTORAGE_KEY,
JSON.stringify(newPreferences),
);
};
const [actualModelFilters, setActualModelFilters] =
useState<ActualModelFilters>(initialModelFilters);
const handleToggleCollectionShowAll = (collectionId: CollectionId) => {
const newPreferences = {
...collectionViewPreferences,
[collectionId]: {
expanded: collectionViewPreferences?.[collectionId]?.expanded ?? true,
showAll: !collectionViewPreferences?.[collectionId]?.showAll,
},
};
setCollectionViewPreferences(newPreferences);
localStorage.setItem(
BROWSE_MODELS_LOCALSTORAGE_KEY,
JSON.stringify(newPreferences),
);
};
const groupsOfModels = groupModels(models, localeCode);
if (models.length) {
return (
<>
<ModelExplanationBanner />
<ModelGrid role="grid">
{groupsOfModels.map(groupOfModels => {
const collectionId = groupOfModels[0].collection.id;
return (
<ModelGroup
expanded={
collectionViewPreferences?.[collectionId]?.expanded ?? true
}
showAll={!!collectionViewPreferences?.[collectionId]?.showAll}
toggleExpanded={() =>
handleToggleCollectionExpand(collectionId)
}
toggleShowAll={() =>
handleToggleCollectionShowAll(collectionId)
}
models={groupOfModels}
key={`modelgroup-${collectionId}`}
localeCode={localeCode}
/>
);
})}
</ModelGrid>
</>
);
}
useEffect(() => {
setActualModelFilters(initialModelFilters);
}, [initialModelFilters, setActualModelFilters]);
return (
<CenteredEmptyState
title={<Box mb=".5rem">{t`No models here yet`}</Box>}
message={
<Box maw="24rem">{t`Models help curate data to make it easier to find answers to questions all in one place.`}</Box>
}
illustrationElement={
<Box mb=".5rem">
<img src={NoResults} />
</Box>
}
/>
<BrowseContainer>
<BrowseHeader>
<BrowseSection>
<Flex w="100%" direction="row" justify="space-between" align="center">
<Title order={1} color="text-dark">
<Group spacing="sm">
<Icon size={18} color={color("brand")} name="model" />
{t`Models`}
</Group>
</Title>
<PLUGIN_CONTENT_VERIFICATION.ModelFilterControls
actualModelFilters={actualModelFilters}
setActualModelFilters={setActualModelFilters}
/>
</Flex>
</BrowseSection>
</BrowseHeader>
<BrowseMain>
<BrowseSection>
<BrowseModelsBody actualModelFilters={actualModelFilters} />
</BrowseSection>
</BrowseMain>
</BrowseContainer>
);
};

View File

@@ -1,5 +1,7 @@
import userEvent from "@testing-library/user-event";
import {
setupSearchEndpoints,
setupSettingsEndpoints,
} from "__support__/server-mocks";
import { renderWithProviders, screen } from "__support__/ui";
import { defaultRootCollection } from "metabase/admin/permissions/pages/CollectionPermissionsPage/tests/setup";
import type { SearchResult } from "metabase-types/api";
@@ -9,24 +11,19 @@ import {
} from "metabase-types/api/mocks";
import { createMockSetupState } from "metabase-types/store/mocks";
import { BROWSE_MODELS_LOCALSTORAGE_KEY } from "../constants";
import { BrowseModels } from "./BrowseModels";
const renderBrowseModels = (modelCount: number) => {
const setup = (modelCount: number) => {
const models = mockModels.slice(0, modelCount);
return renderWithProviders(
<BrowseModels
modelsResult={{ data: models, isLoading: false, error: false }}
/>,
{
storeInitialState: {
setup: createMockSetupState({
locale: { name: "English", code: "en" },
}),
},
setupSearchEndpoints(models);
setupSettingsEndpoints([]);
return renderWithProviders(<BrowseModels />, {
storeInitialState: {
setup: createMockSetupState({
locale: { name: "English", code: "en" },
}),
},
);
});
};
const collectionAlpha = createMockCollection({ id: 99, name: "Alpha" });
@@ -212,154 +209,19 @@ const mockModels: SearchResult[] = [
].map(model => createMockModelResult(model));
describe("BrowseModels", () => {
beforeEach(() => {
localStorage.clear();
});
it("displays a 'no models' message in the Models tab when no models exist", async () => {
renderBrowseModels(0);
setup(0);
expect(await screen.findByText("No models here yet")).toBeInTheDocument();
});
it("displays collection groups", async () => {
renderBrowseModels(10);
expect(await screen.findByText("Alpha")).toBeInTheDocument();
expect(await screen.findByText("Beta")).toBeInTheDocument();
expect(await screen.findByText("Charlie")).toBeInTheDocument();
expect(await screen.findByText("Delta")).toBeInTheDocument();
});
it("displays models in collections by default", () => {
const modelCount = 22;
renderBrowseModels(modelCount);
expect(screen.queryByText("No models here yet")).not.toBeInTheDocument();
assertThatModelsExist(0, modelCount - 1);
});
it("can collapse collections to hide models within them", async () => {
renderBrowseModels(10);
await userEvent.click(await screen.findByLabelText("collapse Alpha"));
expect(screen.queryByText("Model 0")).not.toBeInTheDocument();
expect(screen.queryByText("Model 1")).not.toBeInTheDocument();
expect(screen.queryByText("Model 2")).not.toBeInTheDocument();
await userEvent.click(await screen.findByLabelText("collapse Beta"));
expect(screen.queryByText("Model 3")).not.toBeInTheDocument();
expect(screen.queryByText("Model 4")).not.toBeInTheDocument();
expect(screen.queryByText("Model 5")).not.toBeInTheDocument();
});
it("can expand a collection to see models within it", async () => {
renderBrowseModels(10);
await userEvent.click(await screen.findByLabelText("collapse Alpha"));
expect(screen.queryByText("Model 0")).not.toBeInTheDocument();
await userEvent.click(await screen.findByLabelText("expand Alpha"));
expect(await screen.findByText("Model 0")).toBeInTheDocument();
});
it("displays the Our Analytics collection if it has a model", async () => {
renderBrowseModels(25);
await screen.findByText("Alpha");
await screen.findByText("Our analytics");
setup(25);
expect(await screen.findByRole("table")).toBeInTheDocument();
expect(
await screen.findAllByTestId("path-for-collection: Our analytics"),
).toHaveLength(2);
expect(await screen.findByText("Model 20")).toBeInTheDocument();
expect(await screen.findByText("Model 21")).toBeInTheDocument();
expect(await screen.findByText("Model 22")).toBeInTheDocument();
});
it("shows the first six models in a collection by default", async () => {
renderBrowseModels(9999);
expect(await screen.findByText("100 models")).toBeInTheDocument();
expect(await screen.findByText("Show all")).toBeInTheDocument();
assertThatModelsExist(300, 305);
});
it("can show more than 6 models by clicking 'Show all'", async () => {
renderBrowseModels(9999);
await screen.findByText("6 of 100");
expect(screen.queryByText("Model 350")).not.toBeInTheDocument();
await userEvent.click(await screen.findByText("Show all"));
assertThatModelsExist(300, 399);
});
it("can show less than all models by clicking 'Show less'", async () => {
renderBrowseModels(9999);
expect(screen.queryByText("Model 399")).not.toBeInTheDocument();
await userEvent.click(await screen.findByText("Show all"));
await screen.findByText("Model 301");
expect(screen.getByText("Model 399")).toBeInTheDocument();
await userEvent.click(await screen.findByText("Show less"));
await screen.findByText("Model 301");
expect(screen.queryByText("Model 399")).not.toBeInTheDocument();
});
it("persists show-all state when expanding and collapsing collections", async () => {
renderBrowseModels(9999);
await userEvent.click(screen.getByText("Show all"));
expect(await screen.findByText("Model 301")).toBeInTheDocument();
expect(screen.getByText("Model 399")).toBeInTheDocument();
await userEvent.click(screen.getByLabelText("collapse Grande"));
expect(screen.queryByText("Model 301")).not.toBeInTheDocument();
expect(screen.queryByText("Model 399")).not.toBeInTheDocument();
await userEvent.click(screen.getByLabelText("expand Grande"));
expect(await screen.findByText("Model 301")).toBeInTheDocument();
expect(screen.getByText("Model 399")).toBeInTheDocument();
});
describe("local storage", () => {
it("persists the expanded state of collections in local storage", async () => {
renderBrowseModels(10);
await userEvent.click(await screen.findByLabelText("collapse Alpha"));
expect(screen.queryByText("Model 0")).not.toBeInTheDocument();
expect(localStorage.getItem(BROWSE_MODELS_LOCALSTORAGE_KEY)).toEqual(
JSON.stringify({ 99: { expanded: false, showAll: false } }),
);
});
it("loads the collapsed state of collections from local storage", async () => {
localStorage.setItem(
BROWSE_MODELS_LOCALSTORAGE_KEY,
JSON.stringify({ 99: { expanded: false, showAll: false } }),
);
renderBrowseModels(10);
expect(screen.queryByText("Model 0")).not.toBeInTheDocument();
});
it("persists the 'show all' state of collections in local storage", async () => {
renderBrowseModels(9999);
await userEvent.click(await screen.findByText("Show all"));
await screen.findByText("Model 399");
expect(localStorage.getItem(BROWSE_MODELS_LOCALSTORAGE_KEY)).toEqual(
JSON.stringify({ 7: { expanded: true, showAll: true } }),
);
});
it("loads the 'show all' state of collections from local storage", async () => {
localStorage.setItem(
BROWSE_MODELS_LOCALSTORAGE_KEY,
JSON.stringify({ 7: { expanded: true, showAll: true } }),
);
renderBrowseModels(9999);
expect(await screen.findByText("Show less")).toBeInTheDocument();
assertThatModelsExist(300, 399);
});
it("can deal with invalid local storage data", async () => {
localStorage.setItem(BROWSE_MODELS_LOCALSTORAGE_KEY, "{invalid json[[[}");
renderBrowseModels(10);
expect(await screen.findByText("Model 0")).toBeInTheDocument();
await userEvent.click(await screen.findByLabelText("collapse Alpha"));
expect(screen.queryByText("Model 0")).not.toBeInTheDocument();
// ignores invalid data and persists the new state
expect(localStorage.getItem(BROWSE_MODELS_LOCALSTORAGE_KEY)).toEqual(
JSON.stringify({ 99: { expanded: false, showAll: false } }),
);
});
});
});
function assertThatModelsExist(startId: number, endId: number) {
for (let i = startId; i <= endId; i++) {
expect(screen.getByText(`Model ${i}`)).toBeInTheDocument();
}
}

View File

@@ -0,0 +1,116 @@
import { useMemo, useState } from "react";
import { t } from "ttag";
import _ from "underscore";
import NoResults from "assets/img/no_results.svg";
import { useSearchQuery } from "metabase/api";
import type { SortingOptions } from "metabase/components/ItemsTable/BaseItemsTable";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import Search from "metabase/entities/search";
import { useDispatch } from "metabase/lib/redux";
import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins";
import { Box, Stack } from "metabase/ui";
import type { CollectionEssentials, SearchRequest } from "metabase-types/api";
import { SortDirection } from "metabase-types/api";
import { filterModels, type ActualModelFilters } from "../utils";
import { CenteredEmptyState } from "./BrowseApp.styled";
import { ModelExplanationBanner } from "./ModelExplanationBanner";
import { ModelsTable } from "./ModelsTable";
import { getCollectionPathString } from "./utils";
const { availableModelFilters } = PLUGIN_CONTENT_VERIFICATION;
export const BrowseModelsBody = ({
actualModelFilters,
}: {
actualModelFilters: ActualModelFilters;
}) => {
const dispatch = useDispatch();
const [sortingOptions, setSortingOptions] = useState<SortingOptions>({
sort_column: "name",
sort_direction: SortDirection.Asc,
});
const query: SearchRequest = {
models: ["dataset"], // 'model' in the sense of 'type of thing'
model_ancestors: true,
filter_items_in_personal_collection: "exclude",
};
const { data, error, isLoading } = useSearchQuery(query);
const unfilteredModels = useMemo(() => data?.data, [data]);
const filteredModels = useMemo(
() =>
filterModels(
unfilteredModels || [],
actualModelFilters,
availableModelFilters,
),
[unfilteredModels, actualModelFilters],
);
const sortedModels = useMemo(() => {
const { sort_column, sort_direction } = sortingOptions;
const sorted = _.sortBy(filteredModels, model => {
if (sort_column === "collection") {
const collection: CollectionEssentials = model.collection;
return getCollectionPathString(collection);
}
if (sort_column in model) {
return model[sort_column as keyof typeof model];
} else {
console.error("Invalid sort column", sort_column);
return null;
}
});
if (sort_direction === SortDirection.Desc) {
sorted.reverse();
}
return sorted;
}, [filteredModels, sortingOptions]);
const wrappedModels = useMemo(
() => sortedModels.map(model => Search.wrapEntity(model, dispatch)),
[sortedModels, dispatch],
);
if (error || isLoading) {
return (
<LoadingAndErrorWrapper
error={error}
loading={isLoading}
style={{ display: "flex", flex: 1 }}
/>
);
}
if (filteredModels.length) {
return (
<Stack spacing="md" mb="lg">
<ModelExplanationBanner />
<ModelsTable
items={wrappedModels}
sortingOptions={sortingOptions}
onSortingOptionsChange={setSortingOptions}
/>
</Stack>
);
}
return (
<CenteredEmptyState
title={<Box mb=".5rem">{t`No models here yet`}</Box>}
message={
<Box maw="24rem">{t`Models help curate data to make it easier to find answers to questions all in one place.`}</Box>
}
illustrationElement={
<Box mb=".5rem">
<img src={NoResults} />
</Box>
}
/>
);
};

View File

@@ -1,61 +0,0 @@
import { useEffect } from "react";
import { replace } from "react-router-redux";
import { useSearchListQuery } from "metabase/common/hooks";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import { useDispatch } from "metabase/lib/redux";
import type { SearchResult } from "metabase-types/api";
export const BrowseRedirect = () => {
const defaultTab = localStorage.getItem("defaultBrowseTab");
const shouldQueryModels = !defaultTab;
const {
data: models,
error,
isLoading,
} = useSearchListQuery<SearchResult>({
query: {
models: ["dataset"],
filter_items_in_personal_collection: "exclude",
},
enabled: shouldQueryModels,
});
const dispatch = useDispatch();
useEffect(() => {
switch (defaultTab) {
case "models":
dispatch(replace("/browse/models"));
break;
case "databases":
dispatch(replace("/browse/databases"));
break;
default:
if (models !== undefined) {
if (models.length > 0) {
dispatch(replace("/browse/models"));
} else {
dispatch(replace("/browse/databases"));
}
}
if (!error && !isLoading) {
dispatch(replace("/browse/models"));
}
}
}, [models, defaultTab, dispatch, error, isLoading]);
if (error || isLoading) {
return (
<LoadingAndErrorWrapper
error={error}
loading={isLoading}
style={{ display: "flex", flex: 1 }}
/>
);
}
return null;
};

View File

@@ -1,88 +0,0 @@
import { replace } from "react-router-redux";
import { setupSearchEndpoints } from "__support__/server-mocks";
import { renderWithProviders, waitFor } from "__support__/ui";
import type { SearchResult } from "metabase-types/api";
import { createMockModelResult } from "metabase-types/api/mocks";
import { BrowseRedirect } from "./BrowseRedirect";
const mockModels: SearchResult[] = [
{
id: 0,
name: "Model 0",
collection: { id: 0, name: "Alpha" },
},
{
id: 1,
name: "Model 1",
collection: { id: 0, name: "Alpha" },
},
].map(model => createMockModelResult(model));
const setup = ({
models,
defaultTab = null,
}: {
models: SearchResult[];
defaultTab: string | null;
}) => {
setupSearchEndpoints(models);
if (defaultTab === null) {
localStorage.removeItem("defaultBrowseTab");
} else {
localStorage.setItem("defaultBrowseTab", defaultTab);
}
return renderWithProviders(<BrowseRedirect />);
};
describe("BrowseRedirect", () => {
it("redirects to /browse/databases if there are no models and no saved setting", async () => {
const { store } = setup({ models: [], defaultTab: null });
const mockDispatch = jest.spyOn(store, "dispatch");
await waitFor(() => {
expect(mockDispatch).toHaveBeenCalledWith(replace("/browse/databases"));
});
});
it("redirects to /browse/models if there are some models but no saved setting", async () => {
const { store } = setup({ models: mockModels, defaultTab: null });
const mockDispatch = jest.spyOn(store, "dispatch");
await waitFor(() => {
expect(mockDispatch).toHaveBeenCalledWith(replace("/browse/models"));
});
});
it("redirects to /browse/models if the user's defaultBrowseTab setting is 'models'", async () => {
const { store, rerender } = setup({
models: [],
defaultTab: "models",
});
const mockDispatch = jest.spyOn(store, "dispatch");
rerender(<BrowseRedirect />);
await waitFor(() => {
expect(mockDispatch).toHaveBeenCalledWith(replace("/browse/models"));
});
});
it("redirects to /browse/databases if the user's defaultBrowseBab setting is 'databases'", async () => {
const { store, rerender } = setup({
models: mockModels,
defaultTab: "databases",
});
const mockDispatch = jest.spyOn(store, "dispatch");
rerender(<BrowseRedirect />);
await waitFor(() => {
expect(mockDispatch).toHaveBeenCalledWith(replace("/browse/databases"));
});
});
it("redirects to /browse/models if the user has an invalid defaultBrowseTab setting, and some models exist", async () => {
const { store, rerender } = setup({
models: mockModels,
defaultTab: "this is an invalid value",
});
const mockDispatch = jest.spyOn(store, "dispatch");
rerender(<BrowseRedirect />);
await waitFor(() => {
expect(mockDispatch).toHaveBeenCalledWith(replace("/browse/models"));
});
});
});

View File

@@ -0,0 +1,91 @@
import cx from "classnames";
import { t } from "ttag";
import TableBrowser from "metabase/browse/containers/TableBrowser";
import { BrowserCrumbs } from "metabase/components/BrowserCrumbs";
import Card from "metabase/components/Card";
import EntityItem from "metabase/components/EntityItem";
import { Grid } from "metabase/components/Grid";
import CS from "metabase/css/core/index.css";
import Database from "metabase/entities/databases";
import Schema from "metabase/entities/schemas";
import * as Urls from "metabase/lib/urls";
import type { CollectionItem } from "metabase-types/api";
import { BrowseContainer, BrowseMain } from "./BrowseApp.styled";
import { BrowseHeaderContent } from "./BrowseHeader.styled";
import { SchemaGridItem, SchemaLink } from "./SchemaBrowser.styled";
const BrowseSchemas = ({
schemas,
params,
slug,
}: {
schemas: CollectionItem[];
params: any;
slug: string;
}) => {
const dbId = Urls.extractEntityId(slug);
return (
<BrowseContainer>
<BrowseMain>
{schemas.length === 1 ? (
<TableBrowser
schemas={schemas}
params={params}
slug={slug}
dbId={dbId}
schemaName={schemas[0].name}
// hide the schema since there's only one
showSchemaInHeader={false}
/>
) : (
<>
<BrowseHeaderContent>
<BrowserCrumbs
crumbs={[
{ title: t`Databases`, to: "/browse/databases" },
{ title: <Database.Name id={dbId} /> },
]}
/>
</BrowseHeaderContent>
{schemas.length === 0 ? (
<h2
className={cx(CS.full, CS.textCentered, CS.textMedium)}
>{t`This database doesn't have any tables.`}</h2>
) : (
<Grid>
{schemas.map(schema => (
<SchemaGridItem key={schema.id}>
<SchemaLink
to={`/browse/databases/${dbId}/schema/${encodeURIComponent(
schema.name,
)}`}
>
<Card hoverable className={CS.px1}>
<EntityItem
name={schema.name}
iconName="folder"
// TODO: Is it necessary to support this color?
// iconColor={color("accent2")}
item={schema}
/>
</Card>
</SchemaLink>
</SchemaGridItem>
))}
</Grid>
)}
</>
)}
</BrowseMain>
</BrowseContainer>
);
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default Schema.loadList({
query: (state: any, { params: { slug } }: { params: { slug: string } }) => ({
dbId: Urls.extractEntityId(slug),
}),
})(BrowseSchemas);

View File

@@ -0,0 +1,56 @@
import styled from "@emotion/styled";
import type { AnchorHTMLAttributes } from "react";
import { ResponsiveChild } from "metabase/components/ResponsiveContainer/ResponsiveContainer";
import { color } from "metabase/lib/colors";
import type { AnchorProps } from "metabase/ui";
import { Anchor } from "metabase/ui";
import type { RefProp } from "./types";
export const Breadcrumb = styled(Anchor)<
AnchorProps &
AnchorHTMLAttributes<HTMLAnchorElement> &
RefProp<HTMLAnchorElement>
>`
color: ${color("text-dark")};
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-top: 1px;
padding-bottom: 1px;
:hover {
color: ${color("brand")};
text-decoration: none;
}
`;
export const CollectionBreadcrumbsWrapper = styled(ResponsiveChild)`
line-height: 1;
${props => {
const breakpoint = "10rem";
return `
.initial-ellipsis {
display: none;
}
@container ${props.containerName} (width < ${breakpoint}) {
.ellipsis-and-separator {
display: none;
}
.initial-ellipsis {
display: inline;
}
.for-index-0:not(.sole-breadcrumb) {
display: none;
}
.breadcrumb {
max-width: calc(95cqw - 3rem) ! important;
}
.sole-breadcrumb {
max-width: calc(95cqw - 1rem) ! important;
}
}
`;
}}
`;

View File

@@ -0,0 +1,149 @@
import classNames from "classnames";
import type { FC } from "react";
import { forwardRef, useLayoutEffect, useRef, useState } from "react";
import type { Collection } from "metabase-types/api";
import { ResponsiveContainer } from "metabase/components/ResponsiveContainer/ResponsiveContainer";
import { useAreAnyTruncated } from "metabase/hooks/use-is-truncated";
import resizeObserver from "metabase/lib/resize-observer";
import * as Urls from "metabase/lib/urls";
import type { FlexProps } from "metabase/ui";
import { FixedSizeIcon, Flex, Group, Text, Tooltip } from "metabase/ui";
import { getCollectionName } from "../utils";
import { c } from "ttag";
import {
Breadcrumb,
CollectionBreadcrumbsWrapper
} from "./CollectionBreadcrumbsWithTooltip.styled";
import { pathSeparatorChar } from "./constants";
import type { RefProp } from "./types";
import { getBreadcrumbMaxWidths, getCollectionPathString } from "./utils";
export const CollectionBreadcrumbsWithTooltip = ({
collection,
containerName,
}: {
collection: Collection;
containerName: string;
}) => {
const collections = (collection.effective_ancestors || []).concat(collection);
const pathString = getCollectionPathString(collection);
const ellipsifyPath = collections.length > 2;
const shownCollections = ellipsifyPath
? [collections[0], collections[collections.length - 1]]
: collections;
const justOneShown = shownCollections.length === 1;
const { areAnyTruncated, ref } = useAreAnyTruncated<HTMLAnchorElement>({
tolerance: 1,
});
const initialEllipsisRef = useRef<HTMLDivElement | null>(null);
const [
isFirstCollectionDisplayedAsEllipsis,
setIsFirstCollectionDisplayedAsEllipsis,
] = useState(false);
useLayoutEffect(() => {
const initialEllipsis = initialEllipsisRef.current;
if (!initialEllipsis) {
return;
}
const handleResize = () => {
// The initial ellipsis might be hidden via CSS,
// so we need to check whether it is displayed via getComputedStyle
const style = window.getComputedStyle(initialEllipsis);
setIsFirstCollectionDisplayedAsEllipsis(style.display !== "none");
};
resizeObserver.subscribe(initialEllipsis, handleResize);
return () => {
resizeObserver.unsubscribe(initialEllipsis, handleResize);
};
}, [initialEllipsisRef]);
const isTooltipEnabled =
areAnyTruncated || ellipsifyPath || isFirstCollectionDisplayedAsEllipsis;
const maxWidths = getBreadcrumbMaxWidths(shownCollections, 96, ellipsifyPath);
return (
<Tooltip
disabled={!isTooltipEnabled}
variant="multiline"
label={pathString}
>
<ResponsiveContainer
aria-label={pathString}
data-testid={`breadcrumbs-for-collection: ${collection.name}`}
name={containerName}
w="auto"
>
<Flex align="center" w="100%" lh="1" style={{ flexFlow: "row nowrap" }}>
<FixedSizeIcon name="folder" style={{ marginInlineEnd: ".5rem" }} />
{shownCollections.map((collection, index) => {
const key = `collection${collection.id}`;
return (
<Group spacing={0} style={{ flexFlow: "row nowrap" }} key={key}>
{index > 0 && <PathSeparator />}
<CollectionBreadcrumbsWrapper
containerName={containerName}
style={{ alignItems: "center" }}
w="auto"
display="flex"
>
{index === 0 && !justOneShown && (
<Ellipsis
ref={initialEllipsisRef}
includeSep={false}
className="initial-ellipsis"
/>
)}
{index > 0 && ellipsifyPath && <Ellipsis />}
<Breadcrumb
href={Urls.collection(collection)}
className={classNames("breadcrumb", `for-index-${index}`, {
"sole-breadcrumb": collections.length === 1,
})}
ref={(el: HTMLAnchorElement) => ref.current.set(key, el)}
maw={maxWidths[index]}
key={collection.id}
>
{getCollectionName(collection)}
</Breadcrumb>
</CollectionBreadcrumbsWrapper>
</Group>
);
})}
</Flex>
</ResponsiveContainer>
</Tooltip>
);
};
type EllipsisProps = {
includeSep?: boolean;
} & FlexProps;
const Ellipsis: FC<EllipsisProps & Partial<RefProp<HTMLDivElement | null>>> =
forwardRef<HTMLDivElement, EllipsisProps>(
({ includeSep = true, ...flexProps }, ref) => (
<Flex
ref={ref}
align="center"
className="ellipsis-and-separator"
{...flexProps}
>
<Text lh={1}>…</Text>
{includeSep && <PathSeparator />}
</Flex>
),
);
Ellipsis.displayName = "Ellipsis";
const PathSeparator = () => (
<Text color="text-light" mx="xs" py={1}>
{pathSeparatorChar}
</Text>
);

View File

@@ -0,0 +1,28 @@
import { Ellipsified } from "metabase/core/components/Ellipsified";
import Markdown from "metabase/core/components/Markdown";
export const EllipsifiedWithMarkdown = ({
shouldMarkdownifyTooltipTarget = false,
shouldMarkdownifyTooltip = true,
children,
}: {
/** NOTE: If the tooltip target is markdownified,
* useIsTruncated won't know whether it has been truncated */
shouldMarkdownifyTooltipTarget?: boolean;
shouldMarkdownifyTooltip?: boolean;
children: string;
}) => {
const tooltip = shouldMarkdownifyTooltip ? (
<Markdown disallowHeading unstyleLinks lineClamp={12}>
{children}
</Markdown>
) : (
children
);
const tooltipTarget = shouldMarkdownifyTooltipTarget ? (
<Markdown disallowHeading>{children}</Markdown>
) : (
children
);
return <Ellipsified tooltip={tooltip}>{tooltipTarget}</Ellipsified>;
};

View File

@@ -1,39 +1,24 @@
import { useState } from "react";
import { t } from "ttag";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { updateUserSetting } from "metabase/redux/settings";
import { getSetting } from "metabase/selectors/settings";
import { Flex, Paper, Icon, Text } from "metabase/ui";
import { useUserSetting } from "metabase/common/hooks";
import { Flex, Icon, Paper, Text } from "metabase/ui";
import { BannerCloseButton, BannerModelIcon } from "./BrowseModels.styled";
export function ModelExplanationBanner() {
const hasDismissedBanner = useSelector(state =>
getSetting(state, "dismissed-browse-models-banner"),
const [hasDismissedBanner, setHasDismissedBanner] = useUserSetting(
"dismissed-browse-models-banner",
);
const dispatch = useDispatch();
const [shouldShowBanner, setShouldShowBanner] = useState(!hasDismissedBanner);
const dismissBanner = () => {
setShouldShowBanner(false);
dispatch(
updateUserSetting({
key: "dismissed-browse-models-banner",
value: true,
}),
);
setHasDismissedBanner(true);
};
if (!shouldShowBanner) {
if (hasDismissedBanner) {
return null;
}
return (
<Paper
mt="1rem"
mb="-0.5rem"
p="1rem"
color="text-dark"
bg="brand-lighter"
@@ -44,7 +29,7 @@ export function ModelExplanationBanner() {
>
<Flex>
<BannerModelIcon name="model" />
<Text size="md" lh="1rem" mr="1rem">
<Text size="md" lh="1rem" style={{ marginInlineEnd: "1rem" }}>
{t`Models help curate data to make it easier to find answers to questions all in one place.`}
</Text>
<BannerCloseButton onClick={dismissBanner}>

View File

@@ -0,0 +1,134 @@
import { t } from "ttag";
import {
SortableColumnHeader,
type SortingOptions,
} from "metabase/components/ItemsTable/BaseItemsTable";
import {
ColumnHeader,
ItemCell,
Table,
TableColumn,
TBody,
} from "metabase/components/ItemsTable/BaseItemsTable.styled";
import { Columns } from "metabase/components/ItemsTable/Columns";
import type { ResponsiveProps } from "metabase/components/ItemsTable/utils";
import { color } from "metabase/lib/colors";
import type { CollectionItem } from "metabase-types/api";
import { getCollectionName } from "../utils";
import { CollectionBreadcrumbsWithTooltip } from "./CollectionBreadcrumbsWithTooltip";
import { EllipsifiedWithMarkdown } from "./EllipsifiedWithMarkdown";
import { getModelDescription } from "./utils";
export interface ModelsTableProps {
items: CollectionItem[];
sortingOptions?: SortingOptions;
onSortingOptionsChange?: (newSortingOptions: SortingOptions) => void;
}
const descriptionProps: ResponsiveProps = {
hideAtContainerBreakpoint: "sm",
containerName: "ItemsTableContainer",
};
const collectionProps: ResponsiveProps = {
hideAtContainerBreakpoint: "xs",
containerName: "ItemsTableContainer",
};
export const ModelsTable = ({
items,
sortingOptions,
onSortingOptionsChange,
}: ModelsTableProps) => {
return (
<Table>
<colgroup>
<Columns.Type.Col />
{/* <col> for Name column */}
<TableColumn style={{ width: "10rem" }} />
{/* <col> for Description column */}
<TableColumn {...descriptionProps} />
{/* <col> for Collection column */}
<TableColumn {...collectionProps} />
<Columns.RightEdge.Col />
</colgroup>
<thead>
<tr>
<Columns.Type.Header title="" />
<Columns.Name.Header
sortingOptions={sortingOptions}
onSortingOptionsChange={onSortingOptionsChange}
/>
<ColumnHeader {...descriptionProps}>{t`Description`}</ColumnHeader>
<SortableColumnHeader
name="collection"
sortingOptions={sortingOptions}
onSortingOptionsChange={onSortingOptionsChange}
{...collectionProps}
>
{t`Collection`}
</SortableColumnHeader>
<Columns.RightEdge.Header />
</tr>
</thead>
<TBody>
{items.map((item: CollectionItem) => (
<TBodyRow item={item} key={`${item.model}-${item.id}`} />
))}
</TBody>
</Table>
);
};
const TBodyRow = ({ item }: { item: CollectionItem }) => {
const icon = item.getIcon();
if (item.model === "card") {
icon.color = color("text-light");
}
const containerName = `collections-path-for-${item.id}`;
return (
<tr>
{/* Type */}
<Columns.Type.Cell icon={icon} />
{/* Name */}
<Columns.Name.Cell item={item} includeDescription={false} />
{/* Description */}
<ItemCell {...descriptionProps}>
<EllipsifiedWithMarkdown>
{getModelDescription(item) || ""}
</EllipsifiedWithMarkdown>
</ItemCell>
{/* Collection */}
<ItemCell
data-testid={`path-for-collection: ${
item.collection
? getCollectionName(item.collection)
: t`Untitled collection`
}`}
{...collectionProps}
>
{item.collection && (
<CollectionBreadcrumbsWithTooltip
containerName={containerName}
collection={item.collection}
/>
)}
</ItemCell>
{/* Adds a border-radius to the table */}
<Columns.RightEdge.Cell />
</tr>
);
};

View File

@@ -1,83 +0,0 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import { t } from "ttag";
import TableBrowser from "metabase/browse/containers/TableBrowser";
import { BrowserCrumbs } from "metabase/components/BrowserCrumbs";
import Card from "metabase/components/Card";
import EntityItem from "metabase/components/EntityItem";
import { Grid } from "metabase/components/Grid";
import CS from "metabase/css/core/index.css";
import Database from "metabase/entities/databases";
import Schema from "metabase/entities/schemas";
import { color } from "metabase/lib/colors";
import * as Urls from "metabase/lib/urls";
import { BrowseHeaderContent } from "./BrowseHeader.styled";
import {
SchemaBrowserContainer,
SchemaGridItem,
SchemaLink,
} from "./SchemaBrowser.styled";
function SchemaBrowser(props) {
const { schemas, params } = props;
const { slug } = params;
const dbId = Urls.extractEntityId(slug);
return (
<SchemaBrowserContainer>
{schemas.length === 1 ? (
<TableBrowser
{...props}
dbId={dbId}
schemaName={schemas[0].name}
// hide the schema since there's only one
showSchemaInHeader={false}
/>
) : (
<>
<BrowseHeaderContent>
<BrowserCrumbs
crumbs={[
{ title: t`Databases`, to: "/browse/databases" },
{ title: <Database.Name id={dbId} /> },
]}
/>
</BrowseHeaderContent>
{schemas.length === 0 ? (
<h2
className={cx(CS.full, CS.textCentered, CS.textMedium)}
>{t`This database doesn't have any tables.`}</h2>
) : (
<Grid>
{schemas.map(schema => (
<SchemaGridItem key={schema.id}>
<SchemaLink
to={`/browse/databases/${dbId}/schema/${encodeURIComponent(
schema.name,
)}`}
>
<Card hoverable className={CS.px1}>
<EntityItem
name={schema.name}
iconName="folder"
iconColor={color("accent2")}
item={schema}
/>
</Card>
</SchemaLink>
</SchemaGridItem>
))}
</Grid>
)}
</>
)}
</SchemaBrowserContainer>
);
}
export default Schema.loadList({
query: (state, { params: { slug } }) => ({
dbId: Urls.extractEntityId(slug),
}),
})(SchemaBrowser);

View File

@@ -0,0 +1,6 @@
import { c } from "ttag";
export const pathSeparatorChar = c(
"Character that separates the names of collections in a path, as in Europe / Belgium / Antwerp",
).t`/`;

View File

@@ -0,0 +1,4 @@
import type { MutableRefObject } from "react";
/** @template T: The type of the value the ref will hold */
export type RefProp<T> = { ref: MutableRefObject<T> | ((el: T) => void) };

View File

@@ -0,0 +1,59 @@
import { t } from "ttag";
import { isRootCollection } from "metabase/collections/utils";
import type {
CollectionItem,
Collection,
CollectionEssentials,
} from "metabase-types/api";
import { getCollectionName } from "../utils";
import { pathSeparatorChar } from "./constants";
export const getBreadcrumbMaxWidths = (
collections: Collection["effective_ancestors"],
totalUnitsOfWidthAvailable: number,
isPathEllipsified: boolean,
) => {
if (!collections || collections.length < 2) {
return [];
}
const lengths = collections.map(
collection => getCollectionName(collection).length,
);
const ratio = lengths[0] / (lengths[0] + lengths[1]);
const firstWidth = Math.max(
Math.round(ratio * totalUnitsOfWidthAvailable),
25,
);
const secondWidth = totalUnitsOfWidthAvailable - firstWidth;
const padding = isPathEllipsified ? "2rem" : "1rem";
return [
`calc(${firstWidth}cqw - ${padding})`,
`calc(${secondWidth}cqw - ${padding})`,
];
};
export const isModel = (item: CollectionItem) => item.model === "dataset";
export const getModelDescription = (item: CollectionItem) => {
if (
item.collection &&
isRootCollection(item.collection) &&
isModel(item) &&
!item.description?.trim()
) {
return t`A model`;
} else {
return item.description;
}
};
export const getCollectionPathString = (collection: CollectionEssentials) => {
const collections = (collection.effective_ancestors || []).concat(collection);
const pathString = collections
.map(coll => getCollectionName(coll))
.join(` ${pathSeparatorChar} `);
return pathString;
};

View File

@@ -1,3 +1,4 @@
import type { Dispatch, SetStateAction } from "react";
import { t } from "ttag";
import _ from "underscore";
@@ -95,7 +96,7 @@ export type AvailableModelFilters = Record<
export type ModelFilterControlsProps = {
actualModelFilters: ActualModelFilters;
handleModelFilterChange: (filterName: string, active: boolean) => void;
setActualModelFilters: Dispatch<SetStateAction<ActualModelFilters>>;
};
export const sortModels = (

View File

@@ -18,10 +18,7 @@ import type {
} from "metabase/collections/types";
import { isPersonalCollectionChild } from "metabase/collections/utils";
import { ItemsTable } from "metabase/components/ItemsTable";
import {
Sort,
type SortingOptions,
} from "metabase/components/ItemsTable/BaseItemsTable";
import type { SortingOptions } from "metabase/components/ItemsTable/BaseItemsTable";
import PaginationControls from "metabase/components/PaginationControls";
import ItemsDragLayer from "metabase/containers/dnd/ItemsDragLayer";
import CS from "metabase/css/core/index.css";
@@ -32,11 +29,12 @@ import { useToggle } from "metabase/hooks/use-toggle";
import { useDispatch } from "metabase/lib/redux";
import { addUndo } from "metabase/redux/undo";
import type Database from "metabase-lib/v1/metadata/Database";
import type {
Bookmark,
Collection,
CollectionId,
CollectionItem,
import {
SortDirection,
type Bookmark,
type Collection,
type CollectionId,
type CollectionItem,
} from "metabase-types/api";
import type { CollectionOrTableIdProps } from "../ModelUploadModal";
@@ -97,7 +95,7 @@ export const CollectionContentView = ({
const [unpinnedItemsSorting, setUnpinnedItemsSorting] =
useState<SortingOptions>({
sort_column: "name",
sort_direction: Sort.Asc,
sort_direction: SortDirection.Asc,
});
const [

View File

@@ -1,9 +1,42 @@
import { useSelector } from "metabase/lib/redux";
import { useCallback, useMemo } from "react";
import _ from "underscore";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { updateUserSetting } from "metabase/redux/settings";
import { getSetting } from "metabase/selectors/settings";
import type { Settings } from "metabase-types/api";
import type { Settings, UserSettings } from "metabase-types/api";
export const useSetting = <SettingName extends keyof Settings>(
settingName: SettingName,
) => {
return useSelector(state => getSetting(state, settingName));
};
export const useUserSetting = <T extends keyof UserSettings>(
key: T,
{
shouldRefresh = false,
shouldDebounce = true,
debounceTimeout = 200,
debounceOnLeadingEdge,
}: {
shouldRefresh?: boolean;
shouldDebounce?: boolean;
debounceTimeout?: number;
debounceOnLeadingEdge?: boolean;
} = {},
): [UserSettings[T], (value: UserSettings[T]) => void] => {
const currentValue = useSetting(key);
const dispatch = useDispatch();
const setter = useCallback(
(value: UserSettings[T]) => {
dispatch(updateUserSetting({ key, value, shouldRefresh }));
},
[dispatch, key, shouldRefresh],
);
const debouncedSetter = useMemo(
() => _.debounce(setter, debounceTimeout, debounceOnLeadingEdge),
[setter, debounceTimeout, debounceOnLeadingEdge],
);
return [currentValue, shouldDebounce ? debouncedSetter : setter];
};

View File

@@ -4,7 +4,7 @@ import styled from "@emotion/styled";
import type { IconName, IconProps } from "metabase/ui";
import { Icon } from "metabase/ui";
export const HeaderContainer = styled.div<{ role: string; tabIndex?: number }>`
export const HeaderContainer = styled.div<{ role?: string; tabIndex?: number }>`
display: flex;
align-items: center;
cursor: pointer;
@@ -43,9 +43,8 @@ export const ToggleIcon = styled(
isExpanded,
variant,
size = 12,
name: _,
...props
}: ToggleIconProps & IconProps) => {
}: ToggleIconProps & Omit<IconProps, "name">) => {
const { collapsed, expanded } = ICON_VARIANTS[variant];
const name = isExpanded ? expanded : collapsed;
return <Icon name={name as IconName} size={size} {...props} />;

View File

@@ -1,22 +1,8 @@
import PropTypes from "prop-types";
import { useCallback, useState } from "react";
import { HeaderContainer, Header, ToggleIcon } from "./CollapseSection.styled";
const propTypes = {
children: PropTypes.node,
className: PropTypes.string,
header: PropTypes.node,
headerClass: PropTypes.string,
bodyClass: PropTypes.string,
initialState: PropTypes.oneOf(["expanded", "collapsed"]),
iconVariant: PropTypes.oneOf(["right-down", "up-down"]),
iconPosition: PropTypes.oneOf(["left", "right"]),
iconSize: PropTypes.number,
onToggle: PropTypes.func,
};
function CollapseSection({
const CollapseSection = ({
initialState = "collapsed",
iconVariant = "right-down",
iconPosition = "left",
@@ -27,7 +13,18 @@ function CollapseSection({
bodyClass,
children,
onToggle,
}) {
}: {
children?: React.ReactNode;
className?: string;
header?: React.ReactNode;
headerClass?: string;
bodyClass?: string;
initialState?: "expanded" | "collapsed";
iconVariant?: "right-down" | "up-down";
iconPosition?: "left" | "right";
iconSize?: number;
onToggle?: (nextState: boolean) => void;
}) => {
const [isExpanded, setIsExpanded] = useState(initialState === "expanded");
const toggle = useCallback(() => {
@@ -37,7 +34,7 @@ function CollapseSection({
}, [isExpanded, onToggle]);
const onKeyDown = useCallback(
e => {
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
toggle();
}
@@ -70,8 +67,7 @@ function CollapseSection({
</div>
</div>
);
}
CollapseSection.propTypes = propTypes;
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default CollapseSection;

View File

@@ -2,6 +2,7 @@ import {
useCallback,
type HTMLAttributes,
type PropsWithChildren,
useMemo,
} from "react";
import type { ActionMenuProps } from "metabase/collections/components/ActionMenu/ActionMenu";
@@ -15,7 +16,12 @@ import type {
import ItemDragSource from "metabase/containers/dnd/ItemDragSource";
import { color } from "metabase/lib/colors";
import type Database from "metabase-lib/v1/metadata/Database";
import type { Bookmark, Collection, CollectionItem } from "metabase-types/api";
import {
SortDirection,
type Bookmark,
type Collection,
type CollectionItem,
} from "metabase-types/api";
import {
ColumnHeader,
@@ -29,7 +35,7 @@ import type { ResponsiveProps } from "./utils";
export type SortingOptions = {
sort_column: string;
sort_direction: "asc" | "desc";
sort_direction: SortDirection;
};
export type SortableColumnHeaderProps = {
@@ -38,36 +44,38 @@ export type SortableColumnHeaderProps = {
onSortingOptionsChange?: (newSortingOptions: SortingOptions) => void;
} & PropsWithChildren<Partial<HTMLAttributes<HTMLDivElement>>>;
export enum Sort {
Asc = "asc",
Desc = "desc",
}
export const SortableColumnHeader = ({
name = "",
sortingOptions = {
sort_column: "",
sort_direction: Sort.Asc,
},
name,
sortingOptions,
onSortingOptionsChange,
children,
hideAtContainerBreakpoint,
containerName,
...props
}: SortableColumnHeaderProps & ResponsiveProps) => {
const isSortable = !!onSortingOptionsChange;
const isSortingThisColumn = sortingOptions.sort_column === name;
const isSortable = !!onSortingOptionsChange && !!name;
const isSortingThisColumn = sortingOptions?.sort_column === name;
const direction = isSortingThisColumn
? sortingOptions.sort_direction
: Sort.Desc;
? sortingOptions?.sort_direction
: SortDirection.Desc;
const onSortingControlClick = () => {
const nextDirection = direction === Sort.Asc ? Sort.Desc : Sort.Asc;
onSortingOptionsChange?.({
sort_column: name,
sort_direction: nextDirection,
});
};
const onSortingControlClick = useMemo(() => {
if (!isSortable) {
return undefined;
}
const handler = () => {
const nextDirection =
direction === SortDirection.Asc
? SortDirection.Desc
: SortDirection.Asc;
const newSortingOptions = {
sort_column: name,
sort_direction: nextDirection,
};
onSortingOptionsChange?.(newSortingOptions);
};
return handler;
}, [direction, isSortable, name, onSortingOptionsChange]);
return (
<ColumnHeader
@@ -84,7 +92,7 @@ export const SortableColumnHeader = ({
{children}
{isSortable && (
<SortingIcon
name={direction === Sort.Asc ? "chevronup" : "chevrondown"}
name={direction === SortDirection.Asc ? "chevronup" : "chevrondown"}
/>
)}
</SortingControlContainer>

View File

@@ -9,7 +9,7 @@ import {
DEFAULT_TIME_STYLE,
} from "metabase/lib/formatting/datetime-utils";
import type { IconName } from "metabase/ui";
import type { CollectionItem } from "metabase-types/api";
import { SortDirection, type CollectionItem } from "metabase-types/api";
import { createMockCollection } from "metabase-types/api/mocks";
import type { BaseItemsTableProps } from "./BaseItemsTable";
@@ -64,7 +64,10 @@ describe("BaseItemsTable", () => {
component={() => (
<BaseItemsTable
items={items}
sortingOptions={{ sort_column: "name", sort_direction: "asc" }}
sortingOptions={{
sort_column: "name",
sort_direction: SortDirection.Asc,
}}
onSortingOptionsChange={jest.fn()}
{...props}
/>

View File

@@ -0,0 +1,28 @@
import styled from "@emotion/styled";
import { Box, type BoxProps } from "metabase/ui";
const doNotForwardProps = (...propNamesToBlock: string[]) => ({
shouldForwardProp: (propName: string) => !propNamesToBlock.includes(propName),
});
/** Helps with @container queries in CSS */
export const ResponsiveContainer = styled(
Box,
doNotForwardProps("name", "type"),
)<
BoxProps & {
name: string;
type?: string;
}
>`
container-name: ${props => props.name};
container-type: ${props => props.type};
`;
ResponsiveContainer.defaultProps = { type: "inline-size" };
export const ResponsiveChild = styled(Box, doNotForwardProps("containerName"))<
BoxProps & {
containerName: string;
}
>``;

View File

@@ -22,4 +22,8 @@ interface EllipsifiedRootProps {
export const EllipsifiedRoot = styled.div<EllipsifiedRootProps>`
${props => ((props.lines ?? 1) > 1 ? clampCss(props) : ellipsifyCss)};
// To ellipsify markdown
p {
${ellipsifyCss}
}
`;

View File

@@ -1,10 +1,18 @@
import { useLayoutEffect, useRef, useState } from "react";
import _ from "underscore";
import resizeObserver from "metabase/lib/resize-observer";
type UseIsTruncatedProps = {
disabled?: boolean;
/** To avoid rounding errors, we can require that the truncation is at least a certain number of pixels */
tolerance?: number;
};
export const useIsTruncated = <E extends Element>({
disabled = false,
}: { disabled?: boolean } = {}) => {
tolerance = 0,
}: UseIsTruncatedProps = {}) => {
const ref = useRef<E | null>(null);
const [isTruncated, setIsTruncated] = useState(false);
@@ -16,7 +24,7 @@ export const useIsTruncated = <E extends Element>({
}
const handleResize = () => {
setIsTruncated(getIsTruncated(element));
setIsTruncated(getIsTruncated(element, tolerance));
};
handleResize();
@@ -25,14 +33,56 @@ export const useIsTruncated = <E extends Element>({
return () => {
resizeObserver.unsubscribe(element, handleResize);
};
}, [disabled]);
}, [disabled, tolerance]);
return { isTruncated, ref };
};
const getIsTruncated = (element: Element): boolean => {
const getIsTruncated = (element: Element, tolerance: number): boolean => {
return (
element.scrollHeight > element.clientHeight ||
element.scrollWidth > element.clientWidth
element.scrollHeight > element.clientHeight + tolerance ||
element.scrollWidth > element.clientWidth + tolerance
);
};
export const useAreAnyTruncated = <E extends Element>({
disabled = false,
tolerance = 0,
}: UseIsTruncatedProps = {}) => {
const ref = useRef(new Map<string, E>());
const [truncationStatusByKey, setTruncationStatusByKey] = useState<
Map<string, boolean>
>(new Map());
useLayoutEffect(() => {
const elementsMap = ref.current;
if (!elementsMap.size || disabled) {
return;
}
const unsubscribeFns: (() => void)[] = [];
[...elementsMap.entries()].forEach(([elementKey, element]) => {
const handleResize = () => {
const isTruncated = getIsTruncated(element, tolerance);
setTruncationStatusByKey(statuses => {
const newStatuses = new Map(statuses);
newStatuses.set(elementKey, isTruncated);
return newStatuses;
});
};
const handleResizeDebounced = _.debounce(handleResize, 200);
resizeObserver.subscribe(element, handleResizeDebounced);
unsubscribeFns.push(() =>
resizeObserver.unsubscribe(element, handleResizeDebounced),
);
});
return () => {
unsubscribeFns.forEach(fn => fn());
};
}, [disabled, tolerance]);
const areAnyTruncated = [...truncationStatusByKey.values()].some(Boolean);
return { areAnyTruncated, ref };
};

View File

@@ -18,6 +18,6 @@ export function createThunkAction<TArgs extends any[]>(
thunkCreator: (
...args: TArgs
) => (dispatch: Dispatch, getState: GetState) => any,
): (...args: TArgs) => any {
) {
return withAction(actionType)(thunkCreator);
}

View File

@@ -1,12 +1,13 @@
import slugg from "slugg";
import type Database from "metabase-lib/v1/metadata/Database";
import type DatabaseV1 from "metabase-lib/v1/metadata/Database";
import type Table from "metabase-lib/v1/metadata/Table";
import { SAVED_QUESTIONS_VIRTUAL_DB_ID } from "metabase-lib/v1/metadata/utils/saved-questions";
import type { Database } from "metabase-types/api";
import { appendSlug } from "./utils";
export function browseDatabase(database: Database) {
export function browseDatabase(database: DatabaseV1 | Database) {
const name =
database.id === SAVED_QUESTIONS_VIRTUAL_DB_ID
? "Saved Questions"
@@ -18,7 +19,7 @@ export function browseDatabase(database: Database) {
export function browseSchema(table: {
db_id?: Table["db_id"];
schema_name: Table["schema_name"] | null;
db?: Pick<Database, "id">;
db?: Pick<DatabaseV1, "id">;
}) {
const databaseId = table.db?.id || table.db_id;
return `/browse/databases/${databaseId}/schema/${encodeURIComponent(

View File

@@ -15,7 +15,7 @@ import { SidebarLink } from "./SidebarItems";
const openSidebarCSS = css`
width: ${NAV_SIDEBAR_WIDTH};
border-right: 1px solid ${color("border")};
border-inline-end: 1px solid ${color("border")};
${breakpointMaxSmall} {
width: 90vw;
@@ -44,7 +44,7 @@ export const Sidebar = styled.aside<{ isOpen: boolean }>`
${breakpointMaxSmall} {
position: absolute;
top: 0;
left: 0;
inline-start: 0;
}
`;
@@ -85,8 +85,8 @@ export const SidebarContentRoot = styled.div`
export const SidebarSection = styled.div`
margin-top: ${space(1)};
margin-bottom: ${space(2)};
padding-left: ${space(2)};
padding-right: ${space(2)};
padding-inline-start: ${space(2)};
padding-inline-end: ${space(2)};
`;
export const SidebarHeadingWrapper = styled.div`
@@ -101,12 +101,12 @@ export const SidebarHeading = styled.h4`
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.45px;
padding-left: ${space(2)};
padding-inline-start: ${space(2)};
`;
export const CollectionsMoreIconContainer = styled.button`
margin-left: auto;
margin-right: ${space(1)};
margin-inline-start: auto;
margin-inline-end: ${space(1)};
cursor: pointer;
`;
@@ -140,7 +140,7 @@ export const LoadingAndErrorTitle = styled.h2`
`;
export const PaddedSidebarLink = styled(SidebarLink)`
padding-left: 12px;
padding-inline-start: 12px;
`;
export const AddYourOwnDataLink = styled(SidebarLink)`

View File

@@ -11,6 +11,7 @@ import {
renderWithProviders,
screen,
waitForLoaderToBeRemoved,
within,
} from "__support__/ui";
import { ROOT_COLLECTION } from "metabase/entities/collections";
import * as Urls from "metabase/lib/urls";
@@ -191,28 +192,29 @@ describe("nav > containers > MainNavbar", () => {
describe("browse data link", () => {
it("should render", async () => {
await setup();
const link = screen.getByRole("link", { name: /Browse data/i });
const listItem = screen.getByRole("listitem", { name: /Browse data/i });
const link = within(listItem).getByRole("link");
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/browse");
expect(link).toHaveAttribute("href", "/browse/databases");
});
it("should not render when a user has no data access", async () => {
await setup({ hasDataAccess: false });
expect(
screen.queryByRole("link", { name: /Browse data/i }),
screen.queryByRole("listitem", { name: /Browse data/i }),
).not.toBeInTheDocument();
});
it("should be highlighted if selected", async () => {
await setup({ pathname: "/browse" });
const link = screen.getByRole("listitem", { name: /Browse data/i });
expect(link).toHaveAttribute("aria-selected", "true");
await setup({ pathname: "/browse/databases" });
const listItem = screen.getByRole("listitem", { name: /Browse data/i });
expect(listItem).toHaveAttribute("aria-selected", "true");
});
it("should be highlighted if child route selected", async () => {
await setup({ pathname: "/browse/databases/1" });
const link = screen.getByRole("listitem", { name: /Browse data/i });
expect(link).toHaveAttribute("aria-selected", "true");
const listItem = screen.getByRole("listitem", { name: /Browse data/i });
expect(listItem).toHaveAttribute("aria-selected", "true");
});
});

View File

@@ -45,6 +45,8 @@ interface CollectionSidebarBookmarksProps {
newIndex: number;
oldIndex: number;
}) => void;
onToggle: (isExpanded: boolean) => void;
initialState: "expanded" | "collapsed";
}
interface BookmarkItemProps {
@@ -56,9 +58,6 @@ interface BookmarkItemProps {
onDeleteBookmark: (bookmark: Bookmark) => void;
}
const BOOKMARKS_INITIALLY_VISIBLE =
localStorage.getItem("shouldDisplayBookmarks") !== "false";
function isBookmarkSelected(bookmark: Bookmark, selectedItem?: SelectedItem) {
if (!selectedItem) {
return false;
@@ -116,6 +115,8 @@ const BookmarkList = ({
onSelect,
onDeleteBookmark,
reorderBookmarks,
onToggle,
initialState,
}: CollectionSidebarBookmarksProps) => {
const [orderedBookmarks, setOrderedBookmarks] = useState(bookmarks);
const [isSorting, setIsSorting] = useState(false);
@@ -128,10 +129,6 @@ const BookmarkList = ({
activationConstraint: { distance: 15 },
});
const onToggleBookmarks = useCallback(isVisible => {
localStorage.setItem("shouldDisplayBookmarks", String(isVisible));
}, []);
const handleSortStart = useCallback(() => {
document.body.classList.add(GrabberS.grabbing);
setIsSorting(true);
@@ -153,11 +150,11 @@ const BookmarkList = ({
return (
<CollapseSection
header={<SidebarHeading>{t`Bookmarks`}</SidebarHeading>}
initialState={BOOKMARKS_INITIALLY_VISIBLE ? "expanded" : "collapsed"}
initialState={initialState}
iconPosition="right"
iconSize={8}
headerClass={CS.mb1}
onToggle={onToggleBookmarks}
onToggle={onToggle}
>
<DndContext
onDragEnd={handleSortEnd}

View File

@@ -1,9 +1,12 @@
import { useCallback } from "react";
import { t } from "ttag";
import { c, t } from "ttag";
import _ from "underscore";
import { useUserSetting } from "metabase/common/hooks";
import CollapseSection from "metabase/components/CollapseSection";
import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger";
import { Tree } from "metabase/components/tree";
import CS from "metabase/css/core/index.css";
import {
getCollectionIcon,
PERSONAL_COLLECTIONS,
@@ -34,7 +37,6 @@ interface CollectionTreeItem extends Collection {
icon: IconName | IconProps;
children: CollectionTreeItem[];
}
type Props = {
isAdmin: boolean;
isOpen: boolean;
@@ -55,8 +57,8 @@ type Props = {
oldIndex: number;
}) => void;
};
const BROWSE_URL = "/browse";
const BROWSE_MODELS_URL = "/browse/models";
const BROWSE_DATA_URL = "/browse/databases";
const OTHER_USERS_COLLECTIONS_URL = Urls.otherUsersPersonalCollections();
const ARCHIVE_URL = "/archive";
const ADD_YOUR_OWN_DATA_URL = "/admin/databases/create";
@@ -86,44 +88,77 @@ function MainNavbarView({
}
}, [handleCloseNavbar]);
const [expandBrowse = true, setExpandBrowse] = useUserSetting(
"expand-browse-in-nav",
);
const [expandBookmarks = true, setExpandBookmarks] = useUserSetting(
"expand-bookmarks-in-nav",
);
const debounceTimeout = 200;
return (
<SidebarContentRoot>
<div>
<SidebarSection>
<ul>
<PaddedSidebarLink
isSelected={nonEntityItem?.url === "/"}
icon="home"
onClick={onItemSelect}
url="/"
>
{t`Home`}
</PaddedSidebarLink>
</SidebarSection>
<SidebarSection>
<CollapseSection
header={
<SidebarHeading>{c("A verb, shown in the sidebar")
.t`Browse`}</SidebarHeading>
}
initialState={expandBrowse ? "expanded" : "collapsed"}
iconPosition="right"
iconSize={8}
headerClass={CS.mb1}
onToggle={_.debounce(setExpandBrowse, debounceTimeout)}
>
<PaddedSidebarLink
isSelected={nonEntityItem?.url === "/"}
icon="home"
icon="model"
url={BROWSE_MODELS_URL}
isSelected={nonEntityItem?.url?.startsWith(BROWSE_MODELS_URL)}
onClick={onItemSelect}
url="/"
aria-label={t`Browse models`}
>
{t`Home`}
{t`Models`}
</PaddedSidebarLink>
{hasDataAccess && (
<>
<PaddedSidebarLink
icon="database"
url={BROWSE_URL}
isSelected={nonEntityItem?.url?.startsWith(BROWSE_URL)}
<PaddedSidebarLink
icon="database"
url={BROWSE_DATA_URL}
isSelected={nonEntityItem?.url?.startsWith(BROWSE_DATA_URL)}
onClick={onItemSelect}
aria-label={t`Browse data`}
>
{t`Data`}
</PaddedSidebarLink>
)}
</CollapseSection>
{hasDataAccess && (
<>
{!hasOwnDatabase && isAdmin && (
<AddYourOwnDataLink
icon="add"
url={ADD_YOUR_OWN_DATA_URL}
isSelected={nonEntityItem?.url?.startsWith(
ADD_YOUR_OWN_DATA_URL,
)}
onClick={onItemSelect}
>
{t`Browse data`}
</PaddedSidebarLink>
{!hasOwnDatabase && isAdmin && (
<AddYourOwnDataLink
icon="add"
url={ADD_YOUR_OWN_DATA_URL}
isSelected={nonEntityItem?.url?.startsWith(
ADD_YOUR_OWN_DATA_URL,
)}
onClick={onItemSelect}
>
{t`Add your own data`}
</AddYourOwnDataLink>
)}
</>
)}
</ul>
{t`Add your own data`}
</AddYourOwnDataLink>
)}
</>
)}
</SidebarSection>
{bookmarks.length > 0 && (
@@ -133,6 +168,8 @@ function MainNavbarView({
selectedItem={cardItem ?? dashboardItem ?? collectionItem}
onSelect={onItemSelect}
reorderBookmarks={reorderBookmarks}
onToggle={_.debounce(setExpandBookmarks, debounceTimeout)}
initialState={expandBookmarks ? "expanded" : "collapsed"}
/>
</SidebarSection>
)}
@@ -156,12 +193,10 @@ function MainNavbarView({
</SidebarContentRoot>
);
}
interface CollectionSectionHeadingProps {
currentUser: User;
handleCreateNewCollection: () => void;
}
function CollectionSectionHeading({
currentUser,
handleCreateNewCollection,
@@ -217,6 +252,5 @@ function CollectionSectionHeading({
</SidebarHeadingWrapper>
);
}
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default MainNavbarView;

View File

@@ -144,13 +144,22 @@ export const useCommandPaletteBasicActions = ({
}
const browseActions: Action[] = [
{
id: "navigate_models",
name: t`Browse models`,
section: "basic",
icon: "model",
perform: () => {
dispatch(push("/browse/models"));
},
},
{
id: "navigate_data",
name: t`Browse Data`,
name: t`Browse data`,
section: "basic",
icon: "database",
perform: () => {
dispatch(push("/browse"));
dispatch(push("/browse/databases"));
},
},
];

View File

@@ -13,6 +13,7 @@ import {
} from "metabase/admin/permissions/types";
import type { ADMIN_SETTINGS_SECTIONS } from "metabase/admin/settings/selectors";
import type {
ActualModelFilters,
AvailableModelFilters,
ModelFilterControlsProps,
} from "metabase/browse/utils";
@@ -426,6 +427,7 @@ export const PLUGIN_CONTENT_VERIFICATION = {
_a: CollectionEssentials,
_b: CollectionEssentials,
) => 0,
useModelFilterSettings: () => ({} as ActualModelFilters),
};
export const PLUGIN_DASHBOARD_HEADER = {

View File

@@ -244,13 +244,14 @@ export const QuestionActions = ({
const handleFileUpload = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && question._card.based_on_upload) {
uploadFile({
file,
tableId: question._card.based_on_upload,
reloadQuestionData: true,
uploadMode,
})(dispatch);
dispatch(
uploadFile({
file,
tableId: question._card.based_on_upload,
reloadQuestionData: true,
uploadMode,
}),
);
// reset the file input so that subsequent uploads of the same file trigger the change handler
if (fileInputRef.current?.value) {
fileInputRef.current.value = "";

View File

@@ -1,6 +1,6 @@
import { createAsyncThunk, createReducer } from "@reduxjs/toolkit";
import { createReducer } from "@reduxjs/toolkit";
import { createThunkAction } from "metabase/lib/redux";
import { createAsyncThunk } from "metabase/lib/redux";
import MetabaseSettings from "metabase/lib/settings";
import { SessionApi, SettingsApi } from "metabase/services";
import type { UserSettings } from "metabase-types/api";
@@ -19,6 +19,40 @@ export const refreshSiteSettings = createAsyncThunk(
},
);
interface UpdateUserSettingProps<K extends keyof UserSettings> {
key: K;
value: UserSettings[K];
shouldRefresh?: boolean;
}
export const UPDATE_USER_SETTING = "metabase/settings/UPDATE_USER_SETTING";
export const updateUserSetting = createAsyncThunk(
UPDATE_USER_SETTING,
async (
{ key, value, shouldRefresh }: UpdateUserSettingProps<keyof UserSettings>,
{ dispatch },
) => {
const setting = {
key,
value,
};
try {
await SettingsApi.put(setting);
if (!shouldRefresh) {
// When we aren't refreshing all the settings, we need to put the setting into the state
return setting;
}
} catch (error) {
console.error("error updating user setting", setting, error);
throw error;
} finally {
if (shouldRefresh) {
await dispatch(refreshSiteSettings({}));
}
}
},
);
export const settings = createReducer(
{ values: window.MetabaseBootstrap || {}, loading: false },
builder => {
@@ -32,25 +66,10 @@ export const settings = createReducer(
builder.addCase(refreshSiteSettings.rejected, state => {
state.loading = false;
});
},
);
export const UPDATE_USER_SETTING = "metabase/settings/UPDATE_USER_SETTING";
export const updateUserSetting = createThunkAction(
UPDATE_USER_SETTING,
function <K extends keyof UserSettings>(setting: {
key: K;
value: Exclude<UserSettings[K], undefined>;
}) {
return async function (dispatch) {
try {
await SettingsApi.put(setting);
} catch (error) {
console.error("error updating user setting", setting, error);
throw error;
} finally {
await dispatch(refreshSiteSettings({}));
builder.addCase(updateUserSetting.fulfilled, (state, { payload }) => {
if (payload) {
state.values[payload.key] = payload.value;
}
};
});
},
);

View File

@@ -10,8 +10,6 @@ import { ForgotPassword } from "metabase/auth/components/ForgotPassword";
import { Login } from "metabase/auth/components/Login";
import { Logout } from "metabase/auth/components/Logout";
import { ResetPassword } from "metabase/auth/components/ResetPassword";
import { BrowseApp } from "metabase/browse/components/BrowseApp";
import SchemaBrowser from "metabase/browse/components/SchemaBrowser";
import TableBrowser from "metabase/browse/containers/TableBrowser";
import CollectionLanding from "metabase/collections/components/CollectionLanding";
import { MoveCollectionModal } from "metabase/collections/components/MoveCollectionModal";
@@ -60,7 +58,9 @@ import SearchApp from "metabase/search/containers/SearchApp";
import { Setup } from "metabase/setup/components/Setup";
import getCollectionTimelineRoutes from "metabase/timelines/collections/routes";
import { BrowseRedirect } from "./browse/components/BrowseRedirect";
import { BrowseDatabases } from "./browse/components/BrowseDatabases";
import { BrowseModels } from "./browse/components/BrowseModels";
import BrowseSchemas from "./browse/components/BrowseSchemas";
import {
CanAccessMetabot,
CanAccessSettings,
@@ -200,27 +200,13 @@ export const getRoutes = store => {
</Route>
<Route path="browse">
<IndexRoute component={BrowseRedirect} />
<Route path="models" component={() => <BrowseApp tab="models" />} />
<Route
path="databases"
component={() => <BrowseApp tab="databases" />}
/>
<Route
path="databases/:slug"
component={({ params }) => (
<BrowseApp tab="databases">
<SchemaBrowser params={params} />
</BrowseApp>
)}
/>
<IndexRedirect to="/browse/models" />
<Route path="models" component={BrowseModels} />
<Route path="databases" component={BrowseDatabases} />
<Route path="databases/:slug" component={BrowseSchemas} />
<Route
path="databases/:dbId/schema/:schemaName"
component={({ params }) => (
<BrowseApp tab="databases">
<TableBrowser params={params} />
</BrowseApp>
)}
component={TableBrowser}
/>
{/* These two Redirects support legacy paths in v48 and earlier */}

View File

@@ -43,7 +43,7 @@ export const Icon = forwardRef<SVGSVGElement, IconProps>(function Icon(
return tooltip ? <Tooltip label={tooltip}>{icon}</Tooltip> : icon;
});
/** An icon that does not shrink when the viewport gets narrower **/
/** An icon that does not shrink when its container is too narrow **/
export const FixedSizeIcon = styled(Icon)<{ size?: number }>`
min-width: ${({ size }) => size ?? 16}px;
min-height: ${({ size }) => size ?? 16}px;

View File

@@ -25,5 +25,16 @@ export const getTooltipOverrides = (): MantineThemeOverride["components"] => ({
padding: "0.6rem 0.75rem",
},
}),
variants: {
multiline: _theme => {
return {
tooltip: {
whiteSpace: "normal",
maxWidth: "20rem",
overflowWrap: "break-word",
},
};
},
},
},
});

View File

@@ -494,6 +494,30 @@
:type :integer
:default nil)
(defsetting expand-browse-in-nav
(deferred-tru "User preference for whether the 'Browse' section of the nav is expanded.")
:user-local :only
:export? false
:visibility :authenticated
:type :boolean
:default true)
(defsetting expand-bookmarks-in-nav
(deferred-tru "User preference for whether the 'Bookmarks' section of the nav is expanded.")
:user-local :only
:export? false
:visibility :authenticated
:type :boolean
:default true)
(defsetting browse-filter-only-verified-models
(deferred-tru "User preference for whether the 'Browse models' page should be filtered to show only verified models.")
:user-local :only
:export? false
:visibility :authenticated
:type :boolean
:default true)
;;; ## ------------------------------------------ AUDIT LOG ------------------------------------------
(defmethod audit-log/model-details :model/User