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

Add inline collection name and description editing (#23518)

parent a1e23ac7
No related branches found
No related tags found
No related merge requests found
Showing
with 125 additions and 47 deletions
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import EditableText from "metabase/core/components/EditableText";
export const CaptionContainer = styled.div` export const CaptionTitleContainer = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
`; `;
export const CaptionTitle = styled.h1` export const CaptionTitle = styled(EditableText)`
font-size: 1.75rem;
font-weight: 900; font-weight: 900;
word-break: break-word;
word-wrap: anywhere;
overflow-wrap: anywhere;
`; `;
export const CaptionDescription = styled.div` export interface CaptionDescriptionProps {
font-size: 1rem; isVisible: boolean;
line-height: 1.5rem; }
padding-top: 1.15rem;
export const CaptionDescription = styled(EditableText)<CaptionDescriptionProps>`
opacity: ${props => (props.isVisible ? 1 : 0)};
max-width: 25rem; max-width: 25rem;
transition: opacity 400ms ease 1s;
`;
export const CaptionRoot = styled.div`
&:hover,
&:focus-within {
${CaptionDescription} {
opacity: 1;
transition-delay: 0s;
}
}
`; `;
import React from "react"; import React, { useCallback } from "react";
import { t } from "ttag";
import { PLUGIN_COLLECTION_COMPONENTS } from "metabase/plugins"; import { PLUGIN_COLLECTION_COMPONENTS } from "metabase/plugins";
import {
isPersonalCollection,
isRootCollection,
} from "metabase/collections/utils";
import { Collection } from "metabase-types/api"; import { Collection } from "metabase-types/api";
import { import {
CaptionContainer,
CaptionTitle,
CaptionDescription, CaptionDescription,
CaptionRoot,
CaptionTitle,
CaptionTitleContainer,
} from "./CollectionCaption.styled"; } from "./CollectionCaption.styled";
export interface CollectionCaptionProps { export interface CollectionCaptionProps {
collection: Collection; collection: Collection;
onChangeName: (collection: Collection, name: string) => void;
onChangeDescription: (
collection: Collection,
description: string | null,
) => void;
} }
const CollectionCaption = ({ const CollectionCaption = ({
collection, collection,
onChangeName,
onChangeDescription,
}: CollectionCaptionProps): JSX.Element => { }: CollectionCaptionProps): JSX.Element => {
const isRoot = isRootCollection(collection);
const isPersonal = isPersonalCollection(collection);
const isEditable = !isRoot && !isPersonal && collection.can_write;
const handleChangeName = useCallback(
(name: string) => {
onChangeName(collection, name);
},
[collection, onChangeName],
);
const handleChangeDescription = useCallback(
(description: string) => {
onChangeDescription(collection, description ? description : null);
},
[collection, onChangeDescription],
);
return ( return (
<div> <CaptionRoot>
<CaptionContainer> <CaptionTitleContainer>
<PLUGIN_COLLECTION_COMPONENTS.CollectionAuthorityLevelIcon <PLUGIN_COLLECTION_COMPONENTS.CollectionAuthorityLevelIcon
collection={collection} collection={collection}
size={24} size={24}
/> />
<CaptionTitle data-testid="collection-name-heading"> <CaptionTitle
{collection.name} key={collection.id}
</CaptionTitle> initialValue={collection.name}
</CaptionContainer> placeholder={t`Add title`}
{collection.description && ( isDisabled={!isEditable}
<CaptionDescription>{collection.description}</CaptionDescription> data-testid="collection-name-heading"
onChange={handleChangeName}
/>
</CaptionTitleContainer>
{isEditable && (
<CaptionDescription
key={collection.id}
initialValue={collection.description}
placeholder={t`Add description`}
isVisible={Boolean(collection.description)}
isOptional
isMultiline
onChange={handleChangeDescription}
/>
)} )}
</div> </CaptionRoot>
); );
}; };
......
...@@ -11,6 +11,11 @@ export interface CollectionHeaderProps { ...@@ -11,6 +11,11 @@ export interface CollectionHeaderProps {
isAdmin: boolean; isAdmin: boolean;
isBookmarked: boolean; isBookmarked: boolean;
isPersonalCollectionChild: boolean; isPersonalCollectionChild: boolean;
onChangeName: (collection: Collection, name: string) => void;
onChangeDescription: (
collection: Collection,
description: string | null,
) => void;
onCreateBookmark: (collection: Collection) => void; onCreateBookmark: (collection: Collection) => void;
onDeleteBookmark: (collection: Collection) => void; onDeleteBookmark: (collection: Collection) => void;
} }
...@@ -20,12 +25,18 @@ const CollectionHeader = ({ ...@@ -20,12 +25,18 @@ const CollectionHeader = ({
isAdmin, isAdmin,
isBookmarked, isBookmarked,
isPersonalCollectionChild, isPersonalCollectionChild,
onChangeName,
onChangeDescription,
onCreateBookmark, onCreateBookmark,
onDeleteBookmark, onDeleteBookmark,
}: CollectionHeaderProps): JSX.Element => { }: CollectionHeaderProps): JSX.Element => {
return ( return (
<HeaderRoot> <HeaderRoot>
<CollectionCaption collection={collection} /> <CollectionCaption
collection={collection}
onChangeName={onChangeName}
onChangeDescription={onChangeDescription}
/>
<HeaderActions data-testid="collection-menu"> <HeaderActions data-testid="collection-menu">
<CollectionTimeline collection={collection} /> <CollectionTimeline collection={collection} />
<CollectionBookmark <CollectionBookmark
......
import _ from "underscore";
import { connect } from "react-redux";
import { State } from "metabase-types/store";
import Databases from "metabase/entities/databases";
import { getHasDataAccess } from "metabase/new_query/selectors";
import CollectionHeader from "../components/CollectionHeader/CollectionHeader";
const mapStateToProps = (state: State) => ({
canCreateQuestions: getHasDataAccess(state),
});
export default _.compose(
Databases.loadList({
loadingAndErrorWrapper: false,
}),
connect(mapStateToProps),
)(CollectionHeader);
import { connect } from "react-redux";
import Collections from "metabase/entities/collections";
import { Collection } from "metabase-types/api";
import CollectionHeader from "../../components/CollectionHeader";
const mapDispatchToProps = {
onChangeName: (collection: Collection, name: string) =>
Collections.actions.update(collection, { name }),
onChangeDescription: (collection: Collection, description: string | null) =>
Collections.actions.update(collection, { description }),
};
export default connect(null, mapDispatchToProps)(CollectionHeader);
export { default } from "./CollectionHeader";
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { color } from "metabase/lib/colors"; import { color } from "metabase/lib/colors";
export const EditableTextRoot = styled.div` export interface EditableTextRootProps {
isDisabled: boolean;
}
export const EditableTextRoot = styled.div<EditableTextRootProps>`
position: relative; position: relative;
color: ${color("text-dark")}; color: ${color("text-dark")};
padding: 0.25rem; padding: 0.25rem;
...@@ -9,7 +13,7 @@ export const EditableTextRoot = styled.div` ...@@ -9,7 +13,7 @@ export const EditableTextRoot = styled.div`
&:hover, &:hover,
&:focus-within { &:focus-within {
border-color: ${color("border")}; border-color: ${props => (props.isDisabled ? "" : color("border"))};
} }
&:after { &:after {
...@@ -32,7 +36,7 @@ export const EditableTextArea = styled.textarea` ...@@ -32,7 +36,7 @@ export const EditableTextArea = styled.textarea`
font-size: inherit; font-size: inherit;
font-weight: inherit; font-weight: inherit;
line-height: inherit; line-height: inherit;
cursor: pointer; cursor: ${props => (props.disabled ? "text" : "pointer")};
border: none; border: none;
resize: none; resize: none;
outline: none; outline: none;
......
...@@ -19,6 +19,7 @@ export interface EditableTextProps extends EditableTextAttributes { ...@@ -19,6 +19,7 @@ export interface EditableTextProps extends EditableTextAttributes {
placeholder?: string; placeholder?: string;
isOptional?: boolean; isOptional?: boolean;
isMultiline?: boolean; isMultiline?: boolean;
isDisabled?: boolean;
onChange?: (value: string) => void; onChange?: (value: string) => void;
"data-testid"?: string; "data-testid"?: string;
} }
...@@ -29,6 +30,7 @@ const EditableText = forwardRef(function EditableText( ...@@ -29,6 +30,7 @@ const EditableText = forwardRef(function EditableText(
placeholder, placeholder,
isOptional = false, isOptional = false,
isMultiline = false, isMultiline = false,
isDisabled = false,
onChange, onChange,
"data-testid": dataTestId, "data-testid": dataTestId,
...props ...props
...@@ -68,10 +70,16 @@ const EditableText = forwardRef(function EditableText( ...@@ -68,10 +70,16 @@ const EditableText = forwardRef(function EditableText(
); );
return ( return (
<EditableTextRoot {...props} ref={ref} data-value={`${displayValue}\u00A0`}> <EditableTextRoot
{...props}
ref={ref}
isDisabled={isDisabled}
data-value={`${displayValue}\u00A0`}
>
<EditableTextArea <EditableTextArea
value={inputValue} value={inputValue}
placeholder={placeholder} placeholder={placeholder}
disabled={isDisabled}
data-testid={dataTestId} data-testid={dataTestId}
onBlur={handleBlur} onBlur={handleBlur}
onChange={handleChange} onChange={handleChange}
......
...@@ -27,7 +27,7 @@ function SavedQuestionHeaderButton({ className, question, onSave }) { ...@@ -27,7 +27,7 @@ function SavedQuestionHeaderButton({ className, question, onSave }) {
<HeaderRoot> <HeaderRoot>
<HeaderTitle <HeaderTitle
initialValue={question.displayName()} initialValue={question.displayName()}
placeholder={t`A nice title`} placeholder={t`Add title`}
onChange={onSave} onChange={onSave}
data-testid="saved-question-header-title" data-testid="saved-question-header-title"
/> />
......
...@@ -50,7 +50,7 @@ export const QuestionInfoSidebar = ({ ...@@ -50,7 +50,7 @@ export const QuestionInfoSidebar = ({
<ContentSection> <ContentSection>
<EditableText <EditableText
initialValue={description} initialValue={description}
placeholder={t`Description`} placeholder={t`Add description`}
isOptional isOptional
isMultiline isMultiline
onChange={handleSave} onChange={handleSave}
......
...@@ -46,7 +46,10 @@ describe("metabase > scenarios > navbar > new menu", () => { ...@@ -46,7 +46,10 @@ describe("metabase > scenarios > navbar > new menu", () => {
cy.findByText("Create").click(); cy.findByText("Create").click();
}); });
cy.get("h1").should("have.text", "Test collection"); cy.findByTestId("collection-name-heading").should(
"have.text",
"Test collection",
);
}); });
it("should suggest questions saved in collections with colon in their name (metabase#14287)", () => { it("should suggest questions saved in collections with colon in their name (metabase#14287)", () => {
......
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