Compare commits

...

8 Commits

Author SHA1 Message Date
Raphael Krut-Landau
e64bbe26f2 Avoid scroll jump 2024-04-29 12:39:03 -04:00
Raphael Krut-Landau
ff24068178 Squashed branch 2024-04-29 12:39:03 -04:00
Raphael Krut-Landau
4498036f08 Squashed branch 2024-04-29 12:39:03 -04:00
Raphael Krut-Landau
2c0ed89bc3 Squashed branch 2024-04-29 12:39:03 -04:00
Raphael Krut-Landau
0204645f47 Squashed branch 2024-04-29 12:39:03 -04:00
Raphael Krut-Landau
75e2d84984 Squashed branch 2024-04-29 12:39:03 -04:00
Raphael Krut-Landau
f61d1144bc Squashed branch 2024-04-29 12:39:03 -04:00
Raphael Krut-Landau
8494a93f94 Move ItemsTable and related components to frontend/src/metabase/components/ItemsTable 2024-04-29 12:39:03 -04:00
65 changed files with 1846 additions and 1279 deletions

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

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

@@ -132,6 +132,7 @@ export type SearchRequest = {
last_edited_by?: UserId[];
search_native_query?: boolean | null;
verified?: boolean | null;
model_ancestors?: boolean | null;
// this should be in ListCollectionItemsRequest but legacy code expects them here
collection?: CollectionId;

View File

@@ -311,13 +311,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,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,33 +1,116 @@
import { useState } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { t } from "ttag";
import _ from "underscore";
import NoResults from "assets/img/no_results.svg";
import type { useSearchListQuery } from "metabase/common/hooks";
import { useSearchQuery } from "metabase/api";
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 { ContentViewportContext } from "metabase/core/context/ContentViewportContext";
import Search from "metabase/entities/search";
import { color } from "metabase/lib/colors";
import { useDispatch } from "metabase/lib/redux";
import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins";
import { Box, Flex, Group, Icon, Stack, Title } from "metabase/ui";
import { BROWSE_MODELS_LOCALSTORAGE_KEY } from "../constants";
import { getCollectionViewPreferences, groupModels } from "../utils";
import { filterModels, type ActualModelFilters } from "../utils";
import { CenteredEmptyState } from "./BrowseApp.styled";
import { ModelGrid } from "./BrowseModels.styled";
import {
BrowseContainer,
BrowseHeader,
BrowseMain,
BrowseSection,
CenteredEmptyState,
} from "./BrowseApp.styled";
import { ModelExplanationBanner } from "./ModelExplanationBanner";
import { ModelGroup } from "./ModelGroup";
import { ModelsTable } from "./ModelsTable";
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 { availableModelFilters, useModelFilterSettings } =
PLUGIN_CONTENT_VERIFICATION;
export const BrowseModels = () => {
const initialModelFilters = useModelFilterSettings();
const [actualModelFilters, setActualModelFilters] =
useState<ActualModelFilters>(initialModelFilters);
useEffect(() => {
setActualModelFilters(initialModelFilters);
}, [initialModelFilters, setActualModelFilters]);
const contentViewportElement = useContext(ContentViewportContext);
const cachedViewportOverflowYRef = useRef<string>();
// Set the content viewport to scroll to prevent the page from jumping a little when filters are toggled
useEffect(() => {
if (!contentViewportElement) {
return;
}
cachedViewportOverflowYRef.current = getComputedStyle(
contentViewportElement,
).overflowY;
contentViewportElement.style.overflowY = "scroll";
return () => {
if (!contentViewportElement) {
return;
}
const cachedViewportOverflowY = cachedViewportOverflowYRef.current;
if (cachedViewportOverflowY) {
contentViewportElement.style.overflowY = cachedViewportOverflowY;
}
};
}, [contentViewportElement]);
return (
<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>
);
};
export const BrowseModelsBody = ({
actualModelFilters,
}: {
actualModelFilters: ActualModelFilters;
}) => {
const dispatch = useDispatch();
const { data, error, isLoading } = useSearchQuery({
models: ["dataset"],
model_ancestors: true,
filter_items_in_personal_collection: "exclude",
});
const unfilteredModels = data?.data;
const models = useMemo(
() =>
filterModels(
unfilteredModels || [],
actualModelFilters,
availableModelFilters,
),
[unfilteredModels, actualModelFilters],
);
const wrappedModels = models.map(model => Search.wrapEntity(model, dispatch));
if (error || isLoading) {
return (
@@ -39,67 +122,12 @@ export const BrowseModels = ({
);
}
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 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 (
<>
<Stack spacing="md" mb="lg">
<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>
</>
<ModelsTable items={wrappedModels} />
</Stack>
);
}

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,150 +209,29 @@ 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", () => {
it("displays models in collections by default", async () => {
const modelCount = 22;
renderBrowseModels(modelCount);
setup(modelCount);
expect(await screen.findByRole("table")).toBeInTheDocument();
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) {

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,218 @@
import styled from "@emotion/styled";
import classNames from "classnames";
import type { AnchorHTMLAttributes, MutableRefObject, FC } from "react";
import { forwardRef, useLayoutEffect, useRef, useState } from "react";
import {
ResponsiveChild,
ResponsiveContainer,
} from "metabase/components/ResponsiveContainer/ResponsiveContainer";
import { useAreAnyTruncated } from "metabase/hooks/use-is-truncated";
import { color } from "metabase/lib/colors";
import resizeObserver from "metabase/lib/resize-observer";
import * as Urls from "metabase/lib/urls";
import type { AnchorProps, FlexProps } from "metabase/ui";
import { Anchor, FixedSizeIcon, Flex, Group, Text, Tooltip } from "metabase/ui";
import type { Collection } from "metabase-types/api";
import { getCollectionName } from "../utils";
export const CollectionBreadcrumbsWithTooltip = ({
collection,
containerName,
}: {
collection: Collection;
containerName: string;
}) => {
const collections = (collection.effective_ancestors || []).concat(collection);
const pathString = collections
.map(coll => getCollectionName(coll))
.join(" / ");
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>();
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={`collection-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>
);
};
/** @template T The type of the value the ref will hold */
type RefProp<T> = { ref: MutableRefObject<T> | ((el: T) => void) };
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;
}
}
`;
}}
`;
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}>
/
</Text>
);
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})`,
];
};

View File

@@ -0,0 +1,18 @@
import { Ellipsified } from "metabase/core/components/Ellipsified";
import Markdown from "metabase/core/components/Markdown";
export const EllipsifiedWithMarkdown = ({ children }: { children: string }) => {
return (
<Ellipsified
tooltip={
<Markdown disallowHeading unstyleLinks lineClamp={12}>
{children}
</Markdown>
}
>
<Markdown disallowHeading>
{typeof children === "string" ? children.replace(/\s/g, " ") : children}
</Markdown>
</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,117 @@
import { t } from "ttag";
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";
export interface ModelsTableProps {
items: CollectionItem[];
}
const descriptionProps: ResponsiveProps = {
hideAtContainerBreakpoint: "sm",
containerName: "ItemsTableContainer",
};
const collectionProps: ResponsiveProps = {
hideAtContainerBreakpoint: "xs",
containerName: "ItemsTableContainer",
};
export const ModelsTable = ({ items }: 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 />
<ColumnHeader {...descriptionProps}>{t`Description`}</ColumnHeader>
<ColumnHeader {...collectionProps}>{t`Collection`}</ColumnHeader>
<Columns.RightEdge.Header />
</tr>
</thead>
<TBody>
{items.map((item: CollectionItem) => {
return (
<tr key={item.id} style={{ height: 48 }}>
<RowOfCells key={`${item.model}-${item.id}`} item={item} />
</tr>
);
})}
</TBody>
</Table>
);
};
const RowOfCells = ({ item }: { item: CollectionItem }) => {
const icon = item.getIcon();
if (item.model === "card") {
icon.color = color("text-light");
}
const containerName = `collections-path-for-${item.id}`;
return (
<>
{/* Type */}
<Columns.Type.Cell icon={icon} />
{/* Name */}
<Columns.Name.Cell item={item} includeDescription={false} />
{/* Description */}
<ItemCell {...descriptionProps}>
<EllipsifiedWithMarkdown>
{item.description || ""}
</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 />
</>
);
};

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

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

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { connect } from "react-redux";
import type {
@@ -24,7 +24,7 @@ import type { State } from "metabase-types/store";
import { EntityItemMenu } from "./ActionMenu.styled";
interface OwnProps {
export interface ActionMenuProps {
className?: string;
item: CollectionItem;
collection?: Collection;
@@ -36,13 +36,11 @@ interface OwnProps {
deleteBookmark?: DeleteBookmark;
}
interface StateProps {
interface ActionMenuStateProps {
isXrayEnabled: boolean;
isMetabotEnabled: boolean;
}
type ActionMenuProps = OwnProps & StateProps;
function getIsBookmarked(item: CollectionItem, bookmarks: Bookmark[]) {
const normalizedItemModel = normalizeItemModel(item);
@@ -58,7 +56,7 @@ function normalizeItemModel(item: CollectionItem) {
return item.model === "dataset" ? "card" : item.model;
}
function mapStateToProps(state: State): StateProps {
function mapStateToProps(state: State): ActionMenuStateProps {
return {
isXrayEnabled: getSetting(state, "enable-xrays"),
isMetabotEnabled: getSetting(state, "is-metabot-enabled"),
@@ -77,7 +75,7 @@ function ActionMenu({
onMove,
createBookmark,
deleteBookmark,
}: ActionMenuProps) {
}: ActionMenuProps & ActionMenuStateProps) {
const database = databases?.find(({ id }) => id === item.database_id);
const isBookmarked = bookmarks && getIsBookmarked(item, bookmarks);
const canPin = canPinItem(item, collection);
@@ -103,9 +101,15 @@ function ActionMenu({
item.setArchived?.(true);
}, [item]);
const handleToggleBookmark = useCallback(() => {
const toggleBookmark = isBookmarked ? deleteBookmark : createBookmark;
toggleBookmark?.(item.id.toString(), normalizeItemModel(item));
const handleToggleBookmark = useMemo(() => {
if (!createBookmark && !deleteBookmark) {
return undefined;
}
const handler = () => {
const toggleBookmark = isBookmarked ? deleteBookmark : createBookmark;
toggleBookmark?.(item.id.toString(), normalizeItemModel(item));
};
return handler;
}, [createBookmark, deleteBookmark, isBookmarked, item]);
const handleTogglePreview = useCallback(() => {

View File

@@ -142,6 +142,7 @@ describe("ActionMenu", () => {
personal_owner_id: 1,
setCollection: jest.fn(),
setArchived: jest.fn(),
copy: true,
});
setup({ item });
@@ -158,6 +159,7 @@ describe("ActionMenu", () => {
can_write: false,
setCollection: jest.fn(),
setArchived: jest.fn(),
copy: true,
});
setup({ item });

View File

@@ -1,227 +0,0 @@
import type { HTMLAttributes, PropsWithChildren } from "react";
import { t } from "ttag";
import CheckBox from "metabase/core/components/CheckBox";
import type Database from "metabase-lib/v1/metadata/Database";
import type { Bookmark, Collection, CollectionItem } from "metabase-types/api";
import type {
CreateBookmark,
DeleteBookmark,
OnCopy,
OnMove,
OnToggleSelectedWithItem,
} from "../types";
import {
BulkSelectWrapper,
ColumnHeader,
LastEditedByCol,
SortingControlContainer,
SortingIcon,
Table,
TBody,
} from "./BaseItemsTable.styled";
import BaseTableItem, { type BaseTableItemProps } from "./BaseTableItem";
export type SortingOptions = {
sort_column: string;
sort_direction: "asc" | "desc";
};
interface SortableColumnHeaderProps
extends PropsWithChildren<Partial<HTMLAttributes<HTMLDivElement>>> {
name: string;
sortingOptions: SortingOptions;
onSortingOptionsChange: (newSortingOptions: SortingOptions) => void;
}
export enum Sort {
Asc = "asc",
Desc = "desc",
}
const SortableColumnHeader = ({
name,
sortingOptions,
onSortingOptionsChange,
children,
...props
}: SortableColumnHeaderProps) => {
const isSortingThisColumn = sortingOptions.sort_column === name;
const direction = isSortingThisColumn
? sortingOptions.sort_direction
: Sort.Desc;
const onSortingControlClick = () => {
const nextDirection = direction === Sort.Asc ? Sort.Desc : Sort.Asc;
onSortingOptionsChange({
sort_column: name,
sort_direction: nextDirection,
});
};
return (
<ColumnHeader>
<SortingControlContainer
{...props}
isActive={isSortingThisColumn}
onClick={onSortingControlClick}
role="button"
>
{children}
<SortingIcon
name={direction === Sort.Asc ? "chevronup" : "chevrondown"}
/>
</SortingControlContainer>
</ColumnHeader>
);
};
export interface BaseItemsTableProps {
items: CollectionItem[];
collection?: Collection;
databases?: Database[];
bookmarks?: Bookmark[];
createBookmark?: CreateBookmark;
deleteBookmark?: DeleteBookmark;
selectedItems?: CollectionItem[];
hasUnselected?: boolean;
isPinned?: boolean;
renderItem?: (props: ItemRendererProps) => JSX.Element;
sortingOptions: SortingOptions;
onSortingOptionsChange: (newSortingOptions: SortingOptions) => void;
onToggleSelected?: OnToggleSelectedWithItem;
onSelectAll?: () => void;
onSelectNone?: () => void;
onCopy?: OnCopy;
onMove?: OnMove;
onDrop?: () => void;
getIsSelected?: (item: any) => boolean;
/** Used for dragging */
headless?: boolean;
}
type ItemRendererProps = {
item: CollectionItem;
} & BaseTableItemProps;
const defaultItemRenderer = ({ item, ...props }: ItemRendererProps) => {
return (
<BaseTableItem key={`${item.model}-${item.id}`} item={item} {...props} />
);
};
const BaseItemsTable = ({
databases,
bookmarks,
createBookmark,
deleteBookmark,
items,
collection,
selectedItems,
hasUnselected,
isPinned,
renderItem = defaultItemRenderer,
onCopy,
onMove,
onDrop,
sortingOptions,
onSortingOptionsChange,
onToggleSelected,
onSelectAll,
onSelectNone,
getIsSelected = () => false,
headless = false,
...props
}: BaseItemsTableProps) => {
const itemRenderer = (item: CollectionItem) =>
renderItem({
databases,
bookmarks,
createBookmark,
deleteBookmark,
item,
collection,
selectedItems,
isSelected: getIsSelected(item),
isPinned,
onCopy,
onMove,
onDrop,
onToggleSelected,
});
const canSelect = !!collection?.can_write;
return (
<Table canSelect={canSelect} {...props}>
<colgroup>
{canSelect && <col style={{ width: "70px" }} />}
<col style={{ width: "70px" }} />
<col />
<LastEditedByCol />
<col style={{ width: "140px" }} />
<col style={{ width: "100px" }} />
</colgroup>
{!headless && (
<thead
data-testid={
isPinned ? "pinned-items-table-head" : "items-table-head"
}
>
<tr>
{canSelect && (
<ColumnHeader>
<BulkSelectWrapper>
<CheckBox
checked={!!selectedItems?.length}
indeterminate={!!selectedItems?.length && hasUnselected}
onChange={hasUnselected ? onSelectAll : onSelectNone}
aria-label={t`Select all items`}
/>
</BulkSelectWrapper>
</ColumnHeader>
)}
<SortableColumnHeader
name="model"
sortingOptions={sortingOptions}
onSortingOptionsChange={onSortingOptionsChange}
style={{ marginInlineStart: 6 }}
>
{t`Type`}
</SortableColumnHeader>
<SortableColumnHeader
name="name"
sortingOptions={sortingOptions}
onSortingOptionsChange={onSortingOptionsChange}
>
{t`Name`}
</SortableColumnHeader>
<SortableColumnHeader
name="last_edited_by"
sortingOptions={sortingOptions}
onSortingOptionsChange={onSortingOptionsChange}
>
{t`Last edited by`}
</SortableColumnHeader>
<SortableColumnHeader
name="last_edited_at"
sortingOptions={sortingOptions}
onSortingOptionsChange={onSortingOptionsChange}
>
{t`Last edited at`}
</SortableColumnHeader>
<th></th>
</tr>
</thead>
)}
<TBody>{items.map(itemRenderer)}</TBody>
</Table>
);
};
BaseItemsTable.Item = BaseTableItem;
// eslint-disable-next-line import/no-default-export
export default BaseItemsTable;

View File

@@ -3,15 +3,12 @@ import styled from "@emotion/styled";
import Card from "metabase/components/Card";
import Button from "metabase/core/components/Button";
import { alpha, color } from "metabase/lib/colors";
import { NAV_SIDEBAR_WIDTH } from "metabase/nav/constants";
import { space } from "metabase/styled-components/theme";
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"};
margin-bottom: ${space(2)};
z-index: 400; // needed to put this over popovers (z-index: 300)
`;

View File

@@ -39,7 +39,6 @@ type BulkActionsProps = {
onCloseModal: () => void;
onMove: OnMoveWithOneItem;
onCopy: OnCopyWithoutArguments;
isNavbarOpen: boolean;
};
const BulkActions = ({
@@ -52,7 +51,6 @@ const BulkActions = ({
onCloseModal,
onMove,
onCopy,
isNavbarOpen,
}: BulkActionsProps) => {
const canMove = selected.every(item => canMoveItem(item, collection));
const canArchive = selected.every(item => canArchiveItem(item, collection));
@@ -69,7 +67,7 @@ const BulkActions = ({
timingFunction="ease"
>
{styles => (
<BulkActionsToast style={styles} isNavbarOpen={isNavbarOpen}>
<BulkActionsToast style={styles}>
<ToastCard dark data-testid="toast-card">
<CardSide>
{ngettext(

View File

@@ -4,6 +4,11 @@ export const CollectionRoot = styled.div`
height: 100%;
overflow: hidden;
position: relative;
container-name: ItemsTableContainer;
container-type: inline-size;
// NOTE: The BulkActionsToast is centered within this container.
// If this div ever ceases to be a container one day, let's restore the previously
// existing margin-left fix for the toast so it is still centered when the nav sidebar is open
`;
export const CollectionMain = styled.div`

View File

@@ -1,24 +1,21 @@
import _ from "underscore";
import {
useCollectionQuery,
useCollectionListQuery,
useDatabaseListQuery,
useBookmarkListQuery,
useCollectionListQuery,
useCollectionQuery,
useDatabaseListQuery,
} from "metabase/common/hooks";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import Bookmark from "metabase/entities/bookmarks";
import Databases from "metabase/entities/databases";
import { useSelector, useDispatch } from "metabase/lib/redux";
import { useDispatch, useSelector } from "metabase/lib/redux";
import type { UploadFileProps } from "metabase/redux/uploads";
import { uploadFile as uploadFileAction } from "metabase/redux/uploads";
import { getIsNavbarOpen } from "metabase/selectors/app";
import { getSetting } from "metabase/selectors/settings";
import { getUserIsAdmin } from "metabase/selectors/user";
import type {
CollectionId,
BookmarkId,
BookmarkType,
CollectionId,
} from "metabase-types/api";
import { CollectionContentView } from "./CollectionContentView";
@@ -61,7 +58,6 @@ export function CollectionContent({
);
const isAdmin = useSelector(getUserIsAdmin);
const isNavbarOpen = useSelector(getIsNavbarOpen);
const dispatch = useDispatch();
@@ -102,7 +98,6 @@ export function CollectionContent({
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
isAdmin={isAdmin}
isNavbarOpen={isNavbarOpen}
uploadFile={uploadFile}
uploadsEnabled={uploadsEnabled}
canUploadToDb={canUploadToDb}

View File

@@ -8,7 +8,6 @@ import { t } from "ttag";
import ErrorBoundary from "metabase/ErrorBoundary";
import BulkActions from "metabase/collections/components/BulkActions";
import CollectionEmptyState from "metabase/collections/components/CollectionEmptyState";
import ItemsTable from "metabase/collections/components/ItemsTable";
import PinnedItemOverview from "metabase/collections/components/PinnedItemOverview";
import Header from "metabase/collections/containers/CollectionHeader";
import type {
@@ -18,6 +17,11 @@ import type {
UploadFile,
} 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 PaginationControls from "metabase/components/PaginationControls";
import ItemsDragLayer from "metabase/containers/dnd/ItemsDragLayer";
import CS from "metabase/css/core/index.css";
@@ -35,7 +39,6 @@ import type {
CollectionItem,
} from "metabase-types/api";
import { Sort, type SortingOptions } from "../BaseItemsTable";
import type { CollectionOrTableIdProps } from "../ModelUploadModal";
import { ModelUploadModal } from "../ModelUploadModal";
import UploadOverlay from "../UploadOverlay";
@@ -70,7 +73,6 @@ export const CollectionContentView = ({
createBookmark,
deleteBookmark,
isAdmin,
isNavbarOpen,
uploadFile,
uploadsEnabled,
canUploadToDb,
@@ -83,7 +85,6 @@ export const CollectionContentView = ({
createBookmark: CreateBookmark;
deleteBookmark: DeleteBookmark;
isAdmin: boolean;
isNavbarOpen: boolean;
uploadFile: UploadFile;
uploadsEnabled: boolean;
canUploadToDb: boolean;
@@ -396,7 +397,6 @@ export const CollectionContentView = ({
onCopy={clear}
selectedItems={selectedItems}
selectedAction={selectedAction}
isNavbarOpen={isNavbarOpen}
/>
</CollectionTable>
);

View File

@@ -1,9 +1,23 @@
import { useSelector } from "metabase/lib/redux";
import { useDispatch, useSelector } from "metabase/lib/redux";
import type { UpdateUserSettingOptions } from "metabase/redux/settings";
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,
options?: UpdateUserSettingOptions,
): [UserSettings[T], (value: UserSettings[T]) => void] => {
const currentValue = useSetting(key);
const dispatch = useDispatch();
const setter = (value: UserSettings[T]) => {
dispatch(updateUserSetting({ key, value }, options));
};
return [currentValue, 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

@@ -16,7 +16,7 @@ function getForeground(model: string, disabled: boolean) {
: color("brand");
}
const getItemPadding = (variant: string) => {
const getItemPadding = (variant?: string) => {
switch (variant) {
case "list":
return "1rem";
@@ -42,8 +42,8 @@ export const EntityIconWrapper = styled(IconButtonWrapper)<{
`;
export const EntityItemWrapper = styled.div<{
variant: string;
disabled: boolean;
variant?: string;
disabled?: boolean;
}>`
display: flex;
align-items: center;

View File

@@ -283,16 +283,16 @@ const EntityItem = ({
onMove?: OnMove;
onCopy?: OnCopy;
onArchive?: OnArchive;
selected: boolean;
selected?: boolean;
onToggleSelected?: OnToggleSelected;
selectable?: boolean;
variant: string;
variant?: string;
item: CollectionItem;
buttons?: ReactNode;
extraInfo?: ReactNode;
pinned?: boolean;
loading?: boolean;
disabled: boolean;
disabled?: boolean;
}) => {
const icon = useMemo(() => ({ name: iconName }), [iconName]);

View File

@@ -6,81 +6,61 @@ import Link from "metabase/core/components/Link";
import AdminS from "metabase/css/admin.module.css";
import { color } from "metabase/lib/colors";
import BaseModelDetailLink from "metabase/models/components/ModelDetailLink";
import {
breakpointMaxMedium,
breakpointMinLarge,
} from "metabase/styled-components/theme/media-queries";
import { Icon } from "metabase/ui";
import { FixedSizeIcon } from "metabase/ui";
const LAST_EDITED_BY_INDEX = 3;
const LAST_EDITED_AT_INDEX = 4;
import type { ResponsiveProps } from "./utils";
import { getContainerQuery } from "./utils";
export const Table = styled.table<{ canSelect: boolean }>`
export const Table = styled.table<{ isInDragLayer?: boolean }>`
background-color: ${color("white")};
table-layout: fixed;
border-collapse: unset;
border-radius: 8px;
border-radius: 0.5rem;
overflow: hidden;
thead {
th {
border-top: 1px solid ${color("border")};
&:first-of-type {
border-top-left-radius: 8px;
border-left: 1px solid ${color("border")};
border-start-start-radius: 8px;
border-inline-start: 1px solid ${color("border")};
}
&:last-child {
border-top-right-radius: 8px;
border-right: 1px solid ${color("border")};
border-start-end-radius: 8px;
border-inline-end: 1px solid ${color("border")};
}
}
}
${props => {
const offset = props.canSelect ? 1 : 0;
const offsetEditedByIndex = LAST_EDITED_BY_INDEX + offset;
const offsetEditedAtIndex = LAST_EDITED_AT_INDEX + offset;
return `
${breakpointMaxMedium} {
& td:nth-of-type(${offsetEditedByIndex}),
th:nth-of-type(${offsetEditedByIndex}),
col:nth-of-type(${offsetEditedByIndex}),
td:nth-of-type(${offsetEditedAtIndex}),
th:nth-of-type(${offsetEditedAtIndex}),
col:nth-of-type(${offsetEditedAtIndex}) {
display: none;
}
}
`;
}}
${props => (props.isInDragLayer ? `width: 50vw;` : "")}
`;
Table.defaultProps = { className: AdminS.ContentTable };
export const ColumnHeader = styled.th`
padding: 1em 1em 0.75em !important;
export const ColumnHeader = styled.th<ResponsiveProps>`
padding: 0.75em 1em 0.75em !important;
font-weight: bold;
color: ${color("text-medium")};
${({ hideAtContainerBreakpoint, containerName }) =>
getContainerQuery({ hideAtContainerBreakpoint, containerName })};
`;
export const BulkSelectWrapper = styled(IconButtonWrapper)`
padding-left: 12px;
padding-right: 12px;
padding-inline: 12px;
width: 3em;
`;
export const LastEditedByCol = styled.col`
width: 140px;
${breakpointMinLarge} {
width: 240px;
}
export const ItemCell = styled.td<ResponsiveProps>`
padding: 0.25em 0 0.25em 1em !important;
${({ hideAtContainerBreakpoint, containerName }) =>
getContainerQuery({ hideAtContainerBreakpoint, containerName })};
`;
export const ItemCell = styled.td`
padding: 0.25em 0 0.25em 1em !important;
export const TableColumn = styled.col<ResponsiveProps>`
${({ hideAtContainerBreakpoint, containerName }) =>
getContainerQuery({ hideAtContainerBreakpoint, containerName })};
`;
export const EntityIconCheckBox = styled(EntityItem.IconCheckBox)`
@@ -114,11 +94,11 @@ export const ItemNameCell = styled.td`
}
`;
export const SortingIcon = styled(Icon)`
margin-left: 4px;
export const SortingIcon = styled(FixedSizeIcon)`
margin-inline-start: 4px;
`;
export const DescriptionIcon = styled(Icon)`
export const DescriptionIcon = styled(FixedSizeIcon)`
color: ${color("text-medium")};
`;
@@ -131,12 +111,14 @@ export const ModelDetailLink = styled(BaseModelDetailLink)`
visibility: hidden;
`;
export const SortingControlContainer = styled.div<{ isActive: boolean }>`
export const SortingControlContainer = styled.div<{
isActive: boolean;
isSortable?: boolean;
}>`
display: flex;
align-items: center;
color: ${props => (props.isActive ? color("text-dark") : "")};
cursor: pointer;
user-select: none;
${props => (props.isSortable ? `cursor: pointer; user-select: none;` : "")}
.Icon {
visibility: ${props => (props.isActive ? "visible" : "hidden")};
@@ -150,10 +132,15 @@ export const SortingControlContainer = styled.div<{ isActive: boolean }>`
}
}
`;
SortingControlContainer.defaultProps = { isSortable: true };
export const RowActionsContainer = styled.div`
display: flex;
gap: 0.5rem;
span {
display: flex;
align-items: center;
}
`;
export const TableItemSecondaryField = styled.span`
@@ -169,11 +156,11 @@ export const TBody = styled.tbody`
border-top: 1px solid ${color("border")};
&:first-of-type {
border-left: 1px solid ${color("border")};
border-inline-start: 1px solid ${color("border")};
}
&:last-child {
border-right: 1px solid ${color("border")};
border-inline-end: 1px solid ${color("border")};
}
}
@@ -186,11 +173,11 @@ export const TBody = styled.tbody`
border-bottom: 1px solid ${color("border")};
&:last-child {
border-bottom-right-radius: 8px;
border-end-end-radius: 8px;
}
&:first-of-type {
border-bottom-left-radius: 8px;
border-end-start-radius: 8px;
}
}
}

View File

@@ -0,0 +1,309 @@
import {
useCallback,
type HTMLAttributes,
type PropsWithChildren,
} from "react";
import type { ActionMenuProps } from "metabase/collections/components/ActionMenu/ActionMenu";
import type {
CreateBookmark,
DeleteBookmark,
OnCopy,
OnMove,
OnToggleSelectedWithItem,
} from "metabase/collections/types";
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 {
ColumnHeader,
SortingControlContainer,
SortingIcon,
Table,
TBody,
} from "./BaseItemsTable.styled";
import BaseTableItem from "./BaseTableItem";
import { Columns } from "./Columns";
import type { ResponsiveProps } from "./utils";
export type SortingOptions = {
sort_column: string;
sort_direction: "asc" | "desc";
};
export type SortableColumnHeaderProps = {
name?: string;
sortingOptions?: SortingOptions;
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,
},
onSortingOptionsChange,
children,
hideAtContainerBreakpoint,
containerName,
...props
}: SortableColumnHeaderProps & ResponsiveProps) => {
const isSortable = !!onSortingOptionsChange;
const isSortingThisColumn = sortingOptions.sort_column === name;
const direction = isSortingThisColumn
? sortingOptions.sort_direction
: Sort.Desc;
const onSortingControlClick = () => {
const nextDirection = direction === Sort.Asc ? Sort.Desc : Sort.Asc;
onSortingOptionsChange?.({
sort_column: name,
sort_direction: nextDirection,
});
};
return (
<ColumnHeader
hideAtContainerBreakpoint={hideAtContainerBreakpoint}
containerName={containerName}
>
<SortingControlContainer
{...props}
isActive={isSortingThisColumn}
onClick={onSortingControlClick}
role="button"
isSortable={isSortable}
>
{children}
{isSortable && (
<SortingIcon
name={direction === Sort.Asc ? "chevronup" : "chevrondown"}
/>
)}
</SortingControlContainer>
</ColumnHeader>
);
};
export type BaseItemsTableProps = {
items: CollectionItem[];
collection?: Collection;
databases?: Database[];
bookmarks?: Bookmark[];
createBookmark?: CreateBookmark;
deleteBookmark?: DeleteBookmark;
selectedItems?: CollectionItem[];
hasUnselected?: boolean;
isPinned?: boolean;
sortingOptions: SortingOptions;
onSortingOptionsChange: (newSortingOptions: SortingOptions) => void;
onToggleSelected?: OnToggleSelectedWithItem;
onSelectAll?: () => void;
onSelectNone?: () => void;
onCopy?: OnCopy;
onMove?: OnMove;
onDrop?: () => void;
getIsSelected?: (item: any) => boolean;
/** Used for dragging */
headless?: boolean;
isInDragLayer?: boolean;
ItemComponent?: (props: ItemRendererProps) => JSX.Element;
includeColGroup?: boolean;
} & Partial<Omit<HTMLAttributes<HTMLTableElement>, "onCopy">>;
export const BaseItemsTable = ({
databases,
bookmarks,
createBookmark,
deleteBookmark,
items,
collection,
selectedItems,
hasUnselected,
isPinned,
onCopy,
onMove,
onDrop,
sortingOptions,
onSortingOptionsChange,
onToggleSelected,
onSelectAll,
onSelectNone,
getIsSelected = () => false,
headless = false,
isInDragLayer = false,
ItemComponent = DefaultItemRenderer,
includeColGroup = true,
...props
}: BaseItemsTableProps) => {
const canSelect = !!collection?.can_write;
return (
<Table isInDragLayer={isInDragLayer} {...props}>
{includeColGroup && (
<colgroup>
{canSelect && <Columns.Select.Col />}
<Columns.Type.Col />
<Columns.Name.Col isInDragLayer={isInDragLayer} />
<Columns.LastEditedBy.Col />
<Columns.LastEditedAt.Col />
<Columns.ActionMenu.Col />
<Columns.RightEdge.Col />
</colgroup>
)}
{!headless && (
<thead
data-testid={
isPinned ? "pinned-items-table-head" : "items-table-head"
}
>
<tr>
{canSelect && (
<Columns.Select.Header
selectedItems={selectedItems}
hasUnselected={hasUnselected}
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
/>
)}
<Columns.Type.Header
sortingOptions={sortingOptions}
onSortingOptionsChange={onSortingOptionsChange}
/>
<Columns.Name.Header
sortingOptions={sortingOptions}
onSortingOptionsChange={onSortingOptionsChange}
/>
<Columns.LastEditedBy.Header
sortingOptions={sortingOptions}
onSortingOptionsChange={onSortingOptionsChange}
/>
<Columns.LastEditedAt.Header
sortingOptions={sortingOptions}
onSortingOptionsChange={onSortingOptionsChange}
/>
<Columns.ActionMenu.Header />
<Columns.RightEdge.Header />
</tr>
</thead>
)}
<TBody>
{items.map((item: CollectionItem) => {
const isSelected = getIsSelected(item);
const testId = `${isPinned ? "pinned-" : ""}collection-entry`;
return (
<ItemDragSource
item={item}
collection={collection}
isSelected={isSelected}
selected={selectedItems}
onDrop={onDrop}
key={`item-drag-source-${item.id}`}
>
<tr key={item.id} data-testid={testId} style={{ height: 48 }}>
<ItemComponent
key={`${item.model}-${item.id}`}
testId={testId}
item={item}
isSelected={isSelected}
databases={databases}
bookmarks={bookmarks}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
collection={collection}
isPinned={isPinned}
onCopy={onCopy}
onMove={onMove}
onToggleSelected={onToggleSelected}
/>
</tr>
</ItemDragSource>
);
})}
</TBody>
</Table>
);
};
BaseItemsTable.Item = BaseTableItem;
export type ItemRendererProps = {
item: CollectionItem;
isSelected?: boolean;
isPinned?: boolean;
onToggleSelected?: OnToggleSelectedWithItem;
collection?: Collection;
draggable?: boolean;
testId?: string;
} & ActionMenuProps;
const DefaultItemRenderer = ({
item,
isSelected,
isPinned,
onToggleSelected,
collection,
onCopy,
onMove,
createBookmark,
deleteBookmark,
testId = "item-renderer",
}: ItemRendererProps) => {
const canSelect =
collection?.can_write && typeof onToggleSelected === "function";
const icon = item.getIcon();
if (item.model === "card") {
icon.color = color("text-light");
}
const handleSelectionToggled = useCallback(() => {
onToggleSelected?.(item);
}, [item, onToggleSelected]);
return (
<>
{canSelect && (
<Columns.Select.Cell
testId={`${testId}-check`}
icon={icon}
isPinned={isPinned}
isSelected={isSelected}
handleSelectionToggled={handleSelectionToggled}
/>
)}
<Columns.Type.Cell
testId={`${testId}-type`}
icon={icon}
isPinned={isPinned}
/>
<Columns.Name.Cell item={item} testId={`${testId}-name`} />
<Columns.LastEditedBy.Cell
item={item}
testId={`${testId}-last-edited-by`}
/>
<Columns.LastEditedAt.Cell
item={item}
testId={`${testId}-last-edited-at`}
/>
<Columns.ActionMenu.Cell
item={item}
collection={collection}
onCopy={onCopy}
onMove={onMove}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
/>
<Columns.RightEdge.Cell />
</>
);
};
BaseItemsTable.Item = DefaultItemRenderer;

View File

@@ -13,7 +13,7 @@ import type { CollectionItem } from "metabase-types/api";
import { createMockCollection } from "metabase-types/api/mocks";
import type { BaseItemsTableProps } from "./BaseItemsTable";
import BaseItemsTable from "./BaseItemsTable";
import { BaseItemsTable } from "./BaseItemsTable";
const timestamp = "2021-06-03T19:46:52.128";
@@ -43,6 +43,7 @@ function getCollectionItem({
model,
description,
name,
collection: createMockCollection({ can_write: true }),
getIcon: () => ({
name: icon,
}),
@@ -50,7 +51,7 @@ function getCollectionItem({
};
}
describe("Collections BaseItemsTable", () => {
describe("BaseItemsTable", () => {
const ITEM = getCollectionItem();
function setup({
@@ -75,7 +76,7 @@ describe("Collections BaseItemsTable", () => {
it("displays item data", () => {
setup();
const lastEditedAt = moment(timestamp).format("MMMM DD, YYYY");
const lastEditedAt = moment(timestamp).format("MMMM D, YYYY");
expect(screen.getByText(ITEM.name)).toBeInTheDocument();
expect(screen.getByText("John Doe")).toBeInTheDocument();
@@ -84,7 +85,7 @@ describe("Collections BaseItemsTable", () => {
it("displays last edit time on hover", async () => {
setup();
const lastEditedAt = moment(timestamp).format("MMMM DD, YYYY");
const lastEditedAt = moment(timestamp).format("MMMM D, YYYY");
await userEvent.hover(screen.getByText(lastEditedAt));

View File

@@ -2,6 +2,14 @@ import moment from "moment-timezone"; // eslint-disable-line no-restricted-impor
import { useCallback } from "react";
import ActionMenu from "metabase/collections/components/ActionMenu";
import type {
CreateBookmark,
DeleteBookmark,
OnCopy,
OnDrop,
OnMove,
OnToggleSelectedWithItem,
} from "metabase/collections/types";
import DateTime from "metabase/components/DateTime";
import EntityItem from "metabase/components/EntityItem";
import type { Edit } from "metabase/components/LastEditInfoLabel/LastEditInfoLabel";
@@ -15,15 +23,6 @@ import { PLUGIN_MODERATION } from "metabase/plugins";
import type Database from "metabase-lib/v1/metadata/Database";
import type { Bookmark, Collection, CollectionItem } from "metabase-types/api";
import type {
CreateBookmark,
DeleteBookmark,
OnCopy,
OnDrop,
OnMove,
OnToggleSelectedWithItem,
} from "../types";
import {
DescriptionIcon,
EntityIconCheckBox,
@@ -51,6 +50,7 @@ export type BaseTableItemProps = {
onMove?: OnMove;
onDrop?: OnDrop;
onToggleSelected?: OnToggleSelectedWithItem;
shouldShowActionMenu?: boolean;
};
export const BaseTableItem = ({
@@ -69,6 +69,7 @@ export const BaseTableItem = ({
onMove,
onDrop,
onToggleSelected,
shouldShowActionMenu,
}: BaseTableItemProps) => {
const handleSelectionToggled = useCallback(() => {
onToggleSelected?.(item);
@@ -86,10 +87,6 @@ export const BaseTableItem = ({
const testId = isPinned ? "pinned-collection-entry" : "collection-entry";
const trStyles = {
height: 48,
};
const icon = item.getIcon();
if (item.model === "card") {
icon.color = color("text-light");
@@ -99,7 +96,7 @@ export const BaseTableItem = ({
// that only accepts native DOM elements as its children
// So styled-components can't be used here
return (
<tr key={item.id} data-testid={testId} style={trStyles}>
<tr key={item.id} data-testid={testId} style={{ height: 48 }}>
{canSelect && (
<ItemCell data-testid={`${testId}-check`}>
<EntityIconCheckBox
@@ -146,21 +143,24 @@ export const BaseTableItem = ({
</Tooltip>
)}
</ItemCell>
<ItemCell>
<RowActionsContainer>
<ActionMenu
item={item}
collection={collection}
databases={databases}
bookmarks={bookmarks}
onCopy={onCopy}
onMove={onMove}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
/>
{item.model === "dataset" && <ModelDetailLink model={item} />}
</RowActionsContainer>
</ItemCell>
{shouldShowActionMenu && (
<ItemCell>
<RowActionsContainer>
<ActionMenu
item={item}
collection={collection}
databases={databases}
bookmarks={bookmarks}
onCopy={onCopy}
onMove={onMove}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
/>
{item.model === "dataset" && <ModelDetailLink model={item} />}
</RowActionsContainer>
</ItemCell>
)}
<ItemCell />
</tr>
);
}, [
@@ -177,6 +177,7 @@ export const BaseTableItem = ({
collection,
onCopy,
onMove,
shouldShowActionMenu,
]);
if (!draggable) {

View File

@@ -0,0 +1,284 @@
import { t } from "ttag";
import ActionMenu from "metabase/collections/components/ActionMenu";
import type { ActionMenuProps } from "metabase/collections/components/ActionMenu/ActionMenu";
import DateTime from "metabase/components/DateTime";
import EntityItem from "metabase/components/EntityItem";
import type { Edit } from "metabase/components/LastEditInfoLabel/LastEditInfoLabel";
import CheckBox from "metabase/core/components/CheckBox";
import { Ellipsified } from "metabase/core/components/Ellipsified";
import Markdown from "metabase/core/components/Markdown";
import Tooltip from "metabase/core/components/Tooltip";
import { getFullName } from "metabase/lib/user";
import { PLUGIN_MODERATION } from "metabase/plugins";
import type { IconProps } from "metabase/ui";
import type { CollectionItem } from "metabase-types/api";
import type { SortableColumnHeaderProps } from "./BaseItemsTable";
import { SortableColumnHeader } from "./BaseItemsTable";
import {
BulkSelectWrapper,
ColumnHeader,
DescriptionIcon,
EntityIconCheckBox,
ItemCell,
ItemLink,
ItemNameCell,
ModelDetailLink,
RowActionsContainer,
TableColumn,
} from "./BaseItemsTable.styled";
type HeaderProps = Omit<SortableColumnHeaderProps, "name">;
export const Columns = {
Select: {
Col: () => <col style={{ width: "70px" }} />,
Header: ({
selectedItems,
hasUnselected,
onSelectAll,
onSelectNone,
}: {
selectedItems?: CollectionItem[];
hasUnselected?: boolean;
onSelectAll?: () => void;
onSelectNone?: () => void;
}) => (
<ColumnHeader>
<BulkSelectWrapper>
<CheckBox
checked={!!selectedItems?.length}
indeterminate={!!selectedItems?.length && !!hasUnselected}
onChange={hasUnselected ? onSelectAll : onSelectNone}
aria-label={t`Select all items`}
/>
</BulkSelectWrapper>
</ColumnHeader>
),
Cell: ({
testId,
icon,
isPinned,
isSelected,
handleSelectionToggled,
}: {
testId: string;
icon: IconProps;
isPinned?: boolean;
isSelected?: boolean;
handleSelectionToggled: () => void;
}) => (
<ItemCell data-testid={`${testId}-check`}>
<EntityIconCheckBox
variant="list"
icon={icon}
pinned={isPinned}
selected={isSelected}
onToggleSelected={handleSelectionToggled}
selectable
showCheckbox
/>
</ItemCell>
),
},
Type: {
Col: () => <col style={{ width: "70px" }} />,
Header: ({
sortingOptions,
onSortingOptionsChange,
title = t`Type`,
}: HeaderProps) => (
<SortableColumnHeader
name="model"
sortingOptions={sortingOptions}
onSortingOptionsChange={onSortingOptionsChange}
style={{ marginInlineStart: 6 }}
>
{title}
</SortableColumnHeader>
),
Cell: ({
testId = "table",
icon,
isPinned,
}: {
testId?: string;
icon: IconProps;
isPinned?: boolean;
}) => (
<ItemCell data-testid={`${testId}-type`}>
<EntityIconCheckBox variant="list" icon={icon} pinned={isPinned} />
</ItemCell>
),
},
Name: {
Col: ({ isInDragLayer }: { isInDragLayer: boolean }) => (
<col style={{ width: isInDragLayer ? "10rem" : undefined }} />
),
Header: ({ sortingOptions, onSortingOptionsChange }: HeaderProps) => (
<SortableColumnHeader
name="name"
sortingOptions={sortingOptions}
onSortingOptionsChange={onSortingOptionsChange}
>
{t`Name`}
</SortableColumnHeader>
),
Cell: ({
item,
testId = "table",
includeDescription = true,
}: {
item: CollectionItem;
testId?: string;
includeDescription?: boolean;
}) => (
<ItemNameCell data-testid={`${testId}-name`}>
<ItemLink to={item.getUrl()}>
<EntityItem.Name name={item.name} variant="list" />
<PLUGIN_MODERATION.ModerationStatusIcon
size={16}
status={item.moderated_status}
/>
{item.description && includeDescription && (
<DescriptionIcon
name="info"
size={16}
tooltip={
<Markdown dark disallowHeading unstyleLinks lineClamp={8}>
{item.description}
</Markdown>
}
/>
)}
</ItemLink>
</ItemNameCell>
),
},
LastEditedBy: {
Col: () => (
<TableColumn
style={{ width: "140px" }}
hideAtContainerBreakpoint="sm"
containerName="ItemsTableContainer"
/>
),
Header: ({ sortingOptions, onSortingOptionsChange }: HeaderProps) => (
<SortableColumnHeader
name="last_edited_by"
sortingOptions={sortingOptions}
onSortingOptionsChange={onSortingOptionsChange}
hideAtContainerBreakpoint="sm"
containerName="ItemsTableContainer"
>
{t`Last edited by`}
</SortableColumnHeader>
),
Cell: ({
testId = "table",
item,
}: {
testId?: string;
item: CollectionItem;
}) => {
const lastEditInfo = item["last-edit-info"];
const lastEditedBy = getLastEditedBy(lastEditInfo) ?? "";
return (
<ItemCell
data-testid={`${testId}-last-edited-by`}
hideAtContainerBreakpoint="sm"
containerName="ItemsTableContainer"
>
<Ellipsified>{lastEditedBy}</Ellipsified>
</ItemCell>
);
},
},
LastEditedAt: {
Col: () => (
<TableColumn
style={{ width: "140px" }}
hideAtContainerBreakpoint="md"
containerName="ItemsTableContainer"
/>
),
Header: ({ sortingOptions, onSortingOptionsChange }: HeaderProps) => (
<SortableColumnHeader
name="last_edited_at"
sortingOptions={sortingOptions}
onSortingOptionsChange={onSortingOptionsChange}
hideAtContainerBreakpoint="md"
containerName="ItemsTableContainer"
>
{t`Last edited at`}
</SortableColumnHeader>
),
Cell: ({ testId, item }: { testId: string; item: CollectionItem }) => {
const lastEditInfo = item["last-edit-info"];
return (
<ItemCell
data-testid={`${testId}-last-edited-at`}
data-server-date
hideAtContainerBreakpoint="md"
containerName="ItemsTableContainer"
>
{lastEditInfo && (
<Tooltip tooltip={<DateTime value={lastEditInfo.timestamp} />}>
<DateTime unit="day" value={lastEditInfo.timestamp} />
</Tooltip>
)}
</ItemCell>
);
},
},
ActionMenu: {
Header: () => <th></th>,
Col: () => <col style={{ width: "100px" }} />,
Cell: ({
item,
collection,
databases,
bookmarks,
onCopy,
onMove,
createBookmark,
deleteBookmark,
}: ActionMenuProps) => {
return (
<ItemCell>
<RowActionsContainer>
<ActionMenu
item={item}
collection={collection}
databases={databases}
bookmarks={bookmarks}
onCopy={onCopy}
onMove={onMove}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
/>
{item.model === "dataset" && <ModelDetailLink model={item} />}
</RowActionsContainer>
</ItemCell>
);
},
},
/** Applies a border-radius to the right edge of the table.
* This is needed since columns can be hidden responsively,
* and so we can't just apply the border-radius to the last column in the DOM */
RightEdge: {
Header: () => <th></th>,
Col: () => <col style={{ width: "1rem" }} />,
Cell: () => <ItemCell />,
},
};
const getLastEditedBy = (lastEditInfo?: Edit) => {
if (!lastEditInfo) {
return "";
}
const name = getFullName(lastEditInfo);
return name || lastEditInfo.email;
};

View File

@@ -2,8 +2,8 @@ import PinDropZone from "metabase/collections/components/PinDropZone";
import CS from "metabase/css/core/index.css";
import type { CollectionItem } from "metabase-types/api";
import type { BaseItemsTableProps } from "./BaseItemsTable";
import BaseItemsTable from "./BaseItemsTable";
import type { BaseItemsTableProps, ItemRendererProps } from "./BaseItemsTable";
import { BaseItemsTable } from "./BaseItemsTable";
import type { BaseTableItemProps } from "./BaseTableItem";
import { ItemsTableRoot } from "./ItemsTable.styled";
@@ -22,10 +22,14 @@ const Item = ({
);
};
const ItemsTable = ({
export const ItemsTable = ({
items,
ItemComponent = Item,
...props
}: { items: CollectionItem[] } & BaseItemsTableProps) => {
}: {
items: CollectionItem[];
ItemComponent?: (props: ItemRendererProps) => JSX.Element;
} & BaseItemsTableProps) => {
if (items.length === 0) {
return (
<ItemsTableRoot>
@@ -37,10 +41,7 @@ const ItemsTable = ({
return (
<div className={CS.relative}>
<PinDropZone variant="unpin" />
<BaseItemsTable items={items} {...props} renderItem={Item} />
<BaseItemsTable items={items} {...props} ItemComponent={ItemComponent} />
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default ItemsTable;

View File

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

View File

@@ -0,0 +1,14 @@
import { breakpoints, type BreakpointName } from "metabase/ui/theme";
export interface ResponsiveProps {
/** The element will be hidden when the container's width is below this breakpoint */
hideAtContainerBreakpoint?: BreakpointName;
containerName?: string;
}
export const getContainerQuery = (props: ResponsiveProps) =>
props.hideAtContainerBreakpoint
? `@container ${props.containerName || ""} (max-width: ${
breakpoints[props.hideAtContainerBreakpoint]
}) { display: none; }`
: "";

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

@@ -65,7 +65,7 @@ export default DragSource(
onDrop && onDrop();
} catch (e) {
console.error("There was a problem moving these items: " + e);
console.error("There was a problem moving these items: ", e);
}
}
},

View File

@@ -3,9 +3,9 @@ import { Component } from "react";
import { DragLayer } from "react-dnd";
import _ from "underscore";
import BaseItemsTable from "metabase/collections/components/BaseItemsTable";
import PinnedItemCard from "metabase/collections/components/PinnedItemCard";
import BodyComponent from "metabase/components/BodyComponent";
import { BaseItemsTable } from "metabase/components/ItemsTable/BaseItemsTable";
// NOTE: our version of react-hot-loader doesn't play nice with react-dnd's DragLayer,
// so we exclude files named `*DragLayer.jsx` in webpack.config.js
@@ -79,13 +79,11 @@ class DraggedItems extends Component {
renderItem = ({ item, ...itemProps }) => {
const isPinned = this.checkIsPinned(item);
const key = `${item.model}-${item.id}`;
const PINNED_WIDTH = 400;
if (isPinned) {
return (
<div style={{ width: PINNED_WIDTH }}>
<td style={{ padding: 0 }}>
<PinnedItemCard
key={key}
item={item}
@@ -93,7 +91,7 @@ class DraggedItems extends Component {
onCopy={_.noop}
onMove={_.noop}
/>
</div>
</td>
);
}
return (
@@ -111,6 +109,7 @@ class DraggedItems extends Component {
render() {
const { items, draggedItem } = this.props;
const index = _.findIndex(items, draggedItem);
const allPinned = items.every(item => this.checkIsPinned(item));
return (
<div
style={{
@@ -120,9 +119,11 @@ class DraggedItems extends Component {
>
<BaseItemsTable
items={items}
renderItem={this.renderItem}
ItemComponent={props => this.renderItem(props)}
headless
style={{ width: 960 }}
isInDragLayer
style={{ width: allPinned ? 400 : undefined }}
includeColGroup={!allPinned}
/>
</div>
);

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 [truncatedStatuses, setTruncatedStatuses] = 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);
setTruncatedStatuses(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 = [...truncatedStatuses.values()].some(Boolean);
return { areAnyTruncated, ref };
};

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,78 @@ function MainNavbarView({
}
}, [handleCloseNavbar]);
const [expandBrowse, setExpandBrowse] = useUserSetting(
"expand-browse-in-nav",
{ shouldRefresh: false },
);
const [expandBookmarks, setExpandBookmarks] = useUserSetting(
"expand-bookmarks-in-nav",
{ shouldRefresh: false },
);
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 +169,8 @@ function MainNavbarView({
selectedItem={cardItem ?? dashboardItem ?? collectionItem}
onSelect={onItemSelect}
reorderBookmarks={reorderBookmarks}
onToggle={_.debounce(setExpandBookmarks, debounceTimeout)}
initialState={expandBookmarks ? "expanded" : "collapsed"}
/>
</SidebarSection>
)}
@@ -156,12 +194,10 @@ function MainNavbarView({
</SidebarContentRoot>
);
}
interface CollectionSectionHeadingProps {
currentUser: User;
handleCreateNewCollection: () => void;
}
function CollectionSectionHeading({
currentUser,
handleCreateNewCollection,
@@ -217,6 +253,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

@@ -35,13 +35,20 @@ export const settings = createReducer(
},
);
export type UpdateUserSettingOptions = {
shouldRefresh?: boolean;
};
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>;
}) {
function <K extends keyof UserSettings>(
setting: {
key: K;
value: UserSettings[K];
},
{ shouldRefresh = true }: UpdateUserSettingOptions = {},
) {
return async function (dispatch) {
try {
await SettingsApi.put(setting);
@@ -49,7 +56,9 @@ export const updateUserSetting = createThunkAction(
console.error("error updating user setting", setting, error);
throw error;
} finally {
await dispatch(refreshSiteSettings({}));
if (shouldRefresh) {
await dispatch(refreshSiteSettings({}));
}
}
};
},

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

@@ -39,14 +39,17 @@ import {
} from "./components";
import { getThemeColors } from "./utils/colors";
export const breakpoints = {
xs: "23em",
sm: "40em",
md: "60em",
lg: "80em",
xl: "120em",
};
export type BreakpointName = keyof typeof breakpoints;
export const getThemeOverrides = (): MantineThemeOverride => ({
breakpoints: {
xs: "23em",
sm: "40em",
md: "60em",
lg: "80em",
xl: "120em",
},
breakpoints,
colors: getThemeColors(),
primaryColor: "brand",
primaryShade: 0,

View File

@@ -493,6 +493,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