Skip to content
Snippets Groups Projects
Unverified Commit 0d70e9b0 authored by Ryan Laurie's avatar Ryan Laurie Committed by GitHub
Browse files

More question details in sidebar (#47705)

* more detailed question info

small styling updates

refine styling to match designs

update more e2e test

remove some changes

small styling updates

more question details

more question details

DateTime to typescript

test updates

type updates

export fix

fixes

* Icon Updates

* consolidate styles

* test updates

* address review comments
parent 059b2ce1
No related branches found
No related tags found
No related merge requests found
Showing
with 369 additions and 69 deletions
......@@ -47,10 +47,12 @@ import type {
DatasetData,
DatasetQuery,
Field,
LastEditInfo,
ParameterId,
Parameter as ParameterObject,
ParameterValues,
TableId,
UserInfo,
VisualizationSettings,
} from "metabase-types/api";
......@@ -526,7 +528,7 @@ class Question {
return this.setCard(assoc(this.card(), "description", description));
}
lastEditInfo() {
lastEditInfo(): LastEditInfo {
return this._card && this._card["last-edit-info"];
}
......@@ -811,7 +813,7 @@ class Question {
return getIn(this, ["_card", "moderation_reviews"]) || [];
}
getCreator(): string {
getCreator(): UserInfo {
return getIn(this, ["_card", "creator"]) || "";
}
......
import type { EmbeddingParameters } from "metabase/public/lib/types";
import type { PieRow } from "metabase/visualizations/echarts/pie/model/types";
import type { Collection, CollectionId } from "./collection";
import type { Collection, CollectionId, LastEditInfo } from "./collection";
import type { DashCardId, DashboardId } from "./dashboard";
import type { Database, DatabaseId } from "./database";
import type { BaseEntityId } from "./entity-id";
......@@ -14,9 +14,13 @@ import type { Table } from "./table";
import type { UserInfo } from "./user";
import type { CardDisplayType, VisualizationDisplay } from "./visualization";
import type { SmartScalarComparison } from "./visualization-settings";
export type CardType = "model" | "question" | "metric";
type CreatorInfo = Pick<
UserInfo,
"first_name" | "last_name" | "email" | "id" | "common_name"
>;
export interface Card<Q extends DatasetQuery = DatasetQuery>
extends UnsavedCard<Q> {
id: CardId;
......@@ -53,7 +57,8 @@ export interface Card<Q extends DatasetQuery = DatasetQuery>
archived: boolean;
creator?: UserInfo;
creator?: CreatorInfo;
"last-edit-info"?: LastEditInfo;
}
export interface PublicCard {
......
import cx from "classnames";
import { useState } from "react";
import CopyToClipboard from "react-copy-to-clipboard";
import { t } from "ttag";
import Styles from "metabase/css/core/index.css";
import { Icon, Text, Tooltip } from "metabase/ui";
type CopyButtonProps = {
......@@ -11,10 +13,11 @@ type CopyButtonProps = {
style?: object;
"aria-label"?: string;
};
export const CopyButton = ({
value,
onCopy,
className,
className = cx(Styles.textBrandHover, Styles.cursorPointer),
style,
}: CopyButtonProps) => {
const [copied, setCopied] = useState(false);
......
import PropTypes from "prop-types";
import { formatDateTimeWithUnit } from "metabase/lib/formatting";
import MetabaseSettings from "metabase/lib/settings";
// Mirrors DatetimeUnit type, as it's used in date formatting utility fn
// Type: https://github.com/metabase/metabase/blob/8778569c56beb573b0e688d49edba327b8ae62ab/frontend/src/metabase-types/api/query.ts#L31
export const DATE_TIME_UNITS = [
"default",
"minute",
"minute-of-hour",
"hour",
"hour-of-day",
"day",
"day-of-week",
"day-of-month",
"day-of-year",
"week",
"week-of-year",
"month",
"month-of-year",
"quarter",
"quarter-of-year",
"year",
];
const propTypes = {
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.instanceOf(Date),
PropTypes.number, // UNIX timestamp
]).isRequired,
unit: PropTypes.oneOf(DATE_TIME_UNITS),
};
function DateTime({ value, unit = "default", ...props }) {
const options = MetabaseSettings.formattingOptions();
const formattedTime = formatDateTimeWithUnit(value, unit, options);
return <span {...props}>{formattedTime}</span>;
}
DateTime.propTypes = propTypes;
export default DateTime;
// eslint-disable-next-line no-restricted-imports -- legacy usage
import type { Moment } from "moment-timezone";
import { formatDateTimeWithUnit } from "metabase/lib/formatting";
import MetabaseSettings from "metabase/lib/settings";
import type { DatetimeUnit } from "metabase-types/api";
type DateTimeProps = {
value: string | Date | number | Moment;
componentProps?: React.ComponentProps<"span">;
unit?: DatetimeUnit;
};
/**
* note: this component intentionally doesn't let you pick a custom date format
* because that is an instance setting and should be respected globally
*/
function DateTime({ value, unit = "default", ...props }: DateTimeProps) {
const options = MetabaseSettings.formattingOptions();
const formattedTime = formatDateTimeWithUnit(
value,
unit ?? "default",
options,
);
return <span {...props}>{formattedTime}</span>;
}
// eslint-disable-next-line import/no-default-export -- legacy usage
export default DateTime;
......@@ -30,7 +30,7 @@ describe("DateTime", () => {
};
}
function mockFormatting(settings) {
function mockFormatting(settings: any) {
jest
.spyOn(MetabaseSettings, "formattingOptions")
.mockImplementation(() => settings);
......
// eslint-disable-next-line import/no-default-export -- legacy usage
export { default } from "./DateTime";
export * from "./DateTime";
import { t } from "ttag";
import { SidesheetCard } from "metabase/common/components/Sidesheet";
import { useDocsUrl } from "metabase/common/hooks";
import { CopyButton } from "metabase/components/CopyButton";
import Link from "metabase/core/components/Link";
import { Flex, Group, Icon, Paper, Popover, Text } from "metabase/ui";
const EntityIdTitle = () => {
const { url: docsLink, showMetabaseLinks } = useDocsUrl(
"installation-and-operation/serialization",
);
return (
<Group spacing="sm">
{t`Entity ID`}
<Popover position="top-start">
<Popover.Target>
<Icon
name="info"
cursor="pointer"
style={{ position: "relative", top: "-1px" }}
/>
</Popover.Target>
<Popover.Dropdown>
<Paper p="md" maw="13rem">
<Text size="sm">
{t`When using serialization, replace the sequential ID with this global entity ID to have stable URLs across environments. Also useful when troubleshooting serialization.`}{" "}
{showMetabaseLinks && (
<>
<Link
target="_new"
to={docsLink}
style={{ color: "var(--mb-color-brand)" }}
>
Learn more
</Link>
</>
)}
</Text>
</Paper>
</Popover.Dropdown>
</Popover>
</Group>
);
};
export function EntityIdCard({ entityId }: { entityId: string }) {
return (
<SidesheetCard title={<EntityIdTitle />}>
<Flex gap="sm" align="end">
<Text>{entityId}</Text>
<CopyButton value={entityId} />
</Flex>
</SidesheetCard>
);
}
export * from "./EntityIdCard";
......@@ -5,13 +5,12 @@ import type { ActionMenuProps } from "metabase/collections/components/ActionMenu
import ActionMenu from "metabase/collections/components/ActionMenu";
import DateTime from "metabase/components/DateTime";
import EntityItem from "metabase/components/EntityItem";
import type { Edit } from "metabase/components/LastEditInfoLabel/LastEditInfoLabel";
import CheckBox from "metabase/core/components/CheckBox";
import { Ellipsified } from "metabase/core/components/Ellipsified";
import Markdown from "metabase/core/components/Markdown";
import Tooltip from "metabase/core/components/Tooltip";
import { useSelector } from "metabase/lib/redux";
import { getFullName } from "metabase/lib/user";
import { getUserName } from "metabase/lib/user";
import { PLUGIN_MODERATION } from "metabase/plugins";
import { getIsEmbeddingSdk } from "metabase/selectors/embed";
import type { IconProps } from "metabase/ui";
......@@ -219,7 +218,7 @@ export const Columns = {
item: CollectionItem;
}) => {
const lastEditInfo = item["last-edit-info"];
const lastEditedBy = getLastEditedBy(lastEditInfo) ?? "";
const lastEditedBy = getUserName(lastEditInfo) ?? "";
return (
<ItemCell
......@@ -326,11 +325,3 @@ export const Columns = {
Cell: () => <ItemCell />,
},
};
const getLastEditedBy = (lastEditInfo?: Edit) => {
if (!lastEditInfo) {
return "";
}
const name = getFullName(lastEditInfo);
return name || lastEditInfo.email;
};
......@@ -154,7 +154,8 @@
color: var(--mb-color-text-dark);
}
.textBrand {
.textBrand,
.textBrandHover:hover {
color: var(--mb-color-brand);
}
......
......@@ -1016,10 +1016,10 @@ function replaceDateFormatNames(format: string, options: OptionsType) {
.replace(/\bdddd\b/g, getDayFormat(options));
}
function formatDateTimeWithFormats(
value: number | Moment,
export function formatDateTimeWithFormats(
value: number | string | Date | Moment,
dateFormat: string,
timeFormat: string,
timeFormat: string | null,
options: OptionsType,
) {
const m = moment.isMoment(value)
......@@ -1048,7 +1048,7 @@ function formatDateTimeWithFormats(
}
export function formatDateTimeWithUnit(
value: number | string,
value: number | string | Date | Moment,
unit: DatetimeUnit,
options: OptionsType = {},
) {
......@@ -1073,7 +1073,7 @@ export function formatDateTimeWithUnit(
!options.noRange
) {
// tooltip show range like "January 1 - 7, 2017"
return formatDateTimeRangeWithUnit([value], unit, options);
return formatDateTimeRangeWithUnit([String(value)], unit, options);
}
}
......
......@@ -4,6 +4,14 @@ export function getFullName(user: NamedUser): string | null {
return [firstName, lastName].join(" ").trim() || null;
}
export const getUserName = (userInfo?: NamedUser) => {
if (!userInfo) {
return "";
}
const name = getFullName(userInfo);
return name || userInfo.email;
};
export interface NamedUser {
first_name?: string | null;
last_name?: string | null;
......
import cx from "classnames";
import { useState } from "react";
import { c, t } from "ttag";
import { SidesheetCardSection } from "metabase/common/components/Sidesheet";
import DateTime from "metabase/components/DateTime";
import Styles from "metabase/css/core/index.css";
import { getUserName } from "metabase/lib/user";
import { QuestionPublicLinkPopover } from "metabase/sharing/components/PublicLinkPopover";
import { Box, Flex, Icon, Text } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
import SidebarStyles from "./QuestionInfoSidebar.module.css";
export const QuestionDetails = ({ question }: { question: Question }) => {
const lastEditInfo = question.lastEditInfo();
const createdBy = question.getCreator();
const createdAt = question.getCreatedAt();
return (
<>
<SidesheetCardSection title={t`Creator and last editor`}>
{lastEditInfo && (
<Flex gap="sm" align="center">
<Icon name="ai" />
<Text>
{c("{0} is a date/time and {1} is a person's name").jt`${(
<DateTime
unit="day"
value={lastEditInfo.timestamp}
key="date"
/>
)} by ${getUserName(lastEditInfo)}`}
</Text>
</Flex>
)}
<Flex gap="sm" align="center">
<Icon name="pencil" />
<Text>
{c("{0} is a date/time and {1} is a person's name").jt`${(
<DateTime unit="day" value={createdAt} key="date" />
)} by ${getUserName(createdBy)}`}
</Text>
</Flex>
</SidesheetCardSection>
<SidesheetCardSection title={t`Saved in`}>
<Flex gap="sm" align="center">
<Icon name="folder" />
<Text>{question.collection()?.name}</Text>
</Flex>
</SidesheetCardSection>
<SharingDisplay question={question} />
<SourceDisplay question={question} />
</>
);
};
function SourceDisplay({ question }: { question: Question }) {
const sourceInfo = question.legacyQueryTable();
if (!sourceInfo) {
return null;
}
return (
<SidesheetCardSection title={t`Based on`}>
<Flex gap="sm" align="center">
{sourceInfo.db && (
<>
<Text>{sourceInfo.db.name}</Text>
{"/"}
</>
)}
<Text>{sourceInfo?.display_name}</Text>
</Flex>
</SidesheetCardSection>
);
}
function SharingDisplay({ question }: { question: Question }) {
const publicUUID = question.publicUUID();
const embeddingEnabled = question._card.enable_embedding;
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
if (!publicUUID && !embeddingEnabled) {
return null;
}
return (
<SidesheetCardSection title={t`Visibility`}>
{publicUUID && (
<Flex gap="sm" align="center">
<Icon name="globe" color="var(--mb-color-brand)" />
<Text>{t`Shared publicly`}</Text>
<QuestionPublicLinkPopover
target={
<Icon
name="link"
onClick={() => setIsPopoverOpen(prev => !prev)}
className={cx(
Styles.cursorPointer,
Styles.textBrandHover,
SidebarStyles.LinkIcon,
)}
/>
}
isOpen={isPopoverOpen}
onClose={() => setIsPopoverOpen(false)}
question={question}
/>
</Flex>
)}
{embeddingEnabled && (
<Flex gap="sm" align="center">
<Box className={SidebarStyles.BrandCircle}>
<Icon name="embed" size="14px" />
</Box>
<Text>{t`Embedded`}</Text>
</Flex>
)}
</SidesheetCardSection>
);
}
......@@ -3,3 +3,16 @@
overflow: auto;
line-height: 1.38rem; /* magic number to keep line-height from changing in edit mode */
}
.BrandCircle {
background-color: var(--mb-color-brand);
color: var(--mb-color-text-white);
border-radius: 50%;
height: 1rem;
width: 1rem;
padding: 1px;
}
.LinkIcon {
margin-top: 0.2rem;
}
......@@ -8,6 +8,7 @@ import {
SidesheetTabPanelContainer,
} from "metabase/common/components/Sidesheet";
import SidesheetStyles from "metabase/common/components/Sidesheet/sidesheet.module.css";
import { EntityIdCard } from "metabase/components/EntityIdCard";
import EditableText from "metabase/core/components/EditableText";
import Link from "metabase/core/components/Link";
import { useDispatch } from "metabase/lib/redux";
......@@ -18,6 +19,7 @@ import { QuestionActivityTimeline } from "metabase/query_builder/components/Ques
import { Stack, Tabs } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
import { QuestionDetails } from "./QuestionDetails";
import Styles from "./QuestionInfoSidebar.module.css";
interface QuestionInfoSidebarProps {
......@@ -93,6 +95,10 @@ export const QuestionInfoSidebar = ({
>{t`See more about this model`}</Link>
)}
</SidesheetCard>
<SidesheetCard>
<QuestionDetails question={question} />
</SidesheetCard>
<EntityIdCard entityId={question._card.entity_id} />
</Stack>
</Tabs.Panel>
<Tabs.Panel value="history">
......
......@@ -2,8 +2,10 @@ import userEvent from "@testing-library/user-event";
import { screen } from "__support__/ui";
import * as Urls from "metabase/lib/urls";
import type { BaseEntityId } from "metabase-types/api";
import {
createMockCard,
createMockCollection,
createMockModerationReview,
} from "metabase-types/api/mocks";
......@@ -52,6 +54,96 @@ describe("QuestionInfoSidebar", () => {
});
});
describe("question details", () => {
it("should show last edited", () => {
const card = createMockCard({
name: "Question",
"last-edit-info": {
first_name: "Ash",
last_name: "Ketchum",
timestamp: "2024-04-11T00:00:00Z",
email: "Ashboy@example.com",
id: 19,
},
});
setup({ card });
expect(screen.getByText("April 11, 2024")).toBeInTheDocument();
expect(screen.getByText("by Ash Ketchum")).toBeInTheDocument();
});
it("should show creation information", () => {
const card = createMockCard({
name: "Question",
creator: {
first_name: "Ash",
last_name: "Ketchum",
email: "Ashboy@example.com",
common_name: "Ash Ketchum",
id: 19,
},
created_at: "2024-04-13T00:00:00Z",
});
setup({ card });
expect(screen.getByText("April 13, 2024")).toBeInTheDocument();
expect(screen.getByText("by Ash Ketchum")).toBeInTheDocument();
});
it("should show save location", () => {
const card = createMockCard({
name: "Question",
collection: createMockCollection({ name: "My Big Collection" }),
});
setup({ card });
expect(screen.getByText("My Big Collection")).toBeInTheDocument();
});
it("should show source information", () => {
const card = createMockCard({
name: "Question",
});
setup({ card });
expect(screen.getByText("Sample Database")).toBeInTheDocument();
expect(screen.getByText("/")).toBeInTheDocument();
expect(screen.getByText("Products")).toBeInTheDocument();
});
it("should show entity id", () => {
const card = createMockCard({
name: "Question",
entity_id: "jenny8675309" as BaseEntityId,
});
setup({ card });
expect(screen.getByText("Entity ID")).toBeInTheDocument();
expect(screen.getByText("jenny8675309")).toBeInTheDocument();
});
it("should show if a public link is enabled", () => {
const card = createMockCard({
name: "Question",
public_uuid: "watch-me-please",
});
setup({ card });
expect(screen.getByLabelText("globe icon")).toBeInTheDocument();
expect(screen.getByText("Shared publicly")).toBeInTheDocument();
expect(screen.getByLabelText("link icon")).toBeInTheDocument();
});
it("should show if a embedding is enabled", () => {
const card = createMockCard({
name: "Question",
enable_embedding: true,
});
setup({ card });
expect(screen.getByLabelText("embed icon")).toBeInTheDocument();
expect(screen.getByText("Embedded")).toBeInTheDocument();
});
});
describe("model detail link", () => {
it("is shown for models", async () => {
const card = createMockCard({
......
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.99795 14.75C4.27097 14.7489 1.25 11.7272 1.25 8C1.25 4.27208 4.27208 1.25 8 1.25C11.7279 1.25 14.75 4.27208 14.75 8C14.75 11.7272 11.7291 14.7488 8.00222 14.75H7.99795ZM2.75 8C2.75 7.74536 2.76813 7.49495 2.80317 7.25H4.76868C4.75641 7.49077 4.75 7.74066 4.75 8C4.75 8.25934 4.75641 8.50923 4.76868 8.75H2.80317C2.76813 8.50505 2.75 8.25464 2.75 8ZM4.93557 10.25H3.25522C3.78092 11.3566 4.68236 12.2502 5.79468 12.7657C5.43705 12.127 5.12357 11.3038 4.93557 10.25ZM10.2053 12.7657C10.5629 12.127 10.8764 11.3038 11.0644 10.25H12.7448C12.2191 11.3566 11.3176 12.2502 10.2053 12.7657ZM9.53666 10.25C9.31808 11.2877 8.96513 11.9835 8.6397 12.4391C8.40242 12.7713 8.17167 12.9874 8 13.1209C7.82833 12.9874 7.59758 12.7713 7.3603 12.4391C7.03488 11.9835 6.68192 11.2877 6.46334 10.25H9.53666ZM9.72918 8.75H6.27082C6.25724 8.51224 6.25 8.26243 6.25 8C6.25 7.73757 6.25724 7.48776 6.27082 7.25H9.72918C9.74276 7.48776 9.75 7.73757 9.75 8C9.75 8.26243 9.74276 8.51224 9.72918 8.75ZM11.2313 8.75H13.1968C13.2319 8.50505 13.25 8.25464 13.25 8C13.25 7.74536 13.2319 7.49495 13.1968 7.25H11.2313C11.2436 7.49077 11.25 7.74066 11.25 8C11.25 8.25934 11.2436 8.50923 11.2313 8.75ZM11.0644 5.75H12.7448C12.2191 4.64338 11.3176 3.74984 10.2053 3.23426C10.5629 3.87298 10.8764 4.69618 11.0644 5.75ZM5.79468 3.23426C5.43705 3.87298 5.12357 4.69618 4.93557 5.75H3.25522C3.78092 4.64338 4.68236 3.74984 5.79468 3.23426ZM6.46334 5.75H9.53666C9.31808 4.71226 8.96512 4.01653 8.6397 3.56093C8.40241 3.22873 8.17167 3.01261 8 2.87914C7.82833 3.01261 7.59758 3.22873 7.3603 3.56093C7.03487 4.01653 6.68192 4.71226 6.46334 5.75Z" />
</svg>
......@@ -161,6 +161,8 @@ import gear_component from "./gear.svg?component";
import gear_source from "./gear.svg?source";
import gem_component from "./gem.svg?component";
import gem_source from "./gem.svg?source";
import globe_component from "./globe.svg?component";
import globe_source from "./globe.svg?source";
import google_component from "./google.svg?component";
import google_source from "./google.svg?source";
import grabber_component from "./grabber.svg?component";
......@@ -709,6 +711,10 @@ export const Icons = {
component: gem_component,
source: gem_source,
},
globe: {
component: globe_component,
source: globe_source,
},
grabber: {
component: grabber_component,
source: grabber_source,
......
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