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

Add pinned card loading states (#23240)

parent fc479abc
No related merge requests found
Showing
with 613 additions and 122 deletions
import React, { useRef } from "react";
import PropTypes from "prop-types";
import _ from "underscore";
import Questions from "metabase/entities/questions";
import Question from "metabase-lib/lib/Question";
import Visualization, {
ERROR_MESSAGE_GENERIC,
ERROR_MESSAGE_PERMISSION,
} from "metabase/visualizations/components/Visualization";
import QuestionResultLoader from "metabase/containers/QuestionResultLoader";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import { ItemLink } from "../PinnedItemCard/PinnedItemCard.styled";
import { HoverMenu, VizCard } from "./CollectionCardVisualization.styled";
const propTypes = {
bookmarks: PropTypes.array,
createBookmark: PropTypes.func.isRequired,
deleteBookmark: PropTypes.func.isRequired,
item: PropTypes.object.isRequired,
collection: PropTypes.object.isRequired,
metadata: PropTypes.object.isRequired,
onCopy: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired,
};
function CollectionCardVisualization({
bookmarks,
createBookmark,
deleteBookmark,
item,
collection,
metadata,
onCopy,
onMove,
}) {
const questionRef = useRef();
return (
<ItemLink to={item.getUrl()}>
<VizCard flat>
<HoverMenu
bookmarks={bookmarks}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
item={item}
collection={collection}
onCopy={onCopy}
onMove={onMove}
/>
<Questions.Loader id={item.id}>
{({ question: card }) => {
// reusing the initial question instance avoids triggering queries every time this component rerenders
questionRef.current =
questionRef.current || new Question(card, metadata);
return (
<QuestionResultLoader
question={questionRef.current}
collectionPreview
>
{({ loading, error, reload, rawSeries, results, result }) => {
const shouldShowLoader = loading && results == null;
const { errorMessage, errorIcon } = getErrorProps(
error,
result,
);
return (
<LoadingAndErrorWrapper
loading={shouldShowLoader}
noWrapper
>
<Visualization
isDashboard
showTitle
metadata={metadata}
error={errorMessage}
errorIcon={errorIcon}
rawSeries={rawSeries}
/>
</LoadingAndErrorWrapper>
);
}}
</QuestionResultLoader>
);
}}
</Questions.Loader>
</VizCard>
</ItemLink>
);
}
CollectionCardVisualization.propTypes = propTypes;
export default CollectionCardVisualization;
function getErrorProps(error, result) {
error = error || result?.error;
let errorMessage;
let errorIcon;
if (error) {
if (error.status === 403) {
errorMessage = ERROR_MESSAGE_PERMISSION;
} else {
errorMessage = ERROR_MESSAGE_GENERIC;
}
errorIcon = "warning";
}
return { errorMessage, errorIcon };
}
export { default } from "./CollectionCardVisualization";
import styled from "@emotion/styled";
import ActionMenu from "metabase/collections/components/ActionMenu";
import Card from "metabase/components/Card";
import { color } from "metabase/lib/colors";
import Link from "metabase/core/components/Link";
import ActionMenu from "metabase/collections/components/ActionMenu";
import ChartSkeleton from "metabase/visualizations/components/skeletons/ChartSkeleton";
import { LegendLabel } from "metabase/visualizations/components/legend/LegendCaption.styled";
const HEIGHT = 250;
export const HoverMenu = styled(ActionMenu)`
visibility: hidden;
color: ${color("text-medium")};
export const CardActionMenu = styled(ActionMenu)`
position: absolute;
top: 5px;
right: 5px;
top: 0.3125rem;
right: 0.3125rem;
z-index: 3;
color: ${color("text-medium")};
visibility: hidden;
`;
export const VizCard = styled(Card)`
padding: 0.5rem 0;
export const CardSkeleton = styled(ChartSkeleton)`
padding: 0.5rem 1rem;
`;
export const CardRoot = styled(Link)`
position: relative;
line-height: inherit;
height: ${HEIGHT}px;
display: block;
height: 15.625rem;
padding: 0.5rem 0;
border: 1px solid ${color("border")};
border-radius: 0.375rem;
background-color: ${color("white")};
&:hover {
${HoverMenu} {
${CardActionMenu} {
visibility: visible;
}
${LegendLabel} {
color: ${color("brand")};
}
${ChartSkeleton.Title} {
color: ${color("brand")};
}
${ChartSkeleton.Description} {
visibility: visible;
}
}
.leaflet-container,
......
import React from "react";
import { Item } from "metabase/collections/utils";
import Visualization from "metabase/visualizations/components/Visualization";
import Metadata from "metabase-lib/lib/metadata/Metadata";
import { Bookmark, Collection } from "metabase-types/api";
import PinnedChartLoader from "./PinnedChartLoader";
import {
CardActionMenu,
CardRoot,
CardSkeleton,
} from "./PinnedChartCard.styled";
export interface PinnedChartCardProps {
item: Item;
collection: Collection;
metadata: Metadata;
bookmarks?: Bookmark[];
onCopy: (items: Item[]) => void;
onMove: (items: Item[]) => void;
onCreateBookmark?: (id: string, model: string) => void;
onDeleteBookmark?: (id: string, model: string) => void;
}
const PinnedChartCard = ({
item,
collection,
metadata,
bookmarks,
onCopy,
onMove,
onCreateBookmark,
onDeleteBookmark,
}: PinnedChartCardProps): JSX.Element => {
return (
<CardRoot to={item.getUrl()}>
<CardActionMenu
item={item}
collection={collection}
bookmarks={bookmarks}
onCopy={onCopy}
onMove={onMove}
createBookmark={onCreateBookmark}
deleteBookmark={onDeleteBookmark}
/>
<PinnedChartLoader id={item.id} metadata={metadata}>
{({ question, rawSeries, loading, error, errorIcon }) =>
loading ? (
<CardSkeleton
display={question?.display()}
displayName={question?.displayName()}
description={question?.description()}
/>
) : (
<Visualization
rawSeries={rawSeries}
error={error}
errorIcon={errorIcon}
showTitle
isDashboard
/>
)
}
</PinnedChartLoader>
</CardRoot>
);
};
export default PinnedChartCard;
import React, { useRef } from "react";
import Question from "metabase-lib/lib/Question";
import Metadata from "metabase-lib/lib/metadata/Metadata";
import Questions from "metabase/entities/questions";
import QuestionResultLoader from "metabase/containers/QuestionResultLoader";
import {
ERROR_MESSAGE_GENERIC,
ERROR_MESSAGE_PERMISSION,
} from "metabase/visualizations/components/Visualization";
export interface PinnedChartLoaderProps {
id: number;
metadata: Metadata;
children: (props: PinnedChartChildrenProps) => JSX.Element;
}
export interface PinnedChartChildrenProps {
loading: boolean;
question?: Question;
rawSeries?: any;
error?: string;
errorIcon?: string;
}
export interface QuestionLoaderProps {
loading: boolean;
question: any;
}
export interface QuestionResultLoaderProps {
loading: boolean;
error?: QuestionError;
result?: QuestionResult;
results?: any;
rawSeries?: any;
}
export interface QuestionError {
status?: number;
}
export interface QuestionResult {
error?: QuestionError;
}
const PinnedChartLoader = ({
id,
metadata,
children,
}: PinnedChartLoaderProps): JSX.Element => {
const questionRef = useRef<Question>();
return (
<Questions.Loader id={id} loadingAndErrorWrapper={false}>
{({ loading, question: card }: QuestionLoaderProps) => {
if (loading) {
return children({ loading: true });
}
const question = questionRef.current ?? new Question(card, metadata);
questionRef.current = question;
return (
<QuestionResultLoader question={question} collectionPreview>
{({
loading,
error,
result,
results,
rawSeries,
}: QuestionResultLoaderProps) =>
children({
question,
rawSeries,
loading: loading || results == null,
error: getError(error, result),
errorIcon: getErrorIcon(error, result),
})
}
</QuestionResultLoader>
);
}}
</Questions.Loader>
);
};
const getError = (error?: QuestionError, result?: QuestionResult) => {
const errorResponse = error ?? result?.error;
if (!errorResponse) {
return undefined;
} else if (errorResponse.status === 403) {
return ERROR_MESSAGE_PERMISSION;
} else {
return ERROR_MESSAGE_GENERIC;
}
};
const getErrorIcon = (error?: QuestionError, result?: QuestionResult) => {
const errorResponse = error ?? result?.error;
if (!errorResponse) {
return undefined;
} else if (errorResponse.status === 403) {
return "lock";
} else {
return "warning";
}
};
export default PinnedChartLoader;
export { default } from "./PinnedChartCard";
......@@ -6,7 +6,7 @@ import { BookmarksType, Collection } from "metabase-types/api";
import Metadata from "metabase-lib/lib/metadata/Metadata";
import PinnedItemCard from "metabase/collections/components/PinnedItemCard";
import CollectionCardVisualization from "metabase/collections/components/CollectionCardVisualization";
import PinnedChartCard from "metabase/collections/components/PinnedChartCard";
import PinnedItemSortDropTarget from "metabase/collections/components/PinnedItemSortDropTarget";
import { Item, isRootCollection } from "metabase/collections/utils";
import PinDropZone from "metabase/collections/components/PinDropZone";
......@@ -66,15 +66,15 @@ function PinnedItemOverview({
/>
<ItemDragSource item={item} collection={collection}>
<div>
<CollectionCardVisualization
bookmarks={bookmarks}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
<PinnedChartCard
item={item}
collection={collection}
metadata={metadata}
collection={collection}
bookmarks={bookmarks}
onCopy={onCopy}
onMove={onMove}
onCreateBookmark={createBookmark}
onDeleteBookmark={deleteBookmark}
/>
</div>
</ItemDragSource>
......
......@@ -34,9 +34,9 @@ export const IconWrapper = styled.div<IconWrapperProps>`
}
&:hover {
color: ${({ hover }) => hover?.color ?? color("brand")};
color: ${({ hover }) => hover?.color ?? c("brand")};
background-color: ${({ hover }) =>
hover?.backgroundColor ?? color("bg-medium")};
hover?.backgroundColor ?? c("bg-medium")};
}
transition: all 300ms ease-in-out;
......
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import { containerStyles, animationStyles } from "../Skeleton";
export const SkeletonRoot = styled.div`
${containerStyles};
`;
export const SkeletonImage = styled.svg`
${animationStyles};
flex: 1 1 0;
margin-top: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid ${color("bg-medium")};
`;
import React, { HTMLAttributes } from "react";
import SkeletonCaption from "../SkeletonCaption";
import { SkeletonImage, SkeletonRoot } from "./AreaSkeleton.styled";
export interface AreaSkeletonProps extends HTMLAttributes<HTMLDivElement> {
displayName?: string | null;
description?: string | null;
}
const AreaSkeleton = ({
displayName,
description,
...props
}: AreaSkeletonProps): JSX.Element => {
return (
<SkeletonRoot {...props}>
<SkeletonCaption name={displayName} description={description} />
<SkeletonImage
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 371 113"
preserveAspectRatio="none"
>
<path
d="M15.254 97.568 0 107.524V113h371V59.736L345.455 0l-48.453 59.736-15.317-15.432-15.191 15.432-24.227-30.864-43.517 40.82-20.19-15.93-30.847 15.93-30.169-15.93-46.658 46.793-15.254-9.458L33.2 107.524l-17.946-9.956Z"
fill="currentColor"
/>
</SkeletonImage>
</SkeletonRoot>
);
};
export default AreaSkeleton;
export { default } from "./AreaSkeleton";
import styled from "@emotion/styled";
import { containerStyles, animationStyles } from "../Skeleton";
export const SkeletonRoot = styled.div`
${containerStyles};
`;
export const SkeletonImage = styled.svg`
${animationStyles};
flex: 1 1 0;
margin-top: 1rem;
`;
import React, { HTMLAttributes } from "react";
import SkeletonCaption from "../SkeletonCaption";
import { SkeletonImage, SkeletonRoot } from "./BarSkeleton.styled";
export interface BarSkeletonProps extends HTMLAttributes<HTMLDivElement> {
displayName?: string | null;
description?: string | null;
}
const BarSkeleton = ({
displayName,
description,
...props
}: BarSkeletonProps): JSX.Element => {
return (
<SkeletonRoot {...props}>
<SkeletonCaption name={displayName} description={description} />
<SkeletonImage
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 372 117"
preserveAspectRatio="none"
>
<path
fill="currentColor"
d="M0 38.71h23.878V117H0zM28.906 20.503h23.878V117H28.906zM57.81 38.71h23.878V117H57.81zM86.715 0h23.878v117H86.715zM115.62 20.503h23.878V117H115.62zM144.527 25.965h23.878v91.034h-23.878zM173.431 9.579h25.135V117h-25.135zM202.337 20.503h25.135V117h-25.135zM231.244 20.503h25.135V117h-25.135zM261.406 9.579h23.878V117h-23.878zM290.311 20.503h23.878V117h-23.878zM319.216 20.503h23.878V117h-23.878zM348.121 9.579h23.878V117h-23.878z"
/>
</SkeletonImage>
</SkeletonRoot>
);
};
export default BarSkeleton;
export { default } from "./BarSkeleton";
import React from "react";
import { ComponentStory } from "@storybook/react";
import ChartSkeleton from "./ChartSkeleton";
export default {
title: "Visualizations/ChartSkeleton",
component: ChartSkeleton,
};
const Template: ComponentStory<typeof ChartSkeleton> = args => {
return (
<div style={{ padding: 8, height: 250, backgroundColor: "white" }}>
<ChartSkeleton {...args} />
</div>
);
};
export const Default = Template.bind({});
Default.args = {
display: "table",
description: "Description",
};
export const Empty = Template.bind({
display: null,
});
export const Area = Template.bind({});
Area.args = {
display: "area",
displayName: "Area",
};
export const Bar = Template.bind({});
Bar.args = {
display: "bar",
displayName: "Bar",
};
export const Funnel = Template.bind({});
Funnel.args = {
display: "funnel",
displayName: "Funnel",
};
export const Line = Template.bind({});
Line.args = {
display: "line",
displayName: "Line",
};
export const Map = Template.bind({});
Map.args = {
display: "map",
displayName: "Map",
};
export const Pie = Template.bind({});
Pie.args = {
display: "pie",
displayName: "Pie",
};
export const Progress = Template.bind({});
Progress.args = {
display: "progress",
displayName: "Progress",
};
export const Row = Template.bind({});
Row.args = {
display: "row",
displayName: "Row",
};
export const Scalar = Template.bind({});
Scalar.args = {
display: "scalar",
displayName: "Scalar",
};
export const Scatter = Template.bind({});
Scatter.args = {
display: "scatter",
displayName: "Scatter",
};
export const SmartScalar = Template.bind({});
SmartScalar.args = {
display: "smartscalar",
displayName: "SmartScalar",
};
export const Table = Template.bind({});
Table.args = {
display: "table",
displayName: "Table",
};
export const Waterfall = Template.bind({});
Waterfall.args = {
display: "waterfall",
displayName: "Waterfall",
};
import React, { HTMLAttributes } from "react";
import AreaSkeleton from "../AreaSkeleton";
import BarSkeleton from "../BarSkeleton";
import EmptySkeleton from "../EmptySkeleton";
import FunnelSkeleton from "../FunnelSkeleton";
import GaugeSkeleton from "../GaugeSkeleton";
import LineSkeleton from "../LineSkeleton";
import MapSkeleton from "../MapSkeleton";
import PieSkeleton from "../PieSkeleton";
import ProgressSkeleton from "../ProgressSkeleton";
import RowSkeleton from "../RowSkeleton";
import ScalarSkeleton from "../ScalarSkeleton";
import ScatterSkeleton from "../ScatterSkeleton";
import SkeletonCaption from "../SkeletonCaption";
import SmartScalarSkeleton from "../SmartScalarSkeleton";
import TableSkeleton from "../TableSkeleton";
import WaterfallSkeleton from "../WaterfallSkeleton";
export interface ChartSkeletonProps extends HTMLAttributes<HTMLDivElement> {
display?: string | null;
displayName?: string | null;
description?: string | null;
}
const ChartSkeleton = ({
display,
...props
}: ChartSkeletonProps): JSX.Element => {
if (!display) {
return <EmptySkeleton {...props} />;
}
switch (display) {
case "area":
return <AreaSkeleton {...props} />;
case "bar":
return <BarSkeleton {...props} />;
case "funnel":
return <FunnelSkeleton {...props} />;
case "gauge":
return <GaugeSkeleton {...props} />;
case "line":
return <LineSkeleton {...props} />;
case "map":
return <MapSkeleton {...props} />;
case "object":
case "pivot":
case "table":
return <TableSkeleton {...props} />;
case "pie":
return <PieSkeleton {...props} />;
case "progress":
return <ProgressSkeleton {...props} />;
case "row":
return <RowSkeleton {...props} />;
case "scalar":
return <ScalarSkeleton {...props} />;
case "scatter":
return <ScatterSkeleton {...props} />;
case "smartscalar":
return <SmartScalarSkeleton {...props} />;
case "waterfall":
return <WaterfallSkeleton {...props} />;
default:
return <TableSkeleton {...props} />;
}
};
export default Object.assign(ChartSkeleton, {
Title: SkeletonCaption.Title,
Description: SkeletonCaption.Description,
});
import React from "react";
import { render, screen } from "@testing-library/react";
import ChartSkeleton from "./ChartSkeleton";
describe("ChartSkeleton", () => {
it("should render area", () => {
render(<ChartSkeleton display="area" displayName="Area" />);
expect(screen.getByText("Area")).toBeInTheDocument();
});
it("should render bar", () => {
render(<ChartSkeleton display="bar" displayName="Bar" />);
expect(screen.getByText("Bar")).toBeInTheDocument();
});
it("should render funnel", () => {
render(<ChartSkeleton display="funnel" displayName="Funnel" />);
expect(screen.getByText("Funnel")).toBeInTheDocument();
});
it("should render gauge", () => {
render(<ChartSkeleton display="gauge" displayName="Gauge" />);
expect(screen.getByText("Gauge")).toBeInTheDocument();
});
it("should render line", () => {
render(<ChartSkeleton display="line" displayName="Line" />);
expect(screen.getByText("Line")).toBeInTheDocument();
});
it("should render map", () => {
render(<ChartSkeleton display="map" displayName="Map" />);
expect(screen.getByText("Map")).toBeInTheDocument();
});
it("should render table", () => {
render(<ChartSkeleton display="table" displayName="Table" />);
expect(screen.getByText("Table")).toBeInTheDocument();
});
it("should render pie", () => {
render(<ChartSkeleton display="pie" displayName="Pie" />);
expect(screen.getByText("Pie")).toBeInTheDocument();
});
it("should render progress", () => {
render(<ChartSkeleton display="progress" displayName="Progress" />);
expect(screen.getByText("Progress")).toBeInTheDocument();
});
it("should render row", () => {
render(<ChartSkeleton display="row" displayName="Row" />);
expect(screen.getByText("Row")).toBeInTheDocument();
});
it("should render scalar", () => {
render(<ChartSkeleton display="scalar" displayName="Scalar" />);
expect(screen.getByText("Scalar")).toBeInTheDocument();
});
it("should render scatter", () => {
render(<ChartSkeleton display="scatter" displayName="Scatter" />);
expect(screen.getByText("Scatter")).toBeInTheDocument();
});
it("should render smartscalar", () => {
render(<ChartSkeleton display="smartscalar" displayName="Trend" />);
expect(screen.getByText("Trend")).toBeInTheDocument();
});
it("should render waterfall", () => {
render(<ChartSkeleton display="waterfall" displayName="Waterfall" />);
expect(screen.getByText("Waterfall")).toBeInTheDocument();
});
});
export { default } from "./ChartSkeleton";
import styled from "@emotion/styled";
import { containerStyles } from "../Skeleton";
export const SkeletonRoot = styled.div`
${containerStyles};
`;
import React, { HTMLAttributes } from "react";
import SkeletonCaption from "../SkeletonCaption";
import { SkeletonRoot } from "./EmptySkeleton.styled";
export interface EmptySkeletonProps extends HTMLAttributes<HTMLDivElement> {
displayName?: string | null;
description?: string | null;
}
const EmptySkeleton = ({
displayName,
description,
...props
}: EmptySkeletonProps): JSX.Element => {
return (
<SkeletonRoot {...props}>
<SkeletonCaption name={displayName} description={description} />
</SkeletonRoot>
);
};
export default EmptySkeleton;
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