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

Add timeline empty states (#20585)

parent a9aee83e
Branches
Tags
No related merge requests found
Showing
with 356 additions and 27 deletions
import { TimelineEvent, Timeline } from "../timeline";
import { createMockUser } from "./user";
export const createMockTimeline = (opts: Partial<Timeline>): Timeline => ({
export const createMockTimeline = (opts?: Partial<Timeline>): Timeline => ({
id: 1,
collection_id: 1,
name: "Events",
......
import { t } from "ttag";
import { updateIn } from "icepick";
import _ from "underscore";
import { TimelineSchema } from "metabase/schema";
import { TimelineApi } from "metabase/services";
import { createEntity, undo } from "metabase/lib/entities";
......@@ -56,10 +57,16 @@ const Timelines = createEntity({
reducer: (state = {}, action) => {
if (action.type === TimelineEvents.actionTypes.CREATE) {
const event = TimelineEvents.HACK_getObjectFromAction(action);
return updateIn(state, [event.timeline_id, "events"], (events = []) => [
...events,
event.id,
]);
return updateIn(state, [event.timeline_id, "events"], (events = []) => {
return [...events, event.id];
});
}
if (action.type === TimelineEvents.actionTypes.DELETE) {
const eventId = action.payload.result;
return _.mapObject(state, timeline =>
updateIn(timeline, ["events"], events => _.without(events, eventId)),
);
}
return state;
......
......@@ -275,6 +275,8 @@ export const ICON_PATHS: Record<string, any> = {
"M17.077 7.148A10.285 10.285 0 0 1 21.818 6C27.441 6 32 10.477 32 16s-4.559 10-10.182 10c-2.038 0-3.936-.588-5.528-1.6l.454-.31c.283-.194.555-.4.816-.617a8.842 8.842 0 0 0 4.258 1.083c4.832 0 8.738-3.836 8.738-8.556 0-4.72-3.906-8.556-8.738-8.556a8.865 8.865 0 0 0-3.546.733 10.65 10.65 0 0 0-1.195-1.03zM15.71 24.399A10.268 10.268 0 0 1 10.182 26C4.559 26 0 21.523 0 16S4.559 6 10.182 6c1.712 0 3.325.415 4.74 1.148-2.643 1.957-4.241 5.022-4.241 8.352 0 3.468 1.733 6.649 4.575 8.59l.454.31zM16 8c2.418 1.651 4 4.395 4 7.5 0 3.105-1.582 5.849-4 7.5-2.418-1.651-4-4.395-4-7.5 0-3.105 1.582-5.849 4-7.5z",
json:
"M28 10.105v18.728A3.166 3.166 0 0 1 24.834 32H6.166A3.163 3.163 0 0 1 3 28.844V3.156A3.163 3.163 0 0 1 6.16 0h13.553V10.105H28zm-.215-1.684h-6.4V.311l6.4 8.11zM10.894 19.233v-.218c1.162-.13 1.79-.718 1.79-1.703v-1.394c0-.964.322-1.333 1.19-1.333h.3v-1.45h-.505c-2.03 0-2.885.766-2.885 2.55v1.094c0 1.005-.451 1.388-1.613 1.395v1.9c1.169.007 1.613.39 1.613 1.395v1.066c0 1.805.862 2.584 2.885 2.584h.506v-1.45h-.301c-.861 0-1.19-.361-1.19-1.332v-1.401c0-.992-.628-1.573-1.79-1.703zm8.184-.212v.22c-1.162.122-1.791.71-1.791 1.701v1.395c0 .964-.321 1.333-1.19 1.333h-.3v1.45h.505c2.03 0 2.892-.766 2.892-2.55v-1.094c0-1.012.444-1.388 1.607-1.395v-1.9c-1.17-.014-1.607-.39-1.607-1.395v-1.073c0-1.798-.861-2.577-2.892-2.577h-.505v1.449h.3c.862 0 1.19.362 1.19 1.326v1.408c0 .985.629 1.573 1.79 1.702z",
kebab:
"M 19.3074 28.3886 a 3.6114 3.6114 90 1 0 -7.2251 0 a 3.6114 3.6114 90 0 0 7.2251 0 Z M 19.3074 3.6114 a 3.6114 3.6114 90 1 0 -7.2251 0 A 3.6114 3.6114 90 0 0 19.3074 3.6114 Z M 15.696 12.3886 a 3.6114 3.6114 90 1 1 0 7.2229 a 3.6114 3.6114 90 0 1 0 -7.2229 Z",
key: {
path:
"M11.5035746,7.9975248 C10.8617389,5.26208051 13.0105798,1.44695394 16.9897081,1.44695394 C20.919315,1.44695394 23.1811258,5.37076315 22.2565255,8.42469226 C21.3223229,7.86427598 20.2283376,7.54198814 19.0589133,7.54198814 C17.3567818,7.54198814 15.8144729,8.22477622 14.6920713,9.33083544 C14.4930673,9.31165867 14.2913185,9.30184676 14.087273,9.30184676 C10.654935,9.30184676 7.87247532,12.0782325 7.87247532,15.5030779 C7.87247532,17.1058665 8.48187104,18.5666337 9.48208198,19.6672763 L8.98356958,20.658345 L9.19925633,22.7713505 L7.5350473,23.4587525 C7.37507672,23.5248284 7.30219953,23.707739 7.37031308,23.8681037 L7.95501877,25.2447188 L6.28291833,25.7863476 C6.10329817,25.8445303 6.01548404,26.0233452 6.06755757,26.1919683 L6.54426059,27.7356153 L5.02460911,28.2609385 C4.86686602,28.3154681 4.7743984,28.501653 4.83652351,28.6704172 L6.04508836,31.95351 C6.10987939,32.1295162 6.29662279,32.2151174 6.46814592,32.160881 L9.48965349,31.2054672 C9.66187554,31.1510098 9.86840241,30.9790422 9.95250524,30.8208731 L14.8228902,21.6613229 C15.8820565,21.5366928 16.8596786,21.1462953 17.6869404,20.558796 C17.5652123,20.567429 17.4424042,20.5718139 17.318643,20.5718139 C14.2753735,20.5718139 11.8083161,17.9204625 11.8083161,14.6498548 C11.8083161,12.518229 12.8562751,10.6496514 14.428709,9.60671162 C13.4433608,10.7041074 12.8441157,12.1538355 12.8441157,13.7432193 C12.8441157,16.9974306 15.3562245,19.6661883 18.5509945,19.9240384 L19.1273026,21.5699573 L20.7971002,22.8826221 L20.1355191,24.5572635 C20.0719252,24.7182369 20.1528753,24.8977207 20.3155476,24.9601226 L21.7119724,25.4957977 L20.9400489,27.0748531 C20.8571275,27.2444782 20.9247553,27.4318616 21.082226,27.5115385 L22.5237784,28.2409344 L21.8460256,29.6990003 C21.7756734,29.8503507 21.8453702,30.0462011 22.0099247,30.1187455 L25.2111237,31.5300046 C25.3827397,31.6056621 25.5740388,31.5307937 25.6541745,31.3697345 L27.0658228,28.5325576 C27.1462849,28.3708422 27.1660474,28.1028205 27.1106928,27.9324485 L23.8023823,17.7500271 C24.7201964,16.6692906 25.273711,15.270754 25.273711,13.7432193 C25.273711,12.0364592 24.582689,10.4907436 23.4645818,9.36943333 C25.0880384,5.38579616 22.187534,0 16.9897081,0 C12.1196563,0 9.42801686,4.46934651 10.0266074,7.9975248 L11.5035746,7.9975248 Z M19.0589133,14.7767578 C20.203026,14.7767578 21.1305126,13.8512959 21.1305126,12.7096808 C21.1305126,11.5680656 20.203026,10.6426037 19.0589133,10.6426037 C17.9148007,10.6426037 16.9873141,11.5680656 16.9873141,12.7096808 C16.9873141,13.8512959 17.9148007,14.7767578 19.0589133,14.7767578 Z",
......
......@@ -330,6 +330,11 @@ export function editEventInCollection(event, timeline, collection) {
return `${timelineUrl}/events/${event.id}/edit`;
}
export function deleteEventInCollection(event, timeline, collection) {
const timelineUrl = timelineInCollection(timeline, collection);
return `${timelineUrl}/events/${event.id}/delete`;
}
export function extractEntityId(slug) {
const id = parseInt(slug, 10);
return Number.isSafeInteger(id) ? id : undefined;
......
import styled from "@emotion/styled";
export const ModalBody = styled.div`
padding: 2rem;
`;
export const ModalFooter = styled.div`
display: flex;
gap: 1rem;
justify-content: flex-end;
`;
import React, { useCallback } from "react";
import { t } from "ttag";
import Button from "metabase/core/components/Button";
import { Collection, Timeline, TimelineEvent } from "metabase-types/api";
import ModalHeader from "../ModalHeader";
import { ModalBody, ModalFooter } from "./DeleteEventModal.styled";
export interface DeleteEventModalProps {
event: TimelineEvent;
timeline: Timeline;
collection: Collection;
onSubmit: (
event: TimelineEvent,
timeline: Timeline,
collection: Collection,
) => void;
onCancel: () => void;
onClose?: () => void;
}
const DeleteEventModal = ({
event,
timeline,
collection,
onSubmit,
onCancel,
onClose,
}: DeleteEventModalProps): JSX.Element => {
const handleSubmit = useCallback(async () => {
await onSubmit(event, timeline, collection);
}, [event, timeline, collection, onSubmit]);
return (
<div>
<ModalHeader title={t`Delete ${event?.name}?`} onClose={onClose} />
<ModalBody>
<ModalFooter>
<Button onClick={onCancel}>{t`Cancel`}</Button>
<Button danger onClick={handleSubmit}>{t`Delete`}</Button>
</ModalFooter>
</ModalBody>
</div>
);
};
export default DeleteEventModal;
export { default } from "./DeleteEventModal";
......@@ -89,9 +89,13 @@ const getMenuItems = (
} else {
return [
{
title: t`Restore event`,
title: t`Unarchive event`,
action: () => onUnarchive?.(event),
},
{
title: t`Delete event`,
link: Urls.deleteEventInCollection(event, timeline, collection),
},
];
}
};
......
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import Icon from "metabase/components/Icon";
export const EmptyStateRoot = styled.div`
display: flex;
flex-direction: column;
align-items: center;
padding: 4rem;
`;
export const EmptyStateIcon = styled(Icon)`
color: ${color("text-medium")};
width: 5rem;
height: 5rem;
margin-bottom: 2.5rem;
`;
export const EmptyStateText = styled.div`
color: ${color("text-medium")};
font-size: 1.25rem;
line-height: 1.5rem;
font-weight: bold;
`;
import React from "react";
import { t } from "ttag";
import {
EmptyStateIcon,
EmptyStateRoot,
EmptyStateText,
} from "./EventEmptyState.styled";
const EventEmptyState = (): JSX.Element => {
return (
<EmptyStateRoot>
<EmptyStateIcon name="star" />
<EmptyStateText>{t`No events found`}</EmptyStateText>
</EmptyStateRoot>
);
};
export default EventEmptyState;
export { default } from "./EventEmptyState";
......@@ -26,7 +26,7 @@ const NewEventModal = ({
onClose,
}: NewEventModalProps): JSX.Element => {
const initialValues = useMemo(() => {
return { timeline_id: timeline?.id };
return { timeline_id: timeline?.id, icon: timeline?.icon };
}, [timeline]);
const handleSubmit = useCallback(
......
......@@ -9,11 +9,11 @@ export const ModalRoot = styled.div`
export const ModalToolbar = styled.div`
display: flex;
justify-content: flex-end;
padding: 1rem 2rem;
padding: 1rem 2rem 0;
`;
export const ModalBody = styled.div`
flex: 1 1 auto;
padding: 0 2rem 2rem;
padding: 2rem;
overflow-y: auto;
`;
......@@ -6,11 +6,17 @@ import { parseTimestamp } from "metabase/lib/time";
import Link from "metabase/core/components/Link";
import EntityMenu from "metabase/components/EntityMenu";
import { Collection, Timeline, TimelineEvent } from "metabase-types/api";
import EventEmptyState from "../EventEmptyState";
import EventList from "../EventList";
import ModalHeader from "../ModalHeader";
import { ModalBody, ModalRoot, ModalToolbar } from "./TimelineModal.styled";
import TimelineEmptyState from "../TimelineEmptyState";
import {
ModalBody,
ModalRoot,
ModalToolbar,
} from "./TimelineDetailsModal.styled";
export interface TimelineModalProps {
export interface TimelineDetailsModalProps {
timeline: Timeline;
collection: Collection;
archived?: boolean;
......@@ -19,33 +25,36 @@ export interface TimelineModalProps {
onClose?: () => void;
}
const TimelineModal = ({
const TimelineDetailsModal = ({
timeline,
collection,
archived = false,
onArchive,
onUnarchive,
onClose,
}: TimelineModalProps): JSX.Element => {
}: TimelineDetailsModalProps): JSX.Element => {
const title = archived ? t`Archived events` : timeline.name;
const events = getEvents(timeline.events, archived);
const hasEvents = events.length > 0;
const menuItems = getMenuItems(timeline, collection);
return (
<ModalRoot>
<ModalHeader title={title} onClose={onClose}>
{!archived && <EntityMenu items={menuItems} triggerIcon="ellipsis" />}
{!archived && <EntityMenu items={menuItems} triggerIcon="kebab" />}
</ModalHeader>
<ModalToolbar>
{!archived && (
<Link
className="Button"
to={Urls.newEventInCollection(timeline, collection)}
>{t`Add an event`}</Link>
)}
</ModalToolbar>
{events.length > 0 && (
<ModalBody>
{hasEvents && (
<ModalToolbar>
{!archived && (
<Link
className="Button"
to={Urls.newEventInCollection(timeline, collection)}
>{t`Add an event`}</Link>
)}
</ModalToolbar>
)}
<ModalBody>
{hasEvents ? (
<EventList
events={events}
timeline={timeline}
......@@ -53,8 +62,12 @@ const TimelineModal = ({
onArchive={onArchive}
onUnarchive={onUnarchive}
/>
</ModalBody>
)}
) : archived ? (
<EventEmptyState />
) : (
<TimelineEmptyState timeline={timeline} collection={collection} />
)}
</ModalBody>
</ModalRoot>
);
};
......@@ -84,4 +97,4 @@ const getMenuItems = (timeline: Timeline, collection: Collection) => {
];
};
export default TimelineModal;
export default TimelineDetailsModal;
export { default } from "./TimelineDetailsModal";
import styled from "@emotion/styled";
import { alpha, color } from "metabase/lib/colors";
import Icon from "metabase/components/Icon";
import DateTime from "metabase/components/DateTime";
export const EmptyStateRoot = styled.div`
display: flex;
flex-direction: column;
align-items: center;
`;
export const EmptyStateBody = styled.div`
display: flex;
flex-direction: column;
align-items: center;
max-width: 22.5rem;
`;
export const EmptyStateChart = styled.div`
color: ${color("brand")};
margin-bottom: -1rem;
`;
export const EmptyStateTooltip = styled.div`
display: flex;
align-items: center;
min-width: 16.75rem;
margin-bottom: 1rem;
padding: 1rem;
border-radius: 0.5rem;
background-color: ${color("text-dark")};
`;
export const EmptyStateTooltipIcon = styled(Icon)`
flex: 0 0 auto;
color: ${color("white")};
width: 1rem;
height: 1rem;
`;
export const EmptyStateTooltipBody = styled.div`
flex: 1 1 auto;
margin-left: 1rem;
`;
export const EmptyStateTooltipTitle = styled.div`
color: ${color("white")};
font-weight: bold;
margin-bottom: 0.25rem;
`;
export const EmptyStateTooltipDate = styled(DateTime)`
display: block;
color: ${color("white")};
`;
export const EmptyStateThread = styled.div`
display: flex;
align-items: center;
margin-bottom: 1.5rem;
`;
export const EmptyStateThreadLine = styled.div`
margin: 0 0.5rem;
width: 11.75rem;
height: 1px;
background-color: ${alpha("brand", 0.2)};
`;
export const EmptyStateThreadIcon = styled(Icon)`
color: ${color("white")};
width: 1rem;
height: 1rem;
`;
export const EmptyStateThreadIconContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 2rem;
height: 2rem;
border-radius: 1rem;
background-color: ${color("brand")};
`;
export const EmptyStateMessage = styled.div`
color: ${color("text-dark")};
line-height: 1.5rem;
margin-bottom: 2rem;
text-align: center;
`;
import React from "react";
import { t } from "ttag";
import moment from "moment";
import Settings from "metabase/lib/settings";
import * as Urls from "metabase/lib/urls";
import { formatDateTimeWithUnit } from "metabase/lib/formatting";
import Link from "metabase/core/components/Link";
import { Collection, Timeline } from "metabase-types/api";
import {
EmptyStateBody,
EmptyStateChart,
EmptyStateMessage,
EmptyStateRoot,
EmptyStateThread,
EmptyStateThreadIcon,
EmptyStateThreadIconContainer,
EmptyStateThreadLine,
EmptyStateTooltip,
EmptyStateTooltipBody,
EmptyStateTooltipDate,
EmptyStateTooltipIcon,
EmptyStateTooltipTitle,
} from "./TimelineEmptyState.styled";
export interface TimelineEmptyStateProps {
timeline?: Timeline;
collection: Collection;
}
const TimelineEmptyState = ({
timeline,
collection,
}: TimelineEmptyStateProps): JSX.Element => {
const date = moment();
const link = timeline
? Urls.newEventInCollection(timeline, collection)
: Urls.newEventAndTimelineInCollection(collection);
return (
<EmptyStateRoot>
<EmptyStateBody>
<EmptyStateChart>
<svg width="231" height="128" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M230.142 1.766 118.697 95.283a5 5 0 0 1-8.877 4.461L.75 127.97l-.501-1.937 108.827-28.16a5 5 0 0 1 8.557-4.306L228.857.233l1.285 1.532ZM114 99.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
</EmptyStateChart>
<EmptyStateTooltip>
<EmptyStateTooltipIcon name="mail" />
<EmptyStateTooltipBody>
<EmptyStateTooltipTitle>{t`Launch of v2.0`}</EmptyStateTooltipTitle>
<EmptyStateTooltipDate value={date} unit="day" />
</EmptyStateTooltipBody>
</EmptyStateTooltip>
<EmptyStateThread>
<EmptyStateThreadLine />
<EmptyStateThreadIconContainer>
<EmptyStateThreadIcon name="balloons" />
</EmptyStateThreadIconContainer>
<EmptyStateThreadLine />
</EmptyStateThread>
<EmptyStateMessage>
{t`Add events to Metabase to open important milestones, launches, or anything else, right alongside your data.`}
</EmptyStateMessage>
<Link className="Button Button--primary" to={link}>
{t`Add an event`}
</Link>
</EmptyStateBody>
</EmptyStateRoot>
);
};
export default TimelineEmptyState;
export { default } from "./TimelineEmptyState";
import React from "react";
import { Timeline } from "metabase-types/api";
import TimelineDetailsModal from "../../containers/TimelineDetailsModal";
import TimelineListModal from "../../containers/TimelineListModal";
import { ModalParams } from "../../types";
export interface TimelineEntryModalProps {
timelines: Timeline[];
params: ModalParams;
onClose?: () => void;
}
const TimelineEntryModal = ({
timelines,
params,
onClose,
}: TimelineEntryModalProps): JSX.Element => {
if (timelines.length === 1) {
const newParams = { ...params, timelineId: timelines[0].id };
return <TimelineDetailsModal params={newParams} onClose={onClose} />;
} else {
return <TimelineListModal params={params} onClose={onClose} />;
}
};
export default TimelineEntryModal;
export { default } from "./TimelineEntryModal";
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment