Skip to content
Snippets Groups Projects
Unverified Commit 0a986a58 authored by Alexander Polyankin's avatar Alexander Polyankin Committed by GitHub
Browse files

Rework collection header actions (#23484)

parent db3a2882
No related branches found
No related tags found
No related merge requests found
Showing
with 352 additions and 404 deletions
......@@ -7,6 +7,7 @@ export type CollectionAuthorityLevel = "official" | null;
export interface Collection {
id: CollectionId;
name: string;
description: string | null;
can_write: boolean;
archived: boolean;
children?: Collection[];
......
......@@ -5,6 +5,7 @@ export const createMockCollection = (
): Collection => ({
id: 1,
name: "Collection",
description: null,
can_write: false,
archived: false,
...opts,
......
......@@ -3,6 +3,7 @@ import { t } from "ttag";
import Button from "metabase/core/components/Button";
import NewItemMenu from "metabase/containers/NewItemMenu";
import { ANALYTICS_CONTEXT } from "metabase/collections/constants";
import { CollectionId } from "metabase-types/api";
import {
EmptyStateDescription,
EmptyStateIconBackground,
......@@ -11,7 +12,13 @@ import {
EmptyStateTitle,
} from "./CollectionEmptyState.styled";
const CollectionEmptyState = (): JSX.Element => {
export interface CollectionEmptyStateProps {
collectionId?: CollectionId;
}
const CollectionEmptyState = ({
collectionId,
}: CollectionEmptyStateProps): JSX.Element => {
return (
<EmptyStateRoot data-testid="collection-empty-state">
<CollectionEmptyIcon />
......@@ -19,6 +26,7 @@ const CollectionEmptyState = (): JSX.Element => {
<EmptyStateDescription>{t`Use collections to organize and group dashboards and questions for your team or yourself`}</EmptyStateDescription>
<NewItemMenu
trigger={<Button icon="add">{t`Create a new…`}</Button>}
collectionId={collectionId}
analyticsContext={ANALYTICS_CONTEXT}
/>
</EmptyStateRoot>
......
import React, { useCallback } from "react";
import BookmarkToggle from "metabase/core/components/BookmarkToggle";
import { isRootCollection } from "metabase/collections/utils";
import { Collection } from "metabase-types/api";
export interface CollectionBookmarkProps {
collection: Collection;
isBookmarked: boolean;
onCreateBookmark: (collection: Collection) => void;
onDeleteBookmark: (collection: Collection) => void;
}
const CollectionBookmark = ({
collection,
isBookmarked,
onCreateBookmark,
onDeleteBookmark,
}: CollectionBookmarkProps): JSX.Element | null => {
const isRoot = isRootCollection(collection);
const handleCreateBookmark = useCallback(() => {
onCreateBookmark(collection);
}, [collection, onCreateBookmark]);
const handleDeleteBookmark = useCallback(() => {
onDeleteBookmark(collection);
}, [collection, onDeleteBookmark]);
if (isRoot) {
return null;
}
return (
<BookmarkToggle
isBookmarked={isBookmarked}
onCreateBookmark={handleCreateBookmark}
onDeleteBookmark={handleDeleteBookmark}
/>
);
};
export default CollectionBookmark;
import styled from "@emotion/styled";
export const CaptionContainer = styled.div`
display: flex;
align-items: center;
gap: 0.5rem;
`;
export const CaptionTitle = styled.h1`
font-weight: 900;
word-break: break-word;
word-wrap: anywhere;
overflow-wrap: anywhere;
`;
export const CaptionDescription = styled.div`
font-size: 1rem;
line-height: 1.5rem;
padding-top: 1.15rem;
max-width: 25rem;
`;
import React from "react";
import { PLUGIN_COLLECTION_COMPONENTS } from "metabase/plugins";
import { Collection } from "metabase-types/api";
import {
CaptionContainer,
CaptionTitle,
CaptionDescription,
} from "./CollectionCaption.styled";
export interface CollectionCaptionProps {
collection: Collection;
}
const CollectionCaption = ({
collection,
}: CollectionCaptionProps): JSX.Element => {
return (
<div>
<CaptionContainer>
<PLUGIN_COLLECTION_COMPONENTS.CollectionAuthorityLevelIcon
collection={collection}
size={24}
/>
<CaptionTitle data-testid="collection-name-heading">
{collection.name}
</CaptionTitle>
</CaptionContainer>
{collection.description && (
<CaptionDescription>{collection.description}</CaptionDescription>
)}
</div>
);
};
export default CollectionCaption;
/* eslint-disable react/prop-types */
import React, { useState } from "react";
import { t } from "ttag";
import * as Urls from "metabase/lib/urls";
import { isPersonalCollection } from "metabase/collections/utils";
import Icon, { IconWrapper } from "metabase/components/Icon";
import Link from "metabase/core/components/Link";
import PageHeading from "metabase/components/type/PageHeading";
import Tooltip from "metabase/components/Tooltip";
import CollectionEditMenu from "metabase/collections/components/CollectionEditMenu";
import NewItemMenu from "metabase/containers/NewItemMenu";
import { color } from "metabase/lib/colors";
import { PLUGIN_COLLECTION_COMPONENTS } from "metabase/plugins";
import { ANALYTICS_CONTEXT } from "metabase/collections/constants";
import {
BookmarkIcon,
BookmarkIconWrapper,
Container,
DescriptionHeading,
MenuContainer,
TitleContent,
} from "./CollectionHeader.styled";
function Title({ collection }) {
return (
<div>
<TitleContent>
<PLUGIN_COLLECTION_COMPONENTS.CollectionAuthorityLevelIcon
collection={collection}
mr={1}
size={24}
/>
<PageHeading
data-testid="collection-name-heading"
className="text-wrap"
>
{collection.name}
</PageHeading>
</TitleContent>
{collection.description && (
<DescriptionHeading>{collection.description}</DescriptionHeading>
)}
</div>
);
}
function PermissionsLink({
collection,
isAdmin,
isPersonal,
isPersonalCollectionChild,
}) {
const tooltip = t`Edit the permissions for this collection`;
const link = `${Urls.collection(collection)}/permissions`;
const canChangePermissions =
isAdmin && !isPersonal && !isPersonalCollectionChild;
return canChangePermissions ? (
<Tooltip tooltip={tooltip}>
<Link to={link}>
<IconWrapper>
<Icon name="lock" />
</IconWrapper>
</Link>
</Tooltip>
) : null;
}
function TimelinesLink({ collection }) {
const title = t`Events`;
const link = Urls.timelinesInCollection(collection);
return (
<Tooltip tooltip={title}>
<Link to={link}>
<IconWrapper>
<Icon name="calendar" size={20} />
</IconWrapper>
</Link>
</Tooltip>
);
}
function EditMenu({
collection,
hasWritePermission,
isAdmin,
isPersonal,
isRoot,
}) {
const tooltip = t`Edit collection`;
const canEditCollection = hasWritePermission && !isPersonal;
return canEditCollection ? (
<CollectionEditMenu
tooltip={tooltip}
collection={collection}
isAdmin={isAdmin}
isRoot={isRoot}
/>
) : null;
}
function Bookmark({ isBookmarked, onClickBookmark }) {
const title = isBookmarked ? t`Remove from bookmarks` : t`Bookmark`;
const iconColor = isBookmarked ? color("brand") : "";
const [animation, setAnimation] = useState(null);
const handleClickBookmark = () => {
onClickBookmark();
setAnimation(isBookmarked ? "shrink" : "expand");
};
return (
<Tooltip tooltip={title}>
<BookmarkIconWrapper
isBookmarked={isBookmarked}
onClick={handleClickBookmark}
>
<BookmarkIcon
name="bookmark"
color={iconColor}
size={20}
animation={animation}
/>
</BookmarkIconWrapper>
</Tooltip>
);
}
function Menu(props) {
const { collectionId, hasWritePermission } = props;
const shouldBeBookmarkable = collectionId !== "root";
return (
<MenuContainer data-testid="collection-menu">
{hasWritePermission && (
<NewItemMenu
{...props}
collectionId={collectionId}
triggerIcon="add"
triggerTooltip={t`New…`}
analyticsContext={ANALYTICS_CONTEXT}
/>
)}
<EditMenu {...props} />
<PermissionsLink {...props} />
<TimelinesLink {...props} />
{shouldBeBookmarkable && <Bookmark {...props} />}
</MenuContainer>
);
}
export default function CollectionHeader(props) {
const { collection } = props;
const isPersonal = isPersonalCollection(collection);
const hasWritePermission = collection && collection.can_write;
return (
<Container>
<Title {...props} />
<Menu
{...props}
isPersonal={isPersonal}
hasWritePermission={hasWritePermission}
/>
</Container>
);
}
import styled from "@emotion/styled";
import { css } from "@emotion/react";
import { color } from "metabase/lib/colors";
import { breakpointMinSmall, space } from "metabase/styled-components/theme";
import {
shrinkOrExpandOnClick,
shrinkOrExpandDuration,
} from "metabase/styled-components/theme/button.ts";
import Icon, { IconWrapper } from "metabase/components/Icon";
export const BookmarkIconWrapper = styled(IconWrapper)`
${props =>
!props.isBookmarked &&
css`
&:hover {
${BookmarkIcon} {
color: ${color("text-dark")};
}
}
`}
`;
export const BookmarkIcon = styled(Icon)`
${shrinkOrExpandOnClick}
${props =>
props.animation === "expand" &&
css`
animation: expand linear ${shrinkOrExpandDuration};
`}
${props =>
props.animation === "shrink" &&
css`
animation: shrink linear ${shrinkOrExpandDuration};
`}
`;
export const Container = styled.div`
display: flex;
justify-content: space-between;
flex-direction: column;
margin-bottom: ${space(3)};
padding-top: ${space(0)};
${breakpointMinSmall} {
align-items: center;
flex-direction: row;
padding-top: ${space(1)};
}
`;
export const MenuContainer = styled.div`
display: flex;
margin-top: ${space(1)};
align-self: start;
`;
export const DescriptionTooltipIcon = styled(Icon)`
color: ${color("bg-dark")};
margin-left: ${space(1)};
margin-right: ${space(1)};
margin-top: ${space(0)};
&:hover {
color: ${color("brand")};
}
`;
DescriptionTooltipIcon.defaultProps = {
name: "info",
};
export const DescriptionHeading = styled.div`
font-size: 1rem;
line-height: 1.5rem;
padding-top: 1.15rem;
max-width: 400px;
`;
export const TitleContent = styled.div`
display: flex;
align-items: center;
`;
import styled from "@emotion/styled";
import { breakpointMinSmall } from "metabase/styled-components/theme";
export const HeaderRoot = styled.div`
display: flex;
justify-content: space-between;
flex-direction: column;
margin-bottom: 2rem;
padding-top: 0.25rem;
${breakpointMinSmall} {
align-items: center;
flex-direction: row;
padding-top: 0.5rem;
}
`;
export const HeaderActions = styled.div`
display: flex;
margin-top: 0.5rem;
align-self: start;
`;
import React from "react";
import { Collection } from "metabase-types/api";
import CollectionCaption from "./CollectionCaption";
import CollectionBookmark from "./CollectionBookmark";
import CollectionMenu from "./CollectionMenu";
import CollectionTimeline from "./CollectionTimeline";
import { HeaderActions, HeaderRoot } from "./CollectionHeader.styled";
export interface CollectionHeaderProps {
collection: Collection;
isAdmin: boolean;
isBookmarked: boolean;
isPersonalCollectionChild: boolean;
onCreateBookmark: (collection: Collection) => void;
onDeleteBookmark: (collection: Collection) => void;
}
const CollectionHeader = ({
collection,
isAdmin,
isBookmarked,
isPersonalCollectionChild,
onCreateBookmark,
onDeleteBookmark,
}: CollectionHeaderProps): JSX.Element => {
return (
<HeaderRoot>
<CollectionCaption collection={collection} />
<HeaderActions data-testid="collection-menu">
<CollectionTimeline collection={collection} />
<CollectionBookmark
collection={collection}
isBookmarked={isBookmarked}
onCreateBookmark={onCreateBookmark}
onDeleteBookmark={onDeleteBookmark}
/>
<CollectionMenu
collection={collection}
isAdmin={isAdmin}
isPersonalCollectionChild={isPersonalCollectionChild}
/>
</HeaderActions>
</HeaderRoot>
);
};
export default CollectionHeader;
import React from "react";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "__support__/ui";
import Header from "./CollectionHeader";
const collection = {
name: "Name",
};
it("should display collection name", () => {
renderWithProviders(<Header collection={collection} />);
screen.getByText(collection.name);
});
describe("description tooltip", () => {
describe("should not be displayed", () => {
it("if description is not received", () => {
const { container } = renderWithProviders(
<Header collection={collection} />,
);
expect(container.textContent).toEqual("Name");
});
});
describe("should be displayed", () => {
it("if description is received", () => {
const description = "description";
renderWithProviders(
<Header collection={{ ...collection, description }} />,
);
screen.getByText(description);
});
});
});
describe("permissions link", () => {
const ariaLabel = "lock icon";
describe("should not be displayed", () => {
it("if user is not admin", () => {
renderWithProviders(<Header collection={collection} />);
expect(screen.queryByLabelText(ariaLabel)).not.toBeInTheDocument();
});
it("for personal collections", () => {
renderWithProviders(
<Header
isAdmin={true}
collection={{ ...collection, personal_owner_id: 1 }}
/>,
);
expect(screen.queryByLabelText(ariaLabel)).not.toBeInTheDocument();
});
it("if a collection is a personal collection child", () => {
renderWithProviders(
<Header
isAdmin={true}
collection={collection}
isPersonalCollectionChild={true}
/>,
);
expect(screen.queryByLabelText(ariaLabel)).not.toBeInTheDocument();
});
});
describe("should be displayed", () => {
it("if user is admin", () => {
renderWithProviders(<Header collection={collection} isAdmin={true} />);
screen.getByLabelText(ariaLabel);
});
});
});
describe("link to add new collection items", () => {
const ariaLabel = "add icon";
describe("should not be displayed", () => {
it("when no detail is passed in the collection to determine if user can change collection", () => {
renderWithProviders(<Header collection={collection} />);
expect(screen.queryByLabelText(ariaLabel)).not.toBeInTheDocument();
});
it("if user is not allowed to change collection", () => {
renderWithProviders(
<Header collection={{ ...collection, can_write: false }} />,
);
expect(screen.queryByLabelText(ariaLabel)).not.toBeInTheDocument();
});
});
describe("should be displayed", () => {
it("if user is allowed to change collection", () => {
renderWithProviders(
<Header collection={{ ...collection, can_write: true }} />,
);
screen.getByLabelText(ariaLabel);
});
});
});
describe("link to add new collection items", () => {
const ariaLabel = "add icon";
describe("should not be displayed", () => {
it("if user is not allowed to change collection", () => {
renderWithProviders(<Header collection={collection} />);
expect(screen.queryByLabelText(ariaLabel)).not.toBeInTheDocument();
});
});
describe("should be displayed", () => {
it("if user is allowed to change collection", () => {
renderWithProviders(
<Header collection={{ ...collection, can_write: true }} />,
);
screen.getByLabelText(ariaLabel);
});
});
});
import React from "react";
import { t } from "ttag";
import * as Urls from "metabase/lib/urls";
import EntityMenu from "metabase/components/EntityMenu";
import { ANALYTICS_CONTEXT } from "metabase/collections/constants";
import {
isPersonalCollection,
isRootCollection,
} from "metabase/collections/utils";
import { Collection } from "metabase-types/api";
export interface CollectionMenuProps {
collection: Collection;
isAdmin: boolean;
isPersonalCollectionChild: boolean;
}
const CollectionMenu = ({
collection,
isAdmin,
isPersonalCollectionChild,
}: CollectionMenuProps): JSX.Element | null => {
const items = [];
const url = Urls.collection(collection);
const isRoot = isRootCollection(collection);
const isPersonal = isPersonalCollection(collection);
const canWrite = collection.can_write;
if (!isRoot && !isPersonal && canWrite) {
items.push(
{
title: t`Edit this collection`,
icon: "edit_document",
link: `${url}/edit`,
event: `${ANALYTICS_CONTEXT};Edit Menu;Edit Collection Click`,
},
{
title: t`Move`,
icon: "move",
link: `${url}/move`,
event: `${ANALYTICS_CONTEXT};Edit Menu;Move Collection`,
},
{
title: t`Archive`,
icon: "view_archive",
link: `${url}/archive`,
event: `${ANALYTICS_CONTEXT};Edit Menu;Archive Collection`,
},
);
}
if (isAdmin && !isPersonal && !isPersonalCollectionChild) {
items.push({
title: t`Edit permissions`,
icon: "lock",
link: `${url}/permissions`,
event: `${ANALYTICS_CONTEXT};Edit Menu;Edit Permissions`,
});
}
if (items.length > 0) {
return <EntityMenu items={items} triggerIcon="ellipsis" />;
} else {
return null;
}
};
export default CollectionMenu;
import React from "react";
import { t } from "ttag";
import * as Urls from "metabase/lib/urls";
import Icon, { IconWrapper } from "metabase/components/Icon";
import Link from "metabase/core/components/Link/Link";
import Tooltip from "metabase/components/Tooltip";
import { Collection } from "metabase-types/api";
interface CollectionTimelineProps {
collection: Collection;
}
const CollectionTimeline = ({
collection,
}: CollectionTimelineProps): JSX.Element => {
const url = Urls.timelinesInCollection(collection);
return (
<Tooltip tooltip={t`Events`}>
<Link to={url}>
<IconWrapper>
<Icon name="calendar" size={20} />
</IconWrapper>
</Link>
</Tooltip>
);
};
export default CollectionTimeline;
export { default } from "./CollectionHeader";
import React, { useCallback } from "react";
import CollectionMoveModal from "metabase/containers/CollectionMoveModal";
import { Collection } from "metabase-types/api";
export interface MoveCollectionModalProps {
collection: Collection;
onMove: (source: Collection, destination: Collection) => void;
onClose: () => void;
}
const MoveCollectionModal = ({
collection,
onMove,
onClose,
}: MoveCollectionModalProps): JSX.Element => {
const handleMove = useCallback(
async (destination: Collection) => {
await onMove(collection, destination);
onClose();
},
[collection, onMove, onClose],
);
return (
<CollectionMoveModal
title={`Move ${collection.name}?`}
initialCollectionId={collection.id}
onMove={handleMove}
onClose={onClose}
/>
);
};
export default MoveCollectionModal;
export { default } from "./MoveCollectionModal";
......@@ -13,6 +13,7 @@ const collection = {
can_write: true,
id: 1,
name: "Collection Foo",
description: null,
archived: false,
};
......
......@@ -63,7 +63,6 @@ function CollectionContent({
createBookmark,
deleteBookmark,
isAdmin,
isRoot,
metadata,
isNavbarOpen,
openNavbar,
......@@ -156,9 +155,12 @@ function CollectionContent({
setSelectedAction("copy");
};
const handleClickBookmark = () => {
const toggleBookmark = isBookmarked ? deleteBookmark : createBookmark;
toggleBookmark(collectionId, "collection");
const handleCreateBookmark = () => {
createBookmark(collectionId, "collection");
};
const handleDeleteBookmark = () => {
deleteBookmark(collectionId, "collection");
};
const unpinnedQuery = {
......@@ -191,16 +193,15 @@ function CollectionContent({
<CollectionRoot>
<CollectionMain>
<Header
onClickBookmark={handleClickBookmark}
isBookmarked={isBookmarked}
isRoot={isRoot}
isAdmin={isAdmin}
collectionId={collectionId}
collection={collection}
isAdmin={isAdmin}
isBookmarked={isBookmarked}
isPersonalCollectionChild={isPersonalCollectionChild(
collection,
collectionList,
)}
onCreateBookmark={handleCreateBookmark}
onDeleteBookmark={handleDeleteBookmark}
/>
<PinnedItemOverview
bookmarks={bookmarks}
......@@ -242,7 +243,7 @@ function CollectionContent({
if (isEmpty && !loadingUnpinnedItems) {
return (
<CollectionEmptyContent>
<CollectionEmptyState />
<CollectionEmptyState collectionId={collectionId} />
</CollectionEmptyContent>
);
}
......
import { connect } from "react-redux";
import _ from "underscore";
import * as Urls from "metabase/lib/urls";
import Collections from "metabase/entities/collections";
import { State } from "metabase-types/store";
import MoveCollectionModal from "../../components/MoveCollectionModal";
interface MoveCollectionModalProps {
params: MoveCollectionModalParams;
}
interface MoveCollectionModalParams {
slug: string;
}
const collectionProps = {
id: (state: State, props: MoveCollectionModalProps) =>
Urls.extractCollectionId(props.params.slug),
};
const mapDispatchToProps = {
onMove: Collections.actions.setCollection,
};
export default _.compose(
Collections.load(collectionProps),
connect(null, mapDispatchToProps),
)(MoveCollectionModal);
export { default } from "./MoveCollectionModal";
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