diff --git a/enterprise/frontend/src/metabase-enterprise/collections/index.js b/enterprise/frontend/src/metabase-enterprise/collections/index.js index 432fd754583241b00f1347a621e9dc4e9813536f..8bdc6996d80d6a5e63a4ad3cb7b0e86ccc2e9a10 100644 --- a/enterprise/frontend/src/metabase-enterprise/collections/index.js +++ b/enterprise/frontend/src/metabase-enterprise/collections/index.js @@ -12,7 +12,15 @@ import { OFFICIAL_COLLECTION, } from "./constants"; +function isRegularCollection({ authority_level }) { + // Root, personal collections don't have `authority_level` + return !authority_level || authority_level === REGULAR_COLLECTION.type; +} + +PLUGIN_COLLECTIONS.isRegularCollection = isRegularCollection; + PLUGIN_COLLECTIONS.REGULAR_COLLECTION = REGULAR_COLLECTION; + PLUGIN_COLLECTIONS.AUTHORITY_LEVEL = AUTHORITY_LEVELS; PLUGIN_COLLECTIONS.formFields = [ diff --git a/frontend/src/metabase/collections/components/CollectionIcon.jsx b/frontend/src/metabase/collections/components/CollectionIcon.jsx new file mode 100644 index 0000000000000000000000000000000000000000..4de52d3b276e948c257eb8c04c35f8728634a188 --- /dev/null +++ b/frontend/src/metabase/collections/components/CollectionIcon.jsx @@ -0,0 +1,18 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import Icon from "metabase/components/Icon"; +import { getCollectionIcon } from "metabase/entities/collections"; + +const propTypes = { + collection: PropTypes.shape({ + authority_level: PropTypes.oneOf(["official"]), + }), +}; + +export function CollectionIcon({ collection, ...props }) { + const { name, color } = getCollectionIcon(collection); + return <Icon name={name} color={color} {...props} />; +} + +CollectionIcon.propTypes = propTypes; diff --git a/frontend/src/metabase/collections/components/CollectionLink.jsx b/frontend/src/metabase/collections/components/CollectionLink.jsx index 3ad0fe95e44e0f15dec973dbb17d01b120614164..63ac5bdef00b49919ab83de8d61d685b4e97b952 100644 --- a/frontend/src/metabase/collections/components/CollectionLink.jsx +++ b/frontend/src/metabase/collections/components/CollectionLink.jsx @@ -1,9 +1,14 @@ -import styled from "styled-components"; +import styled, { css } from "styled-components"; import Link from "metabase/components/Link"; import { color } from "metabase/lib/colors"; import { SIDEBAR_SPACER } from "../constants"; +const dimmedIconCss = css` + fill: ${color("white")}; + opacity: 0.8; +`; + const CollectionLink = styled(Link)` margin-left: ${props => // use negative margin to reset our potentially nested item back by the depth @@ -34,9 +39,14 @@ const CollectionLink = styled(Link)` ? color("brand") : color("bg-medium")}; } + .Icon { - fill: ${props => props.selected && "white"}; - opacity: ${props => props.selected && "0.8"}; + ${props => props.selected && props.dimmedIcon && dimmedIconCss} + } + + .Icon-chevronright, + .Icon-chevrondown { + ${props => props.selected && dimmedIconCss} } `; diff --git a/frontend/src/metabase/collections/components/CollectionList.styled.js b/frontend/src/metabase/collections/components/CollectionList.styled.js new file mode 100644 index 0000000000000000000000000000000000000000..1342d257dc0e764cbd4a70c0df1a7744e8f099c9 --- /dev/null +++ b/frontend/src/metabase/collections/components/CollectionList.styled.js @@ -0,0 +1,24 @@ +import styled from "styled-components"; +import { PLUGIN_COLLECTIONS } from "metabase/plugins"; +import { + ROOT_COLLECTION, + PERSONAL_COLLECTIONS, +} from "metabase/entities/collections"; +import { CollectionIcon } from "metabase/collections/components/CollectionIcon"; + +const { isRegularCollection } = PLUGIN_COLLECTIONS; + +function getOpacity(collection) { + if ( + collection.id === ROOT_COLLECTION.id || + collection.id === PERSONAL_COLLECTIONS.id + ) { + return 1; + } + return isRegularCollection(collection) ? 0.4 : 1; +} + +export const CollectionListIcon = styled(CollectionIcon)` + margin-right: 6px; + opacity: ${props => getOpacity(props.collection)}; +`; diff --git a/frontend/src/metabase/collections/components/CollectionsList.jsx b/frontend/src/metabase/collections/components/CollectionsList.jsx index 47e53eff75618a8b60cbff9766ee413a7002e925..9aee362745899190be0895abdda63604f8cd0048 100644 --- a/frontend/src/metabase/collections/components/CollectionsList.jsx +++ b/frontend/src/metabase/collections/components/CollectionsList.jsx @@ -10,10 +10,15 @@ import CollectionLink from "metabase/collections/components/CollectionLink"; import CollectionDropTarget from "metabase/containers/dnd/CollectionDropTarget"; import { SIDEBAR_SPACER } from "metabase/collections/constants"; +import { PLUGIN_COLLECTIONS } from "metabase/plugins"; + +import { CollectionListIcon } from "./CollectionList.styled"; + +const { isRegularCollection } = PLUGIN_COLLECTIONS; + class CollectionsList extends React.Component { render() { const { - initialIcon, currentCollection, filter = () => true, openCollections, @@ -39,6 +44,7 @@ class CollectionsList extends React.Component { depth={this.props.depth} // when we click on a link, if there are children, expand to show sub collections onClick={() => c.children && action(c.id)} + dimmedIcon={isRegularCollection(c)} hovered={hovered} highlighted={highlighted} role="treeitem" @@ -69,11 +75,7 @@ class CollectionsList extends React.Component { /> </Flex> )} - <Icon - name={initialIcon} - mr={"6px"} - style={{ opacity: 0.4 }} - /> + <CollectionListIcon collection={c} /> {c.name} </Flex> </CollectionLink> @@ -102,8 +104,9 @@ class CollectionsList extends React.Component { } CollectionsList.defaultProps = { - initialIcon: "folder", depth: 1, }; +CollectionsList.Icon = CollectionListIcon; + export default CollectionsList; diff --git a/frontend/src/metabase/collections/containers/CollectionSidebar/CollectionSidebarFooter/CollectionSidebarFooter.jsx b/frontend/src/metabase/collections/containers/CollectionSidebar/CollectionSidebarFooter/CollectionSidebarFooter.jsx index 3d718010e32abd992a26151bc84020b249eabaf3..e95f75b796f0d2d16cf8b0d09d528cbc7219abfc 100644 --- a/frontend/src/metabase/collections/containers/CollectionSidebar/CollectionSidebarFooter/CollectionSidebarFooter.jsx +++ b/frontend/src/metabase/collections/containers/CollectionSidebar/CollectionSidebarFooter/CollectionSidebarFooter.jsx @@ -3,6 +3,8 @@ import PropTypes from "prop-types"; import { t } from "ttag"; import * as Urls from "metabase/lib/urls"; +import { PERSONAL_COLLECTIONS } from "metabase/entities/collections"; +import CollectionsList from "metabase/collections/components/CollectionsList"; import { Container, Icon, Link } from "./CollectionSidebarFooter.styled"; const propTypes = { @@ -14,7 +16,7 @@ export default function CollectionSidebarFooter({ isAdmin }) { <Container> {isAdmin && ( <Link to={Urls.collection({ id: "users" })}> - <Icon name="group" /> + <CollectionsList.Icon collection={PERSONAL_COLLECTIONS} /> {t`Other users' personal collections`} </Link> )} diff --git a/frontend/src/metabase/collections/containers/CollectionSidebar/Collections/Collections.jsx b/frontend/src/metabase/collections/containers/CollectionSidebar/Collections/Collections.jsx index 328aece1f88f5e9aa1e4f3f9bdfbf9dd82bb4877..b10b1624470164f846d93b0a795e42737a6a182b 100644 --- a/frontend/src/metabase/collections/containers/CollectionSidebar/Collections/Collections.jsx +++ b/frontend/src/metabase/collections/containers/CollectionSidebar/Collections/Collections.jsx @@ -53,7 +53,6 @@ export default function Collections({ onClose={onClose} onOpen={onOpen} collections={currentUserPersonalCollections} - initialIcon="person" filter={filterPersonalCollections} currentCollection={collectionId} /> diff --git a/frontend/src/metabase/collections/containers/CollectionSidebar/RootCollectionLink/RootCollectionLink.jsx b/frontend/src/metabase/collections/containers/CollectionSidebar/RootCollectionLink/RootCollectionLink.jsx index ec1804eae530c15eccb789de520b952ec0e64afc..f248510e7ffc3c59bc8872f4296506f475cec6ff 100644 --- a/frontend/src/metabase/collections/containers/CollectionSidebar/RootCollectionLink/RootCollectionLink.jsx +++ b/frontend/src/metabase/collections/containers/CollectionSidebar/RootCollectionLink/RootCollectionLink.jsx @@ -5,6 +5,7 @@ import { t } from "ttag"; import * as Urls from "metabase/lib/urls"; import CollectionDropTarget from "metabase/containers/dnd/CollectionDropTarget"; +import CollectionsList from "metabase/collections/components/CollectionsList"; import CollectionLink from "metabase/collections/components/CollectionLink"; import { Container } from "./RootCollectionLink.styled"; @@ -25,6 +26,7 @@ export default function CollectionSidebarHeader({ isRoot, root }) { highlighted={highlighted} hovered={hovered} > + <CollectionsList.Icon collection={root} /> {t`Our analytics`} </CollectionLink> )} diff --git a/frontend/src/metabase/components/Badge.jsx b/frontend/src/metabase/components/Badge.jsx index 67d5ff3f1ddf7e41b387ed89933f67ad4f177821..12a93be27decbb9f7cedda65e7875c441aec487a 100644 --- a/frontend/src/metabase/components/Badge.jsx +++ b/frontend/src/metabase/components/Badge.jsx @@ -6,7 +6,14 @@ import Icon from "metabase/components/Icon"; import cx from "classnames"; -export default function Badge({ icon, name, className, children, ...props }) { +export default function Badge({ + icon, + iconColor, + name, + className, + children, + ...props +}) { return ( <MaybeLink className={cx( @@ -18,7 +25,14 @@ export default function Badge({ icon, name, className, children, ...props }) { )} {...props} > - {icon && <Icon name={icon} mr={children ? "5px" : null} size={11} />} + {icon && ( + <Icon + name={icon} + mr={children ? "5px" : null} + color={iconColor} + size={12} + /> + )} {children && <span className="text-wrap">{children}</span>} </MaybeLink> ); diff --git a/frontend/src/metabase/components/select-list/SelectListItem.jsx b/frontend/src/metabase/components/select-list/SelectListItem.jsx index 182f7aa00966048a1de9b2242357818f92438229..98bd61cc00dcbe86bc52186fa4d27d8e710c8b63 100644 --- a/frontend/src/metabase/components/select-list/SelectListItem.jsx +++ b/frontend/src/metabase/components/select-list/SelectListItem.jsx @@ -8,6 +8,7 @@ const propTypes = { id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, icon: PropTypes.string.isRequired, + iconColor: PropTypes.string, onSelect: PropTypes.func.isRequired, isSelected: PropTypes.bool, isHighlighted: PropTypes.bool, @@ -19,6 +20,7 @@ export function SelectListItem({ id, name, icon, + iconColor = "brand", onSelect, isSelected = false, isHighlighted = false, @@ -37,7 +39,7 @@ export function SelectListItem({ onClick={() => onSelect(id)} onKeyDown={e => e.key === "Enter" && onSelect(id)} > - <ItemIcon name={icon} isHighlighted={isHighlighted} /> + <ItemIcon name={icon} color={iconColor} isHighlighted={isHighlighted} /> <ItemTitle>{name}</ItemTitle> {hasRightArrow && <ItemIcon name="chevronright" />} </ItemRoot> diff --git a/frontend/src/metabase/components/select-list/SelectListItem.styled.jsx b/frontend/src/metabase/components/select-list/SelectListItem.styled.jsx index d19e66fad26237a3d517f11d4898600329db7b0d..be1e625d031996025173a32f23649d28650e5861 100644 --- a/frontend/src/metabase/components/select-list/SelectListItem.styled.jsx +++ b/frontend/src/metabase/components/select-list/SelectListItem.styled.jsx @@ -1,7 +1,7 @@ import styled, { css } from "styled-components"; import Label from "metabase/components/type/Label"; -import colors from "metabase/lib/colors"; +import { color } from "metabase/lib/colors"; import Icon from "metabase/components/Icon"; export const ItemTitle = styled(Label)` @@ -12,15 +12,15 @@ export const ItemTitle = styled(Label)` export const ItemIcon = styled(Icon)` color: ${props => - props.isHighlighted ? colors["brand"] : colors["text-light"]}; + props.isHighlighted ? color(props.color) : color("text-light")}; `; const activeItemCss = css` - background-color: ${colors["brand"]}; + background-color: ${color("brand")}; ${ItemIcon}, ${ItemTitle} { - color: ${colors["white"]}; + color: ${color("white")}; } `; diff --git a/frontend/src/metabase/components/tree/TreeNode.jsx b/frontend/src/metabase/components/tree/TreeNode.jsx index 525276380a26f35c0bed4e08839321d8a46ef806..315da7e64d873db13e80f44ee6ceb6f6210a9a84 100644 --- a/frontend/src/metabase/components/tree/TreeNode.jsx +++ b/frontend/src/metabase/components/tree/TreeNode.jsx @@ -21,6 +21,7 @@ const propTypes = { item: PropTypes.shape({ name: PropTypes.string.isRequired, icon: PropTypes.string, + iconColor: PropTypes.string, hasRightArrow: PropTypes.string, id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), }).isRequired, @@ -42,7 +43,7 @@ export const TreeNode = React.memo( }, ref, ) { - const { name, icon, hasRightArrow, id } = item; + const { name, icon, iconColor, hasRightArrow, id } = item; const handleSelect = () => { onSelect(item); @@ -80,7 +81,7 @@ export const TreeNode = React.memo( {icon && ( <IconContainer variant={variant}> - <Icon name={icon} /> + <Icon name={icon} color={iconColor} /> </IconContainer> )} <NameContainer>{name}</NameContainer> diff --git a/frontend/src/metabase/containers/ItemPicker.jsx b/frontend/src/metabase/containers/ItemPicker.jsx index d3b490c10fdefc64c71ba87cc5977bce725cb47a..611e5dd313d867e96b0226af14baa57e98c9c924 100644 --- a/frontend/src/metabase/containers/ItemPicker.jsx +++ b/frontend/src/metabase/containers/ItemPicker.jsx @@ -199,14 +199,17 @@ export default class ItemPicker extends React.Component { // NOTE: this assumes the only reason you'd be selecting a collection is to modify it in some way const canSelect = models.has("collection") && collection.can_write; + + const icon = getCollectionIcon(collection); + // only show if collection can be selected or has children return canSelect || hasChildren ? ( <Item key={`collection-${collection.id}`} item={collection} name={collection.name} - color={COLLECTION_ICON_COLOR} - icon={getCollectionIcon(collection).name} + color={color(icon.color) || COLLECTION_ICON_COLOR} + icon={icon.name} selected={canSelect && isSelected(collection)} canSelect={canSelect} hasChildren={hasChildren} diff --git a/frontend/src/metabase/dashboard/components/add-card-sidebar/QuestionPicker.jsx b/frontend/src/metabase/dashboard/components/add-card-sidebar/QuestionPicker.jsx index ba8bdd20bc3025821793e7ebc9022e38fb4b104b..62b9e28c6f082169a789ced8f2c152e1fa902f12 100644 --- a/frontend/src/metabase/dashboard/components/add-card-sidebar/QuestionPicker.jsx +++ b/frontend/src/metabase/dashboard/components/add-card-sidebar/QuestionPicker.jsx @@ -11,12 +11,16 @@ import { entityListLoader } from "metabase/entities/containers/EntityListLoader" import Collections, { ROOT_COLLECTION } from "metabase/entities/collections"; import { useDebouncedValue } from "metabase/hooks/use-debounced-value"; +import { PLUGIN_COLLECTIONS } from "metabase/plugins"; + import { QuestionList } from "./QuestionList"; import { BreadcrumbsWrapper, SearchInput } from "./QuestionPicker.styled"; import { SEARCH_DEBOUNCE_DURATION } from "metabase/lib/constants"; import { SelectList } from "metabase/components/select-list"; +const { isRegularCollection } = PLUGIN_COLLECTIONS; + QuestionPicker.propTypes = { onSelect: PropTypes.func.isRequired, collectionsById: PropTypes.object, @@ -78,16 +82,23 @@ function QuestionPicker({ </BreadcrumbsWrapper> <SelectList> - {collections.map(collection => ( - <SelectList.Item - hasRightArrow - key={collection.id} - id={collection.id} - name={collection.name} - icon={getCollectionIcon(collection).name} - onSelect={collectionId => setCurrentCollectionId(collectionId)} - /> - ))} + {collections.map(collection => { + const icon = getCollectionIcon(collection); + return ( + <SelectList.Item + hasRightArrow + key={collection.id} + id={collection.id} + name={collection.name} + icon={icon.name} + iconColor={icon.color} + isHighlighted={!isRegularCollection(collection)} + onSelect={collectionId => + setCurrentCollectionId(collectionId) + } + /> + ); + })} </SelectList> </React.Fragment> )} diff --git a/frontend/src/metabase/entities/collections.js b/frontend/src/metabase/entities/collections.js index a6ab8eba31e84826901ea2c483c494a1c4c70a35..fe3eabfcc89fc508105b219f62f9033d1041e7a0 100644 --- a/frontend/src/metabase/entities/collections.js +++ b/frontend/src/metabase/entities/collections.js @@ -9,6 +9,7 @@ import { createSelector } from "reselect"; import { GET } from "metabase/lib/api"; import { getUser, getUserPersonalCollectionId } from "metabase/selectors/user"; +import { isPersonalCollection } from "metabase/collections/utils"; import { t } from "ttag"; @@ -81,7 +82,7 @@ const Collections = createEntity({ objectSelectors: { getName: collection => collection && collection.name, getUrl: collection => Urls.collection(collection), - getIcon: collection => ({ name: "folder" }), + getIcon: getCollectionIcon, }, selectors: { @@ -172,6 +173,20 @@ const Collections = createEntity({ export default Collections; +export function getCollectionIcon(collection) { + if (collection.id === PERSONAL_COLLECTIONS.id) { + return { name: "group" }; + } + if (isPersonalCollection(collection)) { + return { name: "person" }; + } + const authorityLevel = + PLUGIN_COLLECTIONS.AUTHORITY_LEVEL[collection.authority_level]; + return authorityLevel + ? { name: authorityLevel.icon, color: color(authorityLevel.color) } + : { name: "folder" }; +} + // API requires items in "root" collection be persisted with a "null" collection ID // Also ensure it's parsed as a number export const canonicalCollectionId = ( diff --git a/frontend/src/metabase/nav/components/SearchBar.jsx b/frontend/src/metabase/nav/components/SearchBar.jsx index e72455b2e9ac6e8972775727ba514129894f6155..6437003376f4896dfa0a729c07760b12dabf7a45 100644 --- a/frontend/src/metabase/nav/components/SearchBar.jsx +++ b/frontend/src/metabase/nav/components/SearchBar.jsx @@ -158,7 +158,11 @@ export default class SearchBar extends React.Component { debounced > {({ list }) => { - return <ol>{this.renderResults(list)}</ol>; + return ( + <ol data-testid="search-results-list"> + {this.renderResults(list)} + </ol> + ); }} </Search.ListLoader> </Card> diff --git a/frontend/src/metabase/plugins/index.js b/frontend/src/metabase/plugins/index.js index d492f54e0e151524af9eb59ca3118bbf655bb6d5..681cfbcbdbc5dfd36a09c0f4c9cc54253f6c75f6 100644 --- a/frontend/src/metabase/plugins/index.js +++ b/frontend/src/metabase/plugins/index.js @@ -66,6 +66,7 @@ const AUTHORITY_LEVEL_REGULAR = { export const PLUGIN_COLLECTIONS = { formFields: [], + isRegularCollection: () => true, REGULAR_COLLECTION: AUTHORITY_LEVEL_REGULAR, AUTHORITY_LEVEL: { [AUTHORITY_LEVEL_REGULAR.type]: AUTHORITY_LEVEL_REGULAR, diff --git a/frontend/src/metabase/query_builder/components/saved-question-picker/utils.js b/frontend/src/metabase/query_builder/components/saved-question-picker/utils.js index 69c51fd2b7283ff25b381b95e4ca97852fc891af..11490016e008d9972bc2fdab9f7577d3d5a4f9d8 100644 --- a/frontend/src/metabase/query_builder/components/saved-question-picker/utils.js +++ b/frontend/src/metabase/query_builder/components/saved-question-picker/utils.js @@ -1,26 +1,20 @@ -import { isPersonalCollection } from "metabase/collections/utils"; -import { PERSONAL_COLLECTIONS } from "metabase/entities/collections"; - -const getCollectionIcon = collection => { - if (collection.id === PERSONAL_COLLECTIONS.id) { - return "group"; - } - - return isPersonalCollection(collection) ? "person" : "folder"; -}; +import { getCollectionIcon } from "metabase/entities/collections"; export function buildCollectionTree(collections) { if (collections == null) { return []; } - - return collections.map(collection => ({ - id: collection.id, - name: collection.name, - schemaName: collection.originalName || collection.name, - icon: getCollectionIcon(collection), - children: buildCollectionTree(collection.children), - })); + return collections.map(collection => { + const icon = getCollectionIcon(collection); + return { + id: collection.id, + name: collection.name, + schemaName: collection.originalName || collection.name, + icon: icon.name, + iconColor: icon.color, + children: buildCollectionTree(collection.children), + }; + }); } export const findCollectionByName = (collections, name) => { diff --git a/frontend/src/metabase/questions/components/CollectionBadge.jsx b/frontend/src/metabase/questions/components/CollectionBadge.jsx index 92e11c03bcec99194a83acf10f78bfb89a62781d..ad830f534d060bf23f888a58ba47264ca80c59ea 100644 --- a/frontend/src/metabase/questions/components/CollectionBadge.jsx +++ b/frontend/src/metabase/questions/components/CollectionBadge.jsx @@ -17,10 +17,12 @@ class CollectionBadge extends React.Component { if (!collection) { return null; } + const icon = collection.getIcon(); return ( <Badge to={collection.getUrl()} - icon={collection.getIcon().name} + icon={icon.name} + iconColor={icon.color} data-metabase-event={`${analyticsContext};Collection Badge Click`} {...props} > diff --git a/frontend/src/metabase/search/components/SearchResult.jsx b/frontend/src/metabase/search/components/SearchResult.jsx index 706a376f86913b530f8d761a57947817a7b0a92c..f7d146ec44c5668aa9e99dfb85c37393b9a00bad 100644 --- a/frontend/src/metabase/search/components/SearchResult.jsx +++ b/frontend/src/metabase/search/components/SearchResult.jsx @@ -12,10 +12,14 @@ import Icon from "metabase/components/Icon"; import Link from "metabase/components/Link"; import Text from "metabase/components/type/Text"; +import { PLUGIN_COLLECTION_COMPONENTS } from "metabase/plugins"; + import Schema from "metabase/entities/schemas"; import Database from "metabase/entities/databases"; import Table from "metabase/entities/tables"; +const { CollectionAuthorityLevelIcon } = PLUGIN_COLLECTION_COMPONENTS; + function getColorForIconWrapper(props) { if (props.item.collection_position) { return color("saturated-yellow"); @@ -97,18 +101,33 @@ function ItemIcon({ item, type }) { ); } +const CollectionBadgeRoot = styled.div` + display: inline-block; +`; + const CollectionLink = styled(Link)` + display: flex; + align-items: center; text-decoration: dashed; &:hover { color: ${color("brand")}; } `; +const AuthorityLevelIcon = styled(CollectionAuthorityLevelIcon).attrs({ + size: 13, +})` + padding-right: 2px; +`; + function CollectionBadge({ collection }) { return ( - <CollectionLink to={Urls.collection(collection)}> - {collection.name} - </CollectionLink> + <CollectionBadgeRoot> + <CollectionLink to={Urls.collection(collection)}> + <AuthorityLevelIcon collection={collection} /> + {collection.name} + </CollectionLink> + </CollectionBadgeRoot> ); } diff --git a/frontend/test/__support__/e2e/commands/api/collection.js b/frontend/test/__support__/e2e/commands/api/collection.js index 10fad892449b5da0f947036ac207b7f928fa0c0c..78b8db3779c5a54d7a5b9c59fcaa4e6f942d3ea8 100644 --- a/frontend/test/__support__/e2e/commands/api/collection.js +++ b/frontend/test/__support__/e2e/commands/api/collection.js @@ -8,7 +8,7 @@ Cypress.Commands.add( authority_level = null, } = {}) => { cy.log(`Create a collection: ${name}`); - cy.request("POST", "/api/collection", { + return cy.request("POST", "/api/collection", { name, description, parent_id, diff --git a/frontend/test/__support__/e2e/commands/api/dashboard.js b/frontend/test/__support__/e2e/commands/api/dashboard.js index 85b44aec4bd60790f610e78832737d884cb55c5a..d696beb0b9d0f2115f4c8b0e5846b4f5435a597f 100644 --- a/frontend/test/__support__/e2e/commands/api/dashboard.js +++ b/frontend/test/__support__/e2e/commands/api/dashboard.js @@ -1,10 +1,11 @@ Cypress.Commands.add( "createDashboard", - (name, { collection_position = null } = {}) => { + (name, { collection_position = null, collection_id = null } = {}) => { cy.log(`Create a dashboard: ${name}`); cy.request("POST", "/api/dashboard", { name, collection_position, + collection_id, }); }, ); diff --git a/frontend/test/__support__/e2e/commands/api/question.js b/frontend/test/__support__/e2e/commands/api/question.js index 584c7aed0c014f1cb436d49d3dde627af2060873..a9cd6d7a4c1ef88e6dfb00842a9b6e89eb9f1b88 100644 --- a/frontend/test/__support__/e2e/commands/api/question.js +++ b/frontend/test/__support__/e2e/commands/api/question.js @@ -6,6 +6,7 @@ Cypress.Commands.add( display = "table", database = 1, visualization_settings = {}, + collection_id = null, collection_position = null, } = {}) => { cy.log(`Create a question: ${name}`); @@ -18,6 +19,7 @@ Cypress.Commands.add( }, display, visualization_settings, + collection_id, collection_position, }); }, diff --git a/frontend/test/metabase/entities/collections/getCollectionIcon.unit.spec.js b/frontend/test/metabase/entities/collections/getCollectionIcon.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1e7958846f31bf5141fecb0be642845e2b570ed4 --- /dev/null +++ b/frontend/test/metabase/entities/collections/getCollectionIcon.unit.spec.js @@ -0,0 +1,59 @@ +import { + getCollectionIcon, + ROOT_COLLECTION, + PERSONAL_COLLECTIONS as ALL_PERSONAL_COLLECTIONS_VIRTUAL, +} from "metabase/entities/collections"; + +// NOTE: getCollectionIcon behaves differently in EE +// e.g. it should not return 'folder' for official collections + +describe("getCollectionIcon", () => { + function collection({ + id = 10, + personal_owner_id = null, + authority_level = null, + } = {}) { + return { + id, + personal_owner_id, + authority_level, + }; + } + + const testCases = [ + { + name: "Our analytics", + collection: ROOT_COLLECTION, + expectedIcon: "folder", + }, + { + name: "All personal collections", + collection: ALL_PERSONAL_COLLECTIONS_VIRTUAL, + expectedIcon: "group", + }, + { + name: "Regular collection", + collection: collection(), + expectedIcon: "folder", + }, + { + name: "Personal collection", + collection: collection({ personal_owner_id: 4 }), + expectedIcon: "person", + }, + { + name: "Official collection", + collection: collection({ authority_level: "official" }), + expectedIcon: "folder", + }, + ]; + + testCases.forEach(testCase => { + const { name, collection, expectedIcon } = testCase; + it(`returns '${expectedIcon}' for '${name}'`, () => { + expect(getCollectionIcon(collection)).toMatchObject({ + name: expectedIcon, + }); + }); + }); +}); diff --git a/frontend/test/metabase/scenarios/collections/collection-types.cy.spec.js b/frontend/test/metabase/scenarios/collections/collection-types.cy.spec.js index 088a92c90bacb410919cdabac55d2539c2125c81..bc5cb768bcca9ba418c749956d0c463dcb7dd525 100644 --- a/frontend/test/metabase/scenarios/collections/collection-types.cy.spec.js +++ b/frontend/test/metabase/scenarios/collections/collection-types.cy.spec.js @@ -1,9 +1,21 @@ import { restore, modal, + sidebar, describeWithToken, describeWithoutToken, } from "__support__/e2e/cypress"; +import { SAMPLE_DATASET } from "__support__/e2e/cypress_sample_dataset"; + +const { ORDERS, ORDERS_ID } = SAMPLE_DATASET; + +const COLLECTION_NAME = "Official Collection Test"; + +const TEST_QUESTION_QUERY = { + "source-table": ORDERS_ID, + aggregation: [["count"]], + breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "hour-of-day" }]], +}; describeWithToken("collections types", () => { beforeEach(() => { @@ -14,33 +26,21 @@ describeWithToken("collections types", () => { it("should be able to manage collection authority level", () => { cy.visit("/collection/root"); - // Test can create official collection - cy.icon("new_folder").click(); - modal().within(() => { - cy.findByLabelText("Name").type("Official Collection Test"); - setOfficial(); - cy.button("Create").click(); - }); - cy.findByText("Official Collection Test").click(); + createAndOpenOfficialCollection({ name: COLLECTION_NAME }); cy.findByTestId("official-collection-marker"); + assertSidebarIcon(COLLECTION_NAME, "badge"); - // Test can change official collection to regular - cy.icon("pencil").click(); - cy.findByText("Edit this collection").click(); - modal().within(() => { - setOfficial(false); - cy.button("Update").click(); - }); + changeCollectionTypeTo("regular"); cy.findByTestId("official-collection-marker").should("not.exist"); + assertSidebarIcon(COLLECTION_NAME, "folder"); - // Test can change regular collection to official - cy.icon("pencil").click(); - cy.findByText("Edit this collection").click(); - modal().within(() => { - setOfficial(); - cy.button("Update").click(); - }); + changeCollectionTypeTo("official"); cy.findByTestId("official-collection-marker"); + assertSidebarIcon(COLLECTION_NAME, "badge"); + }); + + it("displays official badge throughout the application", () => { + testOfficialBadgePresence(); }); }); @@ -60,24 +60,80 @@ describeWithoutToken("collection types", () => { }); cy.findByText("First collection").click(); - cy.icon("pencil").click(); - cy.findByText("Edit this collection").click(); + editCollection(); modal().within(() => { assertNoCollectionTypeInput(); }); }); it("should not display official collection icon", () => { - cy.createCollection({ - name: "Official Collection Test", - authority_level: "official", - }); - cy.visit("/collection/root"); - cy.findByText("Official Collection Test").click(); - cy.findByTestId("official-collection-marker").should("not.exist"); + testOfficialBadgePresence(false); }); }); +function testOfficialBadgePresence(expectBadge = true) { + cy.createCollection({ + name: COLLECTION_NAME, + authority_level: "official", + }).then(response => { + const { id: collectionId } = response.body; + cy.createQuestion({ + name: "Official Question", + collection_id: collectionId, + query: TEST_QUESTION_QUERY, + }); + cy.createDashboard("Official Dashboard", { collection_id: collectionId }); + cy.visit(`/collection/${collectionId}`); + }); + + // Dashboard Page + cy.findByText("Official Dashboard").click(); + assertHasCollectionBadge(expectBadge); + + // Question Page + cy.findByText(COLLECTION_NAME).click(); + cy.findByText("Official Question").click(); + assertHasCollectionBadge(expectBadge); + + // Search + testOfficialBadgeInSearch({ + searchQuery: "Official", + collection: COLLECTION_NAME, + dashboard: "Official Dashboard", + question: "Official Question", + expectBadge, + }); +} + +// The helper accepts a single search query, +// and relies on collection, dashboard and question being found within this single query +function testOfficialBadgeInSearch({ + searchQuery, + collection, + dashboard, + question, + expectBadge, +}) { + cy.get(".Nav") + .findByPlaceholderText("Search…") + .as("searchBar") + .type(searchQuery); + + cy.findByTestId("search-results-list").within(() => { + assertSearchResultBadge(collection, { + expectBadge, + selector: "h3", + }); + assertSearchResultBadge(question, { expectBadge }); + assertSearchResultBadge(dashboard, { expectBadge }); + }); +} + +function editCollection() { + cy.icon("pencil").click(); + cy.findByText("Edit this collection").click(); +} + function setOfficial(official = true) { const isOfficialNow = !official; cy.findByLabelText("Regular").should( @@ -89,8 +145,52 @@ function setOfficial(official = true) { cy.findByText(official ? "Official" : "Regular").click(); } +function createAndOpenOfficialCollection({ name }) { + cy.icon("new_folder").click(); + modal().within(() => { + cy.findByLabelText("Name").type(name); + setOfficial(); + cy.button("Create").click(); + }); + cy.findByText(name).click(); +} + +function changeCollectionTypeTo(type) { + editCollection(); + modal().within(() => { + setOfficial(type === "official"); + cy.button("Update").click(); + }); +} + function assertNoCollectionTypeInput() { cy.findByText(/Collection type/i).should("not.exist"); cy.findByText("Regular").should("not.exist"); cy.findByText("Official").should("not.exist"); } + +function assertSidebarIcon(collectionName, expectedIcon) { + sidebar() + .findByText(collectionName) + .parent() + .within(() => { + cy.icon(expectedIcon); + }); +} + +function assertSearchResultBadge(itemName, opts) { + const { expectBadge } = opts; + cy.findByText(itemName, opts) + .parentsUntil("[data-testid=search-result-item]") + .within(() => { + cy.icon("badge").should(expectBadge ? "exist" : "not.exist"); + }); +} + +function assertHasCollectionBadge(expectBadge = true) { + cy.findByText(COLLECTION_NAME) + .parent() + .within(() => { + cy.icon("badge").should(expectBadge ? "exist" : "not.exist"); + }); +}