Skip to content
Snippets Groups Projects
Unverified Commit 213d77ff authored by metabase-bot[bot]'s avatar metabase-bot[bot] Committed by GitHub
Browse files

:cherries: Backport Browse models improvements (#38661) (#38730) (#38704)

* Browse models: Add one filter (#38661)
* Browse models: refactoring (#38730)
* Browse models: expandable/collapsible collections (#38704)
parent 740815f6
No related branches found
No related tags found
No related merge requests found
Showing
with 1260 additions and 372 deletions
import { restore } from "e2e/support/helpers";
import { restore, setTokenFeatures } from "e2e/support/helpers";
describe("scenarios > browse data", () => {
beforeEach(() => {
......@@ -14,12 +14,6 @@ describe("scenarios > browse data", () => {
cy.findByRole("heading", { name: "Orders Model" }).click();
cy.findByRole("button", { name: "Filter" });
});
it("can view summary of model's last edit", () => {
cy.visit("/");
cy.findByRole("listitem", { name: "Browse data" }).click();
cy.findByRole("note", /Bobby Tables/).realHover();
cy.findByRole("tooltip", { name: /Last edited by Bobby Tables/ });
});
it("can browse to a database", () => {
cy.visit("/");
cy.findByRole("listitem", { name: "Browse data" }).click();
......@@ -49,18 +43,45 @@ describe("scenarios > browse data", () => {
);
cy.location("pathname").should("eq", "/browse/models");
cy.findByRole("tab", { name: "Databases" }).click();
cy.findByRole("heading", { name: "Sample Database" }).click();
cy.findByRole("listitem", { name: "Browse data" }).click();
cy.log(
"/browse/ now defaults to /browse/databases/ because it was the last tab visited",
);
cy.location("pathname").should("eq", "/browse/databases");
cy.findByRole("tab", { name: "Models" }).click();
cy.findByRole("heading", { name: "Orders Model" });
cy.findByRole("listitem", { name: "Browse data" }).click();
cy.log(
"/browse/ now defaults to /browse/models/ because it was the last tab visited",
);
cy.location("pathname").should("eq", "/browse/models");
});
it("/browse/models has no switch for controlling the 'only show verified models' filter, on an open-source instance", () => {
cy.visit("/");
cy.findByRole("listitem", { name: "Browse data" }).click();
cy.findByRole("switch", { name: /Only show verified models/ }).should(
"not.exist",
);
});
it("/browse/models allows models to be filtered, on an enterprise instance", () => {
const toggle = () =>
cy.findByRole("switch", { name: /Only show verified models/ });
setTokenFeatures("all");
cy.visit("/");
cy.findByRole("listitem", { name: "Browse data" }).click();
cy.findByRole("heading", { name: "Our analytics" }).should("not.exist");
cy.findByRole("heading", { name: "Orders Model" }).should("not.exist");
toggle().next("label").click();
toggle().should("have.attr", "aria-checked", "false");
cy.findByRole("heading", { name: "Orders Model" }).click();
cy.findByLabelText("Move, archive, and more...").click();
cy.findByRole("dialog", {
name: /ellipsis icon/i,
})
.findByText(/Verify this model/)
.click();
cy.visit("/browse");
toggle().next("label").click();
cy.findByRole("heading", { name: "Orders Model" }).should("be.visible");
toggle().should("have.attr", "aria-checked", "true");
});
});
import styled from "@emotion/styled";
import { Text } from "metabase/ui";
export const ModelFilterControlSwitchLabel = styled(Text)`
text-align: right;
font-weight: bold;
line-height: 1rem;
padding: 0 0.75rem;
`;
import { t } from "ttag";
import { Switch, Text } from "metabase/ui";
import type { ModelFilterControlsProps } from "metabase/browse/utils";
export const ModelFilterControls = ({
actualModelFilters,
handleModelFilterChange,
}: ModelFilterControlsProps) => {
const checked = actualModelFilters.onlyShowVerifiedModels;
return (
<Switch
label={
<Text
align="right"
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);
}}
ml="auto"
size="sm"
labelPosition="left"
styles={{
root: { display: "flex", alignItems: "center" },
body: {
alignItems: "center",
// Align with tab labels:
position: "relative",
top: "-.5px",
},
labelWrapper: { justifyContent: "center", padding: 0 },
track: { marginTop: "-1.5px" },
}}
/>
);
};
import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins";
import { hasPremiumFeature } from "metabase-enterprise/settings";
import { VerifiedFilter } from "metabase-enterprise/content_verification/VerifiedFilter";
import { VerifiedFilter } from "./VerifiedFilter";
import { ModelFilterControls } from "./ModelFilterControls";
import {
availableModelFilters,
sortCollectionsByVerification,
sortModelsByVerification,
} from "./utils";
if (hasPremiumFeature("content_verification")) {
PLUGIN_CONTENT_VERIFICATION.VerifiedFilter = VerifiedFilter;
Object.assign(PLUGIN_CONTENT_VERIFICATION, {
VerifiedFilter,
ModelFilterControls,
availableModelFilters,
sortModelsByVerification,
sortCollectionsByVerification,
});
}
import type { CollectionEssentials, SearchResult } from "metabase-types/api";
import type { AvailableModelFilters } from "metabase/browse/utils";
export const sortCollectionsByVerification = (
collection1: CollectionEssentials,
collection2: CollectionEssentials,
) => {
const isCollection1Official = collection1.authority_level === "official";
const isCollection2Official = collection2.authority_level === "official";
if (isCollection1Official && !isCollection2Official) {
return -1;
}
if (isCollection2Official && !isCollection1Official) {
return 1;
}
return 0;
};
export const sortModelsByVerification = (a: SearchResult, b: SearchResult) => {
const aVerified = a.moderated_status === "verified";
const bVerified = b.moderated_status === "verified";
if (aVerified && !bVerified) {
return -1;
}
if (!aVerified && bVerified) {
return 1;
}
return 0;
};
export const availableModelFilters: AvailableModelFilters = {
onlyShowVerifiedModels: {
predicate: model => model.moderated_status === "verified",
activeByDefault: true,
},
};
import type { CollectionEssentials, SearchResult } from "metabase-types/api";
import { createMockModelResult } from "metabase-types/api/mocks";
import { availableModelFilters, sortCollectionsByVerification } from "./utils";
describe("Utilities related to content verification", () => {
it("include a function that sorts verified collections before unverified collections", () => {
const unsorted: CollectionEssentials[] = [
{
id: 99,
authority_level: "official",
name: "Collection Zulu - verified",
},
{
id: 1,
authority_level: null,
name: "Collection Alpha - unverified",
},
];
const sortFunction = (a: CollectionEssentials, b: CollectionEssentials) =>
sortCollectionsByVerification(a, b) || a.name.localeCompare(b.name);
const sorted = unsorted.sort(sortFunction);
expect(sorted[0].name).toBe("Collection Zulu - verified");
expect(sorted[1].name).toBe("Collection Alpha - unverified");
});
it("include a constant that defines a filter for only showing verified models", () => {
const models: SearchResult[] = [
createMockModelResult({
name: "A verified model",
moderated_status: "verified",
}),
createMockModelResult({
name: "An unverified model",
moderated_status: null,
}),
];
const filteredModels = models.filter(
availableModelFilters.onlyShowVerifiedModels.predicate,
);
expect(filteredModels.length).toBe(1);
expect(filteredModels[0].name).toBe("A verified model");
});
});
......@@ -13,6 +13,7 @@ import {
MODERATION_STATUS,
getStatusIcon,
getModerationTimelineEvents,
getQuestionIcon,
verifyItem,
removeReview,
isItemVerified,
......@@ -28,6 +29,7 @@ if (hasPremiumFeature("content_verification")) {
ModerationReviewBanner,
ModerationStatusIcon,
getStatusIcon,
getQuestionIcon,
getModerationTimelineEvents,
getMenuItems: (model, isModerator, reload) => {
const id = model.id();
......
......@@ -135,3 +135,10 @@ export function getModerationTimelineEvents(reviews, usersById, currentUser) {
};
});
}
export const getQuestionIcon = question => {
return (question.model === "dataset" || question.dataset) &&
question.moderated_status === "verified"
? { icon: "model_with_badge", tooltip: "Verified model" }
: null;
};
import styled from "@emotion/styled";
import { Tabs } from "metabase/ui";
import { color } from "metabase/lib/colors";
import EmptyState from "metabase/components/EmptyState";
import { color } from "metabase/lib/colors";
import {
breakpointMinMedium,
breakpointMinSmall,
} from "metabase/styled-components/theme";
import { Grid, Icon, Tabs } from "metabase/ui";
export const BrowseAppRoot = styled.div`
flex: 1;
......@@ -15,7 +19,7 @@ export const BrowseTabs = styled(Tabs)`
`;
export const BrowseTabsList = styled(Tabs.List)`
padding: 0 1rem;
padding: 0 2.5rem;
background-color: ${color("white")};
border-bottom-width: 1px;
`;
......@@ -24,7 +28,8 @@ export const BrowseTab = styled(Tabs.Tab)`
top: 1px;
margin-bottom: 1px;
border-bottom-width: 3px !important;
padding: 10px;
padding: 10px 0px;
margin-right: 10px;
&:hover {
color: ${color("brand")};
background-color: inherit;
......@@ -37,7 +42,7 @@ export const BrowseTabsPanel = styled(Tabs.Panel)`
flex-flow: column nowrap;
flex: 1;
height: 100%;
padding: 0 1rem;
padding: 0 2.5rem;
`;
export const BrowseContainer = styled.div`
......@@ -49,23 +54,25 @@ export const BrowseContainer = styled.div`
export const BrowseDataHeader = styled.header`
display: flex;
padding: 1rem;
padding: 1rem 2.5rem;
padding-bottom: 0.375rem;
color: ${color("dark")};
background-color: ${color("white")};
`;
export const BrowseSectionContainer = styled.div`
max-width: 1014px;
margin: 0 auto;
flex: 1;
display: flex;
export const BrowseGrid = styled(Grid)`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: 0rem 1rem;
margin: 0;
width: 100%;
`;
export const BrowseTabsContainer = styled(BrowseSectionContainer)`
flex-flow: column nowrap;
justify-content: flex-start;
${breakpointMinSmall} {
padding-bottom: 2.5rem;
}
${breakpointMinMedium} {
padding-bottom: 3rem;
}
`;
export const CenteredEmptyState = styled(EmptyState)`
......@@ -76,3 +83,8 @@ export const CenteredEmptyState = styled(EmptyState)`
justify-content: center;
height: 100%;
`;
export const LearnAboutDataIcon = styled(Icon)`
min-width: 14px;
min-height: 14px;
`;
import { useEffect } from "react";
import { t } from "ttag";
import { useCallback, useEffect, useMemo, useState } from "react";
import { push } from "react-router-redux";
import { Flex, Icon, Text } from "metabase/ui";
import { t } from "ttag";
import _ from "underscore";
import type { SearchResult } from "metabase-types/api";
import {
useDatabaseListQuery,
useSearchListQuery,
} from "metabase/common/hooks";
import type { SearchResult } from "metabase-types/api";
import { useDispatch } from "metabase/lib/redux";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import type { FlexProps } from "metabase/ui";
import { Flex, Text } from "metabase/ui";
import Link from "metabase/core/components/Link";
import { isValidBrowseTab, type BrowseTabId } from "../utils";
import { BrowseDatabases } from "./BrowseDatabases";
import { BrowseModels } from "./BrowseModels";
import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins";
import type { ActualModelFilters } from "../utils";
import { isValidBrowseTab, type BrowseTabId, filterModels } from "../utils";
import {
BrowseAppRoot,
BrowseContainer,
BrowseDataHeader,
BrowseSectionContainer,
BrowseTab,
BrowseTabs,
BrowseTabsContainer,
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,
......@@ -43,23 +48,60 @@ export const BrowseApp = ({
const databasesResult = useDatabaseListQuery();
useEffect(() => {
if (isValidBrowseTab(tab)) {
localStorage.setItem("defaultBrowseTab", tab);
}
localStorage.setItem("defaultBrowseTab", tab);
}, [tab]);
if (!isValidBrowseTab(tab)) {
return <LoadingAndErrorWrapper error />;
}
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>
<BrowseSectionContainer>
<BrowseSection>
<h2>{t`Browse data`}</h2>
{tab === "databases" && <LearnAboutDataLink />}
</BrowseSectionContainer>
</BrowseSection>
</BrowseDataHeader>
<BrowseTabs
value={tab}
......@@ -70,25 +112,32 @@ export const BrowseApp = ({
}}
>
<BrowseTabsList>
<BrowseSectionContainer>
<BrowseSection>
<BrowseTab key={"models"} value={"models"}>
{t`Models`}
</BrowseTab>
<BrowseTab key={"databases"} value={"databases"}>
{t`Databases`}
</BrowseTab>
</BrowseSectionContainer>
{tab === "models" && (
<PLUGIN_CONTENT_VERIFICATION.ModelFilterControls
actualModelFilters={actualModelFilters}
handleModelFilterChange={handleModelFilterChange}
/>
)}
{tab === "databases" && <LearnAboutDataLink />}
</BrowseSection>
</BrowseTabsList>
<BrowseTabsPanel key={tab} value={tab}>
<BrowseTabsContainer>
<BrowseSection direction="column">
<BrowseTabContent
tab={tab}
modelsResult={modelsResult}
modelsResult={filteredModelsResult}
databasesResult={databasesResult}
>
{children}
</BrowseTabContent>
</BrowseTabsContainer>
</BrowseSection>
</BrowseTabsPanel>
</BrowseTabs>
</BrowseContainer>
......@@ -117,10 +166,10 @@ const BrowseTabContent = ({
return <BrowseDatabases databasesResult={databasesResult} />;
};
const LearnAboutDataLink = () => (
<Flex ml="auto" justify="right" style={{ flexBasis: "40.0%" }}>
<Flex ml="auto" justify="right" align="center" style={{ flexBasis: "40.0%" }}>
<Link to="reference">
<BrowseHeaderIconContainer>
<Icon size={14} name="reference" />
<LearnAboutDataIcon size={14} name="reference" />
<Text size="md" lh="1" fw="bold" ml=".5rem" c="inherit">
{t`Learn about our data`}
</Text>
......@@ -128,3 +177,7 @@ const LearnAboutDataLink = () => (
</Link>
</Flex>
);
const BrowseSection = (props: FlexProps) => (
<Flex maw="64rem" m="0 auto" w="100%" {...props} />
);
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import {
breakpointMinMedium,
breakpointMinSmall,
} from "metabase/styled-components/theme";
import { Link } from "react-router";
import Card from "metabase/components/Card";
import { GridItem, Grid } from "metabase/components/Grid";
import { color } from "metabase/lib/colors";
import { BrowseGrid } from "./BrowseApp.styled";
export const DatabaseGrid = styled(Grid)`
width: 100%;
export const DatabaseGrid = styled(BrowseGrid)`
margin-top: 1rem;
`;
export const DatabaseCard = styled(Card)`
padding: 1.5rem;
box-shadow: 0px 1px 4px 0px rgba(0, 0, 0, 0.06) !important;
margin-bottom: 1rem;
box-shadow: none;
&:hover {
color: ${color("brand")};
}
`;
export const DatabaseGridItem = styled(GridItem)`
width: 100%;
export const DatabaseCardLink = styled(Link)`
&:hover {
color: ${color("brand")};
}
${breakpointMinSmall} {
width: 50%;
}
${breakpointMinMedium} {
width: 33.33%;
}
`;
import _ from "underscore";
import { t } from "ttag";
import * as Urls from "metabase/lib/urls";
import { color } from "metabase/lib/colors";
import * as Urls from "metabase/lib/urls";
import { Icon, Box } from "metabase/ui";
import Link from "metabase/core/components/Link";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import { Box, Icon, Title } from "metabase/ui";
import type { useDatabaseListQuery } from "metabase/common/hooks";
import NoResults from "assets/img/no_results.svg";
import { CenteredEmptyState } from "./BrowseApp.styled";
import {
DatabaseCard,
DatabaseCardLink,
DatabaseGrid,
DatabaseGridItem,
} from "./BrowseDatabases.styled";
import { CenteredEmptyState } from "./BrowseApp.styled";
export const BrowseDatabases = ({
databasesResult,
......@@ -36,8 +34,8 @@ export const BrowseDatabases = ({
return databases.length ? (
<DatabaseGrid data-testid="database-browser">
{databases.map(database => (
<DatabaseGridItem key={database.id}>
<Link to={Urls.browseDatabase(database)}>
<div key={database.id}>
<DatabaseCardLink to={Urls.browseDatabase(database)}>
<DatabaseCard>
<Icon
name="database"
......@@ -45,10 +43,12 @@ export const BrowseDatabases = ({
className="mb3"
size={32}
/>
<h3 className="text-wrap">{database.name}</h3>
<Title order={2} size="1rem" lh="1rem" color="inherit">
{database.name}
</Title>
</DatabaseCard>
</Link>
</DatabaseGridItem>
</DatabaseCardLink>
</div>
))}
</DatabaseGrid>
) : (
......
......@@ -4,7 +4,7 @@ import { color } from "metabase/lib/colors";
export const BrowseHeaderContent = styled.div`
display: flex;
align-items: center;
padding: 1rem 0.5rem 0.5rem 0.5rem;
padding: 1rem 0.5rem 0.5rem 0;
`;
export const BrowseHeaderIconContainer = styled.div`
......
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import {
breakpointMinMedium,
breakpointMinSmall,
} from "metabase/styled-components/theme";
import type { HTMLAttributes } from "react";
import Card from "metabase/components/Card";
import { Ellipsified } from "metabase/core/components/Ellipsified";
import Link from "metabase/core/components/Link";
import { Flex, Grid, Group, Icon } from "metabase/ui";
import IconButtonWrapper from "metabase/components/IconButtonWrapper";
import Link from "metabase/core/components/Link";
import { color } from "metabase/lib/colors";
import { Collapse, Icon, type ButtonProps, Box } from "metabase/ui";
import { BrowseGrid } from "./BrowseApp.styled";
export const ModelCardLink = styled(Link)`
margin: 0.5rem 0;
`;
export const ModelCard = styled(Card)`
padding: 1.5rem;
......@@ -50,45 +54,95 @@ export const MultilineEllipsified = styled(Ellipsified)`
padding-bottom: 1px;
`;
export const GridContainer = styled(Grid)`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: 1.5rem 1rem;
margin: 0;
width: 100%;
${breakpointMinSmall} {
padding-bottom: 1rem;
}
${breakpointMinMedium} {
padding-bottom: 3rem;
}
`;
export const ModelGrid = styled(BrowseGrid)``;
export const CollectionHeaderContainer = styled(Flex)`
export const CollectionHeaderContainer = styled.button`
grid-column: 1 / -1;
&:not(:first-of-type) {
border-top: 1px solid #f0f0f0;
display: flex;
align-items: center;
border-top: 1px solid ${color("border")};
margin-top: 0.75rem;
cursor: pointer;
color: ${color("text-dark")};
&:hover {
color: ${color("brand")};
}
:first-of-type {
margin-top: 1rem;
border-top: none;
}
`;
export const CollectionHeaderLink = styled(Link)`
&:hover * {
display: flex;
align-items: center;
&:hover {
color: ${color("brand")};
}
`;
export const CollectionHeaderGroup = styled(Group)`
export const BannerCloseButton = styled(IconButtonWrapper)`
color: ${color("text-light")};
margin-left: auto;
`;
export const CollectionCollapse = styled(Collapse)`
display: contents;
`;
export const ContainerExpandCollapseButton = styled.div`
border: 0;
background-color: inherit;
`;
export const CollectionExpandCollapseContainer = styled(Box)<
ButtonProps & HTMLAttributes<HTMLButtonElement>
>`
display: flex;
gap: 0.25rem;
justify-content: flex-start;
align-items: center;
grid-column: 1 / -1;
margin: 1rem 0.25rem;
`;
export const CollectionHeaderToggleContainer = styled.div`
padding: 0.5rem;
padding-right: 0.75rem;
position: relative;
top: 0.5rem;
margin-left: -2.25rem;
margin-top: 0.75rem;
border: none;
background-color: transparent;
overflow: unset;
&:hover {
background-color: inherit;
div,
svg {
color: ${color("brand")};
}
}
`;
export const CollectionSummary = styled.div`
margin-left: auto;
white-space: nowrap;
font-size: 0.75rem;
color: ${color("text-medium")};
`;
export const FixedSizeIcon = styled(Icon)<{ size?: number }>`
min-width: ${({ size }) => size ?? 16}px;
min-height: ${({ size }) => size ?? 16}px;
`;
export const BannerModelIcon = styled(Icon)`
export const BannerModelIcon = styled(FixedSizeIcon)`
color: ${color("text-dark")};
margin-right: 0.5rem;
`;
export const BannerCloseButton = styled(IconButtonWrapper)`
color: ${color("text-light")};
margin-left: auto;
export const HoverUnderlineLink = styled(Link)`
&:hover {
text-decoration: underline;
}
`;
import _ from "underscore";
import { useState } from "react";
import { t } from "ttag";
import type {
Card,
CollectionEssentials,
SearchResult,
} from "metabase-types/api";
import * as Urls from "metabase/lib/urls";
import Link from "metabase/core/components/Link";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import Search from "metabase/entities/search";
import { useDispatch, useSelector } from "metabase/lib/redux";
import type { useSearchListQuery } from "metabase/common/hooks";
import { Box, Group, Icon, Text, Title } from "metabase/ui";
import NoResults from "assets/img/no_results.svg";
import type { useSearchListQuery } from "metabase/common/hooks";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import { useSelector } from "metabase/lib/redux";
import { getLocale } from "metabase/setup/selectors";
import { isInstanceAnalyticsCollection } from "metabase/collections/utils";
import { Box } from "metabase/ui";
import type { SearchResult, CollectionId } from "metabase-types/api";
import { BROWSE_MODELS_LOCALSTORAGE_KEY } from "../constants";
import { getCollectionViewPreferences, groupModels } from "../utils";
import { color } from "metabase/lib/colors";
import { getCollectionName, groupModels } from "../utils";
import { CenteredEmptyState } from "./BrowseApp.styled";
import {
CollectionHeaderContainer,
CollectionHeaderGroup,
CollectionHeaderLink,
GridContainer,
ModelCard,
MultilineEllipsified,
} from "./BrowseModels.styled";
import { LastEdited } from "./LastEdited";
import { ModelGrid } from "./BrowseModels.styled";
import { ModelExplanationBanner } from "./ModelExplanationBanner";
import { ModelGroup } from "./ModelGroup";
export const BrowseModels = ({
modelsResult,
......@@ -43,10 +25,9 @@ export const BrowseModels = ({
const { data: models = [], error, isLoading } = modelsResult;
const locale = useSelector(getLocale);
const localeCode: string | undefined = locale?.code;
const modelsFiltered = models.filter(
model => !isInstanceAnalyticsCollection(model.collection),
const [collectionViewPreferences, setCollectionViewPreferences] = useState(
getCollectionViewPreferences,
);
const groupsOfModels = groupModels(modelsFiltered, localeCode);
if (error || isLoading) {
return (
......@@ -58,19 +39,66 @@ export const BrowseModels = ({
);
}
if (modelsFiltered.length) {
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 (
<>
<ModelExplanationBanner />
<GridContainer role="grid">
{groupsOfModels.map(groupOfModels => (
<ModelGroup
models={groupOfModels}
key={`modelgroup-${groupOfModels[0].collection.id}`}
localeCode={localeCode}
/>
))}
</GridContainer>
<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>
</>
);
}
......@@ -89,115 +117,3 @@ export const BrowseModels = ({
/>
);
};
const ModelGroup = ({
models,
localeCode,
}: {
models: SearchResult[];
localeCode: string | undefined;
}) => {
const sortedModels = models.sort((a, b) => {
if (!a.name && b.name) {
return 1;
}
if (a.name && !b.name) {
return -1;
}
if (!a.name && !b.name) {
return 0;
}
const nameA = a.name.toLowerCase();
const nameB = b.name.toLowerCase();
return nameA.localeCompare(nameB, localeCode);
});
const collection = models[0].collection;
/** This id is used by aria-labelledby */
const collectionHtmlId = `collection-${collection.id}`;
// TODO: Check padding above the collection header
return (
<>
<CollectionHeader
collection={collection}
key={collectionHtmlId}
id={collectionHtmlId}
/>
{sortedModels.map(model => (
<ModelCell
model={model}
collectionHtmlId={collectionHtmlId}
key={`model-${model.id}`}
/>
))}
</>
);
};
interface ModelCellProps {
model: SearchResult;
collectionHtmlId: string;
}
const ModelCell = ({ model, collectionHtmlId }: ModelCellProps) => {
const headingId = `heading-for-model-${model.id}`;
const lastEditorFullName =
model.last_editor_common_name ?? model.creator_common_name;
const timestamp = model.last_edited_at ?? model.created_at ?? "";
return (
<Link
aria-labelledby={`${collectionHtmlId} ${headingId}`}
key={model.id}
to={Urls.model(model as unknown as Partial<Card>)}
>
<ModelCard>
<Box mb="auto">
<Icon name="model" size={20} color={color("brand")} />
</Box>
<Title mb=".25rem" size="1rem">
<MultilineEllipsified tooltipMaxWidth="20rem" id={headingId}>
{model.name}
</MultilineEllipsified>
</Title>
<LastEdited editorFullName={lastEditorFullName} timestamp={timestamp} />
</ModelCard>
</Link>
);
};
const CollectionHeader = ({
collection,
id,
}: {
collection: CollectionEssentials;
id: string;
}) => {
const dispatch = useDispatch();
const wrappable = { ...collection, model: "collection" };
const wrappedCollection = Search.wrapEntity(wrappable, dispatch);
const icon = wrappedCollection.getIcon();
return (
<CollectionHeaderContainer
id={id}
role="heading"
pt={"1rem"}
mr="1rem"
align="center"
>
<CollectionHeaderGroup grow noWrap>
<CollectionHeaderLink to={Urls.collection(collection)}>
<Group spacing=".25rem">
<Icon {...icon} />
<Text weight="bold" color="text-dark">
{getCollectionName(collection)}
</Text>
</Group>
</CollectionHeaderLink>
</CollectionHeaderGroup>
</CollectionHeaderContainer>
);
};
import fetchMock from "fetch-mock";
import { renderWithProviders, screen, within } from "__support__/ui";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "__support__/ui";
import { defaultRootCollection } from "metabase/admin/permissions/pages/CollectionPermissionsPage/tests/setup";
import type { SearchResult } from "metabase-types/api";
import { createMockSetupState } from "metabase-types/store/mocks";
import {
createMockCollection,
createMockModelResult,
createMockSettingDefinition,
createMockSettings,
} from "metabase-types/api/mocks";
import { defaultRootCollection } from "metabase/admin/permissions/pages/CollectionPermissionsPage/tests/setup";
import {
setupPropertiesEndpoints,
setupSettingsEndpoints,
} from "__support__/server-mocks";
import { groupModels } from "../utils";
import { createMockSetupState } from "metabase-types/store/mocks";
import { BROWSE_MODELS_LOCALSTORAGE_KEY } from "../constants";
import { BrowseModels } from "./BrowseModels";
const renderBrowseModels = (modelCount: number) => {
......@@ -32,13 +29,14 @@ const renderBrowseModels = (modelCount: number) => {
);
};
const collectionAlpha = createMockCollection({ id: 0, name: "Alpha" });
const collectionAlpha = createMockCollection({ id: 99, name: "Alpha" });
const collectionBeta = createMockCollection({ id: 1, name: "Beta" });
const collectionCharlie = createMockCollection({ id: 2, name: "Charlie" });
const collectionDelta = createMockCollection({ id: 3, name: "Delta" });
const collectionZulu = createMockCollection({ id: 4, name: "Zulu" });
const collectionAngstrom = createMockCollection({ id: 5, name: "Ångström" });
const collectionOzgur = createMockCollection({ id: 6, name: "Özgür" });
const collectionGrande = createMockCollection({ id: 7, name: "Grande" });
const mockModels: SearchResult[] = [
{
......@@ -190,117 +188,178 @@ const mockModels: SearchResult[] = [
},
{
id: 21,
name: "Model 20",
name: "Model 21",
collection: defaultRootCollection,
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
{
id: 22,
name: "Model 21",
name: "Model 22",
collection: defaultRootCollection,
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
...new Array(100).fill(null).map((_, i) => {
return createMockModelResult({
id: i + 300,
name: `Model ${i + 300}`,
collection: collectionGrande,
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
});
}),
].map(model => createMockModelResult(model));
describe("BrowseModels", () => {
beforeEach(() => {
setupPropertiesEndpoints(createMockSettings());
setupSettingsEndpoints([createMockSettingDefinition()]);
fetchMock.put("path:/api/setting/default-browse-tab", 200);
});
it("displays models", async () => {
renderBrowseModels(10);
for (let i = 0; i < 10; i++) {
expect(await screen.findByText(`Model ${i}`)).toBeInTheDocument();
}
localStorage.clear();
});
it("displays a 'no models' message in the Models tab when no models exist", async () => {
renderBrowseModels(0);
expect(await screen.findByText("No models here yet")).toBeInTheDocument();
});
it("displays models, organized by parent collection", async () => {
it("displays collection groups", async () => {
renderBrowseModels(10);
expect(await screen.findByText("Alpha")).toBeInTheDocument();
expect(await screen.findByText("Beta")).toBeInTheDocument();
expect(await screen.findByText("Charlie")).toBeInTheDocument();
expect(await screen.findByText("Delta")).toBeInTheDocument();
});
it("displays models in collections by default", () => {
const modelCount = 22;
renderBrowseModels(modelCount);
expect(screen.queryByText("No models here yet")).not.toBeInTheDocument();
assertThatModelsExist(0, modelCount - 1);
});
it("can collapse collections to hide models within them", async () => {
renderBrowseModels(10);
// Three <a> tags representing models have aria-labelledby="collection-1 model-$id",
// and "collection-1" is the id of an element containing text 'Collection 1',
// so the following line finds those <a> tags.
const modelsInCollection1 = await screen.findAllByLabelText("Alpha");
expect(modelsInCollection1).toHaveLength(3);
const modelsInCollection2 = await screen.findAllByLabelText("Beta");
expect(modelsInCollection2).toHaveLength(3);
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();
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);
userEvent.click(await screen.findByLabelText("collapse Alpha"));
expect(screen.queryByText("Model 0")).not.toBeInTheDocument();
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(23);
const modelsInOurAnalytics = await screen.findAllByLabelText(
"Our analytics",
);
expect(modelsInOurAnalytics).toHaveLength(2);
renderBrowseModels(25);
await screen.findByText("Alpha");
await screen.findByText("Our analytics");
expect(await screen.findByText("Model 20")).toBeInTheDocument();
expect(await screen.findByText("Model 21")).toBeInTheDocument();
expect(await screen.findByText("Model 22")).toBeInTheDocument();
});
it("displays last edited information about models", async () => {
jest.useFakeTimers().setSystemTime(new Date("2024-12-15T12:00:00.000Z"));
renderBrowseModels(12);
const howLongAgo = /\d+(min|h|d|mo|yr)/;
const findWhenModelWasEdited = async (modelName: string) =>
(
await within(await screen.findByLabelText(modelName)).findByText(
howLongAgo,
)
)?.textContent?.match(howLongAgo)?.[0];
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);
});
expect(await findWhenModelWasEdited("Model 0")).toBe("1min");
expect(await findWhenModelWasEdited("Model 1")).toBe("1min");
expect(await findWhenModelWasEdited("Model 2")).toBe("1min");
expect(await findWhenModelWasEdited("Model 3")).toBe("10min");
expect(await findWhenModelWasEdited("Model 4")).toBe("1h");
expect(await findWhenModelWasEdited("Model 5")).toBe("14h");
expect(await findWhenModelWasEdited("Model 6")).toBe("1d");
expect(await findWhenModelWasEdited("Model 7")).toBe("5d");
expect(await findWhenModelWasEdited("Model 8")).toBe("1mo");
expect(await findWhenModelWasEdited("Model 9")).toBe("10mo");
expect(await findWhenModelWasEdited("Model 10")).toBe("1yr");
expect(await findWhenModelWasEdited("Model 11")).toBe("5yr");
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();
userEvent.click(await screen.findByText("Show all"));
assertThatModelsExist(300, 399);
});
jest.useRealTimers();
it("can show less than all models by clicking 'Show less'", async () => {
renderBrowseModels(9999);
expect(screen.queryByText("Model 399")).not.toBeInTheDocument();
userEvent.click(await screen.findByText("Show all"));
await screen.findByText("Model 301");
expect(screen.getByText("Model 399")).toBeInTheDocument();
userEvent.click(await screen.findByText("Show less"));
await screen.findByText("Model 301");
expect(screen.queryByText("Model 399")).not.toBeInTheDocument();
});
it("has a function that groups models by collection, sorting the collections alphabetically when English is the locale", () => {
const groupedModels = groupModels(mockModels, "en-US");
expect(groupedModels[0][0].collection.name).toEqual("Alpha");
expect(groupedModels[0]).toHaveLength(3);
expect(groupedModels[1][0].collection.name).toEqual("Ångström");
expect(groupedModels[1]).toHaveLength(3);
expect(groupedModels[2][0].collection.name).toEqual("Beta");
expect(groupedModels[2]).toHaveLength(3);
expect(groupedModels[3][0].collection.name).toEqual("Charlie");
expect(groupedModels[3]).toHaveLength(3);
expect(groupedModels[4][0].collection.name).toEqual("Delta");
expect(groupedModels[4]).toHaveLength(3);
expect(groupedModels[5][0].collection.name).toEqual("Our analytics");
expect(groupedModels[5]).toHaveLength(2);
expect(groupedModels[6][0].collection.name).toEqual("Özgür");
expect(groupedModels[6]).toHaveLength(3);
expect(groupedModels[7][0].collection.name).toEqual("Zulu");
expect(groupedModels[7]).toHaveLength(3);
it("persists show-all state when expanding and collapsing collections", async () => {
renderBrowseModels(9999);
userEvent.click(screen.getByText("Show all"));
expect(await screen.findByText("Model 301")).toBeInTheDocument();
expect(screen.getByText("Model 399")).toBeInTheDocument();
userEvent.click(screen.getByLabelText("collapse Grande"));
expect(screen.queryByText("Model 301")).not.toBeInTheDocument();
expect(screen.queryByText("Model 399")).not.toBeInTheDocument();
userEvent.click(screen.getByLabelText("expand Grande"));
expect(await screen.findByText("Model 301")).toBeInTheDocument();
expect(screen.getByText("Model 399")).toBeInTheDocument();
});
it("has a function that groups models by collection, sorting the collections alphabetically when Swedish is the locale", () => {
const groupedModels = groupModels(mockModels, "sv-SV");
expect(groupedModels[0][0].collection.name).toEqual("Alpha");
expect(groupedModels[0]).toHaveLength(3);
expect(groupedModels[1][0].collection.name).toEqual("Beta");
expect(groupedModels[1]).toHaveLength(3);
expect(groupedModels[2][0].collection.name).toEqual("Charlie");
expect(groupedModels[2]).toHaveLength(3);
expect(groupedModels[3][0].collection.name).toEqual("Delta");
expect(groupedModels[3]).toHaveLength(3);
expect(groupedModels[4][0].collection.name).toEqual("Our analytics");
expect(groupedModels[4]).toHaveLength(2);
expect(groupedModels[5][0].collection.name).toEqual("Zulu");
expect(groupedModels[5]).toHaveLength(3);
expect(groupedModels[6][0].collection.name).toEqual("Ångström");
expect(groupedModels[6]).toHaveLength(3);
expect(groupedModels[7][0].collection.name).toEqual("Özgür");
expect(groupedModels[7]).toHaveLength(3);
describe("local storage", () => {
it("persists the expanded state of collections in local storage", async () => {
renderBrowseModels(10);
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);
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();
userEvent.click(await screen.findByLabelText("collapse Alpha"));
expect(screen.queryByText("Model 0")).not.toBeInTheDocument();
// ignores invalid data and persists the new state
expect(localStorage.getItem(BROWSE_MODELS_LOCALSTORAGE_KEY)).toEqual(
JSON.stringify({ 99: { expanded: false, showAll: false } }),
);
});
});
});
function assertThatModelsExist(startId: number, endId: number) {
for (let i = startId; i <= endId; i++) {
expect(screen.getByText(`Model ${i}`)).toBeInTheDocument();
}
}
import { useMemo } from "react";
import { t, c, msgid } from "ttag";
import { color } from "metabase/lib/colors";
import * as Urls from "metabase/lib/urls";
import { Box, Icon, Title, Button, Flex } from "metabase/ui";
import type {
Card,
SearchResult,
CollectionEssentials,
} from "metabase-types/api";
import { getCollectionName, sortModels, getIcon } from "../utils";
import {
CollectionCollapse,
CollectionExpandCollapseContainer,
CollectionHeaderContainer,
CollectionHeaderToggleContainer,
CollectionSummary,
FixedSizeIcon,
ModelCard,
ModelCardLink,
MultilineEllipsified,
HoverUnderlineLink,
} from "./BrowseModels.styled";
const MAX_COLLAPSED_MODELS = 6;
export const ModelGroup = ({
models,
localeCode,
expanded,
showAll,
toggleExpanded,
toggleShowAll,
}: {
models: SearchResult[];
localeCode: string | undefined;
expanded: boolean;
showAll: boolean;
toggleExpanded: () => void;
toggleShowAll: () => void;
}) => {
const { sortedModels, aboveFoldModelsCount } = useMemo(() => {
const sortedModels = [...models].sort((a, b) =>
sortModels(a, b, localeCode),
);
const aboveFoldModelsCount =
models.length >= MAX_COLLAPSED_MODELS
? MAX_COLLAPSED_MODELS
: models.length;
return { sortedModels, aboveFoldModelsCount };
}, [models, localeCode]);
const visibleModels = useMemo(() => {
return showAll ? sortedModels : sortedModels.slice(0, MAX_COLLAPSED_MODELS);
}, [sortedModels, showAll]);
const collection = models[0].collection;
/** This id is used by aria-labelledby */
const collectionHtmlId = `collection-${collection.id}`;
return (
<>
<CollectionHeader
collection={collection}
onClick={toggleExpanded}
expanded={expanded}
modelsCount={models.length}
/>
<CollectionCollapse in={expanded} transitionDuration={0}>
{visibleModels.map(model => (
<ModelCell
model={model}
collectionHtmlId={collectionHtmlId}
key={`model-${model.id}`}
/>
))}
<ShowMoreFooter
hasMoreModels={models.length > MAX_COLLAPSED_MODELS}
shownModelsCount={aboveFoldModelsCount}
allModelsCount={models.length}
showAll={showAll}
onClick={toggleShowAll}
/>
</CollectionCollapse>
</>
);
};
const CollectionHeader = ({
collection,
onClick,
expanded,
modelsCount,
}: {
collection: CollectionEssentials;
onClick: () => void;
expanded: boolean;
modelsCount: number;
}) => {
const icon = getIcon({ ...collection, model: "collection" });
const collectionHtmlId = `collection-${collection.id}`;
return (
<CollectionHeaderContainer
id={collectionHtmlId}
role="heading"
onClick={onClick}
>
<CollectionHeaderToggleContainer>
<FixedSizeIcon
aria-label={
expanded
? t`collapse ${getCollectionName(collection)}`
: t`expand ${getCollectionName(collection)}`
}
name={expanded ? "chevrondown" : "chevronright"}
/>
</CollectionHeaderToggleContainer>
<Flex pt="1.5rem" pb="0.75rem" w="100%">
<Flex>
<FixedSizeIcon {...icon} />
<Title size="1rem" lh="1rem" ml=".25rem" mr="1rem" color="inherit">
{getCollectionName(collection)}
</Title>
</Flex>
<CollectionSummary>
<HoverUnderlineLink
to={Urls.collection(collection)}
onClick={e => e.stopPropagation() /* prevent collapse */}
>
{c("{0} is the number of models in a collection").ngettext(
msgid`${modelsCount} model`,
`${modelsCount} models`,
modelsCount,
)}
</HoverUnderlineLink>
</CollectionSummary>
</Flex>
</CollectionHeaderContainer>
);
};
const ShowMoreFooter = ({
hasMoreModels,
shownModelsCount,
allModelsCount,
onClick,
showAll,
}: {
hasMoreModels: boolean;
shownModelsCount: number;
allModelsCount: number;
showAll: boolean;
onClick: () => void;
}) => {
if (!hasMoreModels) {
return null;
}
return (
<CollectionExpandCollapseContainer>
{!showAll && `${shownModelsCount} of ${allModelsCount}`}
<Button variant="subtle" lh="inherit" p="0" onClick={onClick}>
{showAll
? c("For a button that collapses a list of models").t`Show less`
: c("For a button that expands a list of models").t`Show all`}
</Button>
</CollectionExpandCollapseContainer>
);
};
interface ModelCellProps {
model: SearchResult;
collectionHtmlId: string;
}
const ModelCell = ({ model, collectionHtmlId }: ModelCellProps) => {
const headingId = `heading-for-model-${model.id}`;
const icon = getIcon(model);
return (
<ModelCardLink
aria-labelledby={`${collectionHtmlId} ${headingId}`}
key={model.id}
to={Urls.model(model as unknown as Partial<Card>)}
>
<ModelCard>
<Box mb="auto">
<Icon {...icon} size={20} color={color("brand")} />
</Box>
<Title mb=".25rem" size="1rem">
<MultilineEllipsified tooltipMaxWidth="20rem" id={headingId}>
{model.name}
</MultilineEllipsified>
</Title>
<MultilineEllipsified tooltipMaxWidth="20rem">
{model.description}
</MultilineEllipsified>
</ModelCard>
</ModelCardLink>
);
};
export const RELOAD_INTERVAL = 2000;
export const BROWSE_MODELS_LOCALSTORAGE_KEY = "browseModelsViewPreferences";
......@@ -3,9 +3,20 @@ import { t } from "ttag";
import {
canonicalCollectionId,
coerceCollectionId,
isInstanceAnalyticsCollection,
isRootCollection,
isValidCollectionId,
} from "metabase/collections/utils";
import type { CollectionEssentials, SearchResult } from "metabase-types/api";
import { entityForObject } from "metabase/lib/schema";
import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins";
import type { IconName } from "metabase/ui";
import type {
CollectionEssentials,
SearchResult,
CollectionId,
} from "metabase-types/api";
import { BROWSE_MODELS_LOCALSTORAGE_KEY } from "./constants";
export const getCollectionName = (collection: CollectionEssentials) => {
if (isRootCollection(collection)) {
......@@ -29,21 +40,42 @@ export const groupModels = (
getCollectionIdForSorting(model.collection),
);
const groupsOfModels: SearchResult[][] = Object.values(groupedModels);
const sortFunction = (a: SearchResult[], b: SearchResult[]) => {
const sortGroupsByCollection = (a: SearchResult[], b: SearchResult[]) => {
const collection1 = a[0].collection;
const collection2 = b[0].collection;
const collection1AL = collection1.authority_level ?? "z";
const collection2AL = collection2.authority_level ?? "z";
if (collection1AL !== collection2AL) {
return collection1AL.localeCompare(collection2AL, locale);
// Sort instance analytics collection to the end
const collection1IsInstanceAnalyticsCollection =
isInstanceAnalyticsCollection(collection1);
const collection2IsInstanceAnalyticsCollection =
isInstanceAnalyticsCollection(collection2);
if (
collection1IsInstanceAnalyticsCollection &&
!collection2IsInstanceAnalyticsCollection
) {
return 1;
}
if (
collection2IsInstanceAnalyticsCollection &&
!collection1IsInstanceAnalyticsCollection
) {
return -1;
}
const sortValueFromPlugin =
PLUGIN_CONTENT_VERIFICATION.sortCollectionsByVerification(
collection1,
collection2,
);
if (sortValueFromPlugin) {
return sortValueFromPlugin;
}
const name1 = getCollectionName(collection1);
const name2 = getCollectionName(collection2);
return name1.localeCompare(name2, locale);
};
groupsOfModels.sort(sortFunction);
groupsOfModels.sort(sortGroupsByCollection);
return groupsOfModels;
};
......@@ -51,3 +83,114 @@ export type BrowseTabId = "models" | "databases";
export const isValidBrowseTab = (value: unknown): value is BrowseTabId =>
value === "models" || value === "databases";
export type AvailableModelFilters = Record<
string,
{
predicate: (value: SearchResult) => boolean;
activeByDefault: boolean;
}
>;
export type ModelFilterControlsProps = {
actualModelFilters: ActualModelFilters;
handleModelFilterChange: (filterName: string, active: boolean) => void;
};
export const sortModels = (
a: SearchResult,
b: SearchResult,
localeCode?: string,
) => {
const sortValueFromPlugin =
PLUGIN_CONTENT_VERIFICATION.sortModelsByVerification(a, b);
if (sortValueFromPlugin) {
return sortValueFromPlugin;
}
if (a.name && !b.name) {
return -1;
}
if (!a.name && !b.name) {
return 0;
}
if (!a.name && b.name) {
return 1;
}
if (a.name && !b.name) {
return -1;
}
if (!a.name && !b.name) {
return 0;
}
const nameA = a.name.toLowerCase();
const nameB = b.name.toLowerCase();
return nameA.localeCompare(nameB, localeCode);
};
export type ActualModelFilters = Record<string, boolean>;
export const filterModels = (
unfilteredModels: SearchResult[],
actualModelFilters: ActualModelFilters,
availableModelFilters: AvailableModelFilters,
) => {
return _.reduce(
actualModelFilters,
(acc, shouldFilterBeActive, filterName) =>
shouldFilterBeActive
? acc.filter(availableModelFilters[filterName].predicate)
: acc,
unfilteredModels,
);
};
type CollectionPrefs = Partial<Record<CollectionId, ModelVisibilityPrefs>>;
type ModelVisibilityPrefs = {
expanded: boolean;
showAll: boolean;
};
const isRecordWithCollectionIdKeys = (
prefs: unknown,
): prefs is Record<CollectionId, any> =>
!!prefs &&
typeof prefs === "object" &&
!Array.isArray(prefs) &&
Object.keys(prefs).every(isValidCollectionId);
const isValidModelVisibilityPrefs = (
value: unknown,
): value is ModelVisibilityPrefs =>
typeof value === "object" &&
value !== null &&
Object.keys(value).includes("expanded") &&
Object.keys(value).includes("showAll") &&
Object.values(value).every(_.isBoolean);
const isValidCollectionPrefs = (prefs: unknown): prefs is CollectionPrefs =>
isRecordWithCollectionIdKeys(prefs) &&
Object.values(prefs).every(isValidModelVisibilityPrefs);
export const getCollectionViewPreferences = (): CollectionPrefs => {
try {
const collectionPrefs = JSON.parse(
localStorage.getItem(BROWSE_MODELS_LOCALSTORAGE_KEY) ?? "{}",
);
if (isValidCollectionPrefs(collectionPrefs)) {
return collectionPrefs;
}
return {};
} catch (err) {
console.error(err);
return {};
}
};
export const getIcon = (item: unknown): { name: IconName; color: string } => {
const entity = entityForObject(item);
return entity?.objectSelectors?.getIcon?.(item) || { name: "folder" };
};
import type { SearchResult } from "metabase-types/api";
import {
createMockCollection,
createMockModelResult,
} from "metabase-types/api/mocks";
import { defaultRootCollection } from "metabase/admin/permissions/pages/CollectionPermissionsPage/tests/setup";
import type { ActualModelFilters, AvailableModelFilters } from "./utils";
import { filterModels, groupModels } from "./utils";
const collectionAlpha = createMockCollection({ id: 0, name: "Alpha" });
const collectionBeta = createMockCollection({ id: 1, name: "Beta" });
const collectionCharlie = createMockCollection({ id: 2, name: "Charlie" });
const collectionDelta = createMockCollection({ id: 3, name: "Delta" });
const collectionZulu = createMockCollection({ id: 4, name: "Zulu" });
const collectionAngstrom = createMockCollection({ id: 5, name: "Ångström" });
const collectionOzgur = createMockCollection({ id: 6, name: "Özgür" });
const mockModels: SearchResult[] = [
{
id: 0,
name: "Model 0",
collection: collectionAlpha,
last_editor_common_name: "Bobby",
last_edited_at: "2024-12-15T11:59:59.000Z",
},
{
id: 1,
name: "Model 1",
collection: collectionAlpha,
last_editor_common_name: "Bobby",
last_edited_at: "2024-12-15T11:59:30.000Z",
},
{
id: 2,
name: "Model 2",
collection: collectionAlpha,
last_editor_common_name: "Bobby",
last_edited_at: "2024-12-15T11:59:00.000Z",
},
{
id: 3,
name: "Model 3",
collection: collectionBeta,
last_editor_common_name: "Bobby",
last_edited_at: "2024-12-15T11:50:00.000Z",
},
{
id: 4,
name: "Model 4",
collection: collectionBeta,
last_editor_common_name: "Bobby",
last_edited_at: "2024-12-15T11:00:00.000Z",
},
{
id: 5,
name: "Model 5",
collection: collectionBeta,
last_editor_common_name: "Bobby",
last_edited_at: "2024-12-14T22:00:00.000Z",
},
{
id: 6,
name: "Model 6",
collection: collectionCharlie,
last_editor_common_name: "Bobby",
last_edited_at: "2024-12-14T12:00:00.000Z",
},
{
id: 7,
name: "Model 7",
collection: collectionCharlie,
last_editor_common_name: "Bobby",
last_edited_at: "2024-12-10T12:00:00.000Z",
},
{
id: 8,
name: "Model 8",
collection: collectionCharlie,
last_editor_common_name: "Bobby",
last_edited_at: "2024-11-15T12:00:00.000Z",
},
{
id: 9,
name: "Model 9",
collection: collectionDelta,
last_editor_common_name: "Bobby",
last_edited_at: "2024-02-15T12:00:00.000Z",
},
{
id: 10,
name: "Model 10",
collection: collectionDelta,
last_editor_common_name: "Bobby",
last_edited_at: "2023-12-15T12:00:00.000Z",
},
{
id: 11,
name: "Model 11",
collection: collectionDelta,
last_editor_common_name: "Bobby",
last_edited_at: "2020-01-01T00:00:00.000Z",
},
{
id: 12,
name: "Model 12",
collection: collectionZulu,
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
{
id: 13,
name: "Model 13",
collection: collectionZulu,
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
{
id: 14,
name: "Model 14",
collection: collectionZulu,
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
{
id: 15,
name: "Model 15",
collection: collectionAngstrom,
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
{
id: 16,
name: "Model 16",
collection: collectionAngstrom,
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
{
id: 17,
name: "Model 17",
collection: collectionAngstrom,
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
{
id: 18,
name: "Model 18",
collection: collectionOzgur,
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
{
id: 19,
name: "Model 19",
collection: collectionOzgur,
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
{
id: 20,
name: "Model 20",
collection: collectionOzgur,
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
{
id: 21,
name: "Model 20",
collection: defaultRootCollection,
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
{
id: 22,
name: "Model 21",
collection: defaultRootCollection,
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
].map(model => createMockModelResult(model));
describe("Browse utils", () => {
it("include a function that groups models by collection, sorting the collections alphabetically when English is the locale", () => {
const groupedModels = groupModels(mockModels, "en-US");
expect(groupedModels[0][0].collection.name).toEqual("Alpha");
expect(groupedModels[0]).toHaveLength(3);
expect(groupedModels[1][0].collection.name).toEqual("Ångström");
expect(groupedModels[1]).toHaveLength(3);
expect(groupedModels[2][0].collection.name).toEqual("Beta");
expect(groupedModels[2]).toHaveLength(3);
expect(groupedModels[3][0].collection.name).toEqual("Charlie");
expect(groupedModels[3]).toHaveLength(3);
expect(groupedModels[4][0].collection.name).toEqual("Delta");
expect(groupedModels[4]).toHaveLength(3);
expect(groupedModels[5][0].collection.name).toEqual("Our analytics");
expect(groupedModels[5]).toHaveLength(2);
expect(groupedModels[6][0].collection.name).toEqual("Özgür");
expect(groupedModels[6]).toHaveLength(3);
expect(groupedModels[7][0].collection.name).toEqual("Zulu");
expect(groupedModels[7]).toHaveLength(3);
});
it("include a function that groups models by collection, sorting the collections alphabetically when Swedish is the locale", () => {
const groupedModels = groupModels(mockModels, "sv-SV");
expect(groupedModels[0][0].collection.name).toEqual("Alpha");
expect(groupedModels[0]).toHaveLength(3);
expect(groupedModels[1][0].collection.name).toEqual("Beta");
expect(groupedModels[1]).toHaveLength(3);
expect(groupedModels[2][0].collection.name).toEqual("Charlie");
expect(groupedModels[2]).toHaveLength(3);
expect(groupedModels[3][0].collection.name).toEqual("Delta");
expect(groupedModels[3]).toHaveLength(3);
expect(groupedModels[4][0].collection.name).toEqual("Our analytics");
expect(groupedModels[4]).toHaveLength(2);
expect(groupedModels[5][0].collection.name).toEqual("Zulu");
expect(groupedModels[5]).toHaveLength(3);
expect(groupedModels[6][0].collection.name).toEqual("Ångström");
expect(groupedModels[6]).toHaveLength(3);
expect(groupedModels[7][0].collection.name).toEqual("Özgür");
expect(groupedModels[7]).toHaveLength(3);
});
const diverseModels = mockModels.map((model, index) => ({
...model,
name: index % 2 === 0 ? `red ${index}` : `blue ${index}`,
moderated_status: index % 3 === 0 ? `good ${index}` : `bad ${index}`,
}));
const availableModelFilters: AvailableModelFilters = {
onlyRed: {
predicate: (model: SearchResult) => model.name.startsWith("red"),
activeByDefault: false,
},
onlyGood: {
predicate: (model: SearchResult) =>
Boolean(model.moderated_status?.startsWith("good")),
activeByDefault: false,
},
onlyBig: {
predicate: (model: SearchResult) =>
Boolean(model.description?.startsWith("big")),
activeByDefault: true,
},
};
it("include a function that filters models, based on the object provided", () => {
const onlyRedAndGood: ActualModelFilters = {
onlyRed: true,
onlyGood: true,
onlyBig: false,
};
const onlyRedAndGoodModels = filterModels(
diverseModels,
onlyRedAndGood,
availableModelFilters,
);
const everySixthModel = diverseModels.reduce<SearchResult[]>(
(acc, model, index) => {
return index % 6 === 0 ? [...acc, model] : acc;
},
[],
);
// Since every other model is red and every third model is good,
// we expect every sixth model to be both red and good
expect(onlyRedAndGoodModels).toEqual(everySixthModel);
});
it("filterModels does not filter out models if no filters are active", () => {
const noActiveFilters: ActualModelFilters = {
onlyRed: false,
onlyGood: false,
onlyBig: false,
};
const filteredModels = filterModels(
diverseModels,
noActiveFilters,
availableModelFilters,
);
expect(filteredModels).toEqual(diverseModels);
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment