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

Move events between timelines (#21557)

parent a6af0456
No related branches found
No related tags found
No related merge requests found
Showing
with 111 additions and 205 deletions
......@@ -26,3 +26,5 @@ export interface TimelineEvent {
creator: User;
created_at: string;
}
export type TimelineEventSource = "question" | "collections" | "api";
......@@ -11,12 +11,21 @@ const TimelineEvents = createEntity({
forms,
objectActions: {
setArchived: ({ id }, archived, opts) =>
TimelineEvents.actions.update(
setTimeline: ({ id }, timeline, opts) => {
return TimelineEvents.actions.update(
{ id },
{ timeline_id: timeline.id },
undo(opts, t`event`, t`moved`),
);
},
setArchived: ({ id }, archived, opts) => {
return TimelineEvents.actions.update(
{ id },
{ archived },
undo(opts, t`event`, archived ? t`archived` : t`unarchived`),
),
);
},
},
});
......
......@@ -45,16 +45,39 @@ 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 = []) => {
return [...events, event.id];
return updateIn(state, [event.timeline_id, "events"], (eventIds = []) => {
return [...eventIds, event.id];
});
}
if (action.type === TimelineEvents.actionTypes.UPDATE) {
const event = TimelineEvents.HACK_getObjectFromAction(action);
return _.mapObject(state, timeline => {
const hasEvent = timeline.events?.includes(event.id);
const hasTimeline = event.timeline_id === timeline.id;
return updateIn(timeline, ["events"], (eventIds = []) => {
if (hasEvent && !hasTimeline) {
return _.without(eventIds, event.id);
} else if (!hasEvent && hasTimeline) {
return [...eventIds, event.id];
} else {
return eventIds;
}
});
});
}
if (action.type === TimelineEvents.actionTypes.DELETE) {
const eventId = action.payload.result;
return _.mapObject(state, timeline =>
updateIn(timeline, ["events"], events => _.without(events, eventId)),
);
return _.mapObject(state, timeline => {
return updateIn(timeline, ["events"], (eventIds = []) => {
return _.without(eventIds, eventId);
});
});
}
return state;
......
import _ from "underscore";
import { t } from "ttag";
import { Collection, Timeline } from "metabase-types/api";
import { canonicalCollectionId } from "metabase/collections/utils";
......@@ -37,3 +38,19 @@ export const getDefaultTimelineName = (collection: Collection) => {
export const getDefaultTimelineIcon = () => {
return "star";
};
export const getSortedTimelines = (
timelines: Timeline[],
collection?: Collection,
) => {
return _.chain(timelines)
.sortBy(timeline => getTimelineName(timeline).toLowerCase())
.sortBy(timeline => timeline.collection?.personal_owner_id != null) // personal collections last
.sortBy(timeline => !timeline.default) // default timelines first
.sortBy(timeline => timeline.collection?.id !== collection?.id) // timelines within the collection first
.value();
};
export const getEventCount = ({ events = [], archived }: Timeline) => {
return events.filter(e => e.archived === archived).length;
};
......@@ -305,41 +305,46 @@ export function timelinesArchiveInCollection(collection) {
return `${timelinesInCollection(collection)}/archive`;
}
export function timelineInCollection(timeline, collection) {
return `${timelinesInCollection(collection)}/${timeline.id}`;
export function timelineInCollection(timeline) {
return `${timelinesInCollection(timeline.collection)}/${timeline.id}`;
}
export function newTimelineInCollection(collection) {
return `${timelinesInCollection(collection)}/new`;
}
export function editTimelineInCollection(timeline, collection) {
return `${timelineInCollection(timeline, collection)}/edit`;
export function editTimelineInCollection(timeline) {
return `${timelineInCollection(timeline)}/edit`;
}
export function timelineArchiveInCollection(timeline, collection) {
return `${timelineInCollection(timeline, collection)}/archive`;
export function timelineArchiveInCollection(timeline) {
return `${timelineInCollection(timeline)}/archive`;
}
export function deleteTimelineInCollection(timeline, collection) {
return `${timelineInCollection(timeline, collection)}/delete`;
export function deleteTimelineInCollection(timeline) {
return `${timelineInCollection(timeline)}/delete`;
}
export function newEventInCollection(timeline, collection) {
return `${timelineInCollection(timeline, collection)}/events/new`;
export function newEventInCollection(timeline) {
return `${timelineInCollection(timeline)}/events/new`;
}
export function newEventAndTimelineInCollection(collection) {
return `${timelinesInCollection(collection)}/new/events/new`;
}
export function editEventInCollection(event, timeline, collection) {
const timelineUrl = timelineInCollection(timeline, collection);
export function editEventInCollection(event, timeline) {
const timelineUrl = timelineInCollection(timeline);
return `${timelineUrl}/events/${event.id}/edit`;
}
export function deleteEventInCollection(event, timeline, collection) {
const timelineUrl = timelineInCollection(timeline, collection);
export function moveEventInCollection(event, timeline) {
const timelineUrl = timelineInCollection(timeline);
return `${timelineUrl}/events/${event.id}/move`;
}
export function deleteEventInCollection(event, timeline) {
const timelineUrl = timelineInCollection(timeline);
return `${timelineUrl}/events/${event.id}/delete`;
}
......
......@@ -24,6 +24,7 @@ import NewDatasetModal from "metabase/query_builder/components/NewDatasetModal";
import EntityCopyModal from "metabase/entities/containers/EntityCopyModal";
import NewEventModal from "metabase/timelines/questions/containers/NewEventModal";
import EditEventModal from "metabase/timelines/questions/containers/EditEventModal";
import MoveEventModal from "metabase/timelines/questions/containers/MoveEventModal";
export default class QueryModals extends React.Component {
showAlertsAfterQuestionSaved = () => {
......@@ -233,6 +234,14 @@ export default class QueryModals extends React.Component {
<Modal onClose={onCloseModal}>
<EditEventModal eventId={modalContext} onClose={onCloseModal} />
</Modal>
) : modal === MODAL_TYPES.MOVE_EVENT ? (
<Modal onClose={onCloseModal}>
<MoveEventModal
eventId={modalContext}
collectionId={question.collectionId()}
onClose={onCloseModal}
/>
</Modal>
) : null;
}
}
......@@ -45,6 +45,13 @@ const TimelineSidebar = ({
[onOpenModal],
);
const handleMoveEvent = useCallback(
(event: TimelineEvent) => {
onOpenModal?.(MODAL_TYPES.MOVE_EVENT, event.id);
},
[onOpenModal],
);
const handleToggleEvent = useCallback(
(event: TimelineEvent, isSelected: boolean) => {
if (isSelected) {
......@@ -76,6 +83,7 @@ const TimelineSidebar = ({
selectedEventIds={selectedTimelineEventIds}
onNewEvent={handleNewEvent}
onEditEvent={handleEditEvent}
onMoveEvent={handleMoveEvent}
onToggleEvent={handleToggleEvent}
onToggleTimeline={handleToggleTimeline}
/>
......
......@@ -16,6 +16,7 @@ export const MODAL_TYPES = {
CAN_NOT_CREATE_MODEL: "can-not-create-model",
NEW_EVENT: "new-event",
EDIT_EVENT: "edit-event",
MOVE_EVENT: "move-event",
};
export const SIDEBAR_SIZES = {
......
......@@ -27,7 +27,7 @@ import Timelines from "metabase/entities/timelines";
import { getMetadata } from "metabase/selectors/metadata";
import { getAlerts } from "metabase/alert/selectors";
import { parseTimestamp } from "metabase/lib/time";
import { getTimelineName } from "metabase/lib/timelines";
import { getSortedTimelines } from "metabase/lib/timelines";
import {
getXValues,
isTimeseries,
......@@ -666,18 +666,16 @@ export const getFetchedTimelines = createSelector([getEntities], entities => {
export const getTransformedTimelines = createSelector(
[getFetchedTimelines],
timelines => {
return _.chain(timelines)
.map(timeline =>
return getSortedTimelines(
timelines.map(timeline =>
updateIn(timeline, ["events"], (events = []) =>
_.chain(events)
.map(event => updateIn(event, ["timestamp"], parseTimestamp))
.filter(event => !event.archived)
.value(),
),
)
.sortBy(getTimelineName)
.sortBy(timeline => timeline.collection?.personal_owner_id != null) // personal collections last
.value();
),
);
},
);
......
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 styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import Button from "metabase/core/components/Button/Button";
export const ModalBody = styled.div`
padding: 2rem;
`;
export const ModalDangerButton = styled(Button)`
color: ${color("danger")};
padding-left: 0;
padding-right: 0;
&:hover {
color: ${color("danger")};
background-color: transparent;
}
`;
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import Button from "metabase/core/components/Button";
export const ModalBody = styled.div`
padding: 2rem;
`;
export const ModalDangerButton = styled(Button)`
color: ${color("danger")};
padding-left: 0;
padding-right: 0;
&:hover {
color: ${color("danger")};
background-color: transparent;
}
`;
......@@ -6,7 +6,7 @@ import { parseTimestamp } from "metabase/lib/time";
import { formatDateTimeWithUnit } from "metabase/lib/formatting";
import Link from "metabase/core/components/Link";
import EntityMenu from "metabase/components/EntityMenu";
import { Collection, Timeline, TimelineEvent } from "metabase-types/api";
import { Timeline, TimelineEvent } from "metabase-types/api";
import {
CardAside,
CardBody,
......@@ -24,7 +24,6 @@ import {
export interface EventCardProps {
event: TimelineEvent;
timeline: Timeline;
collection: Collection;
onArchive?: (event: TimelineEvent) => void;
onUnarchive?: (event: TimelineEvent) => void;
}
......@@ -32,21 +31,14 @@ export interface EventCardProps {
const EventCard = ({
event,
timeline,
collection,
onArchive,
onUnarchive,
}: EventCardProps): JSX.Element => {
const menuItems = getMenuItems(
event,
timeline,
collection,
onArchive,
onUnarchive,
);
const menuItems = getMenuItems(event, timeline, onArchive, onUnarchive);
const dateMessage = getDateMessage(event);
const creatorMessage = getCreatorMessage(event);
const canEdit = timeline.collection?.can_write && !event.archived;
const editLink = Urls.editEventInCollection(event, timeline, collection);
const editLink = Urls.editEventInCollection(event, timeline);
return (
<CardRoot>
......@@ -82,7 +74,6 @@ const EventCard = ({
const getMenuItems = (
event: TimelineEvent,
timeline: Timeline,
collection: Collection,
onArchive?: (event: TimelineEvent) => void,
onUnarchive?: (event: TimelineEvent) => void,
) => {
......@@ -94,7 +85,11 @@ const getMenuItems = (
return [
{
title: t`Edit event`,
link: Urls.editEventInCollection(event, timeline, collection),
link: Urls.editEventInCollection(event, timeline),
},
{
title: t`Move event`,
link: Urls.moveEventInCollection(event, timeline),
},
{
title: t`Archive event`,
......@@ -109,7 +104,7 @@ const getMenuItems = (
},
{
title: t`Delete event`,
link: Urls.deleteEventInCollection(event, timeline, collection),
link: Urls.deleteEventInCollection(event, timeline),
},
];
}
......
......@@ -106,7 +106,6 @@ describe("EventCard", () => {
export const getProps = (opts?: Partial<EventCardProps>): EventCardProps => ({
event: createMockTimelineEvent(),
timeline: createMockTimeline(),
collection: createMockCollection(),
onArchive: jest.fn(),
onUnarchive: jest.fn(),
...opts,
......
import React, { memo } from "react";
import { t } from "ttag";
import { Collection, Timeline, TimelineEvent } from "metabase-types/api";
import { Timeline, TimelineEvent } from "metabase-types/api";
import EventCard from "../EventCard";
import {
ListFooter,
......@@ -15,7 +15,6 @@ import {
export interface EventListProps {
events: TimelineEvent[];
timeline: Timeline;
collection: Collection;
onArchive?: (event: TimelineEvent) => void;
onUnarchive?: (event: TimelineEvent) => void;
}
......@@ -23,7 +22,6 @@ export interface EventListProps {
const EventList = ({
events,
timeline,
collection,
onArchive,
onUnarchive,
}: EventListProps): JSX.Element => {
......@@ -34,7 +32,6 @@ const EventList = ({
key={event.id}
event={event}
timeline={timeline}
collection={collection}
onArchive={onArchive}
onUnarchive={onUnarchive}
/>
......
import React from "react";
import { render, screen } from "@testing-library/react";
import {
createMockCollection,
createMockTimeline,
createMockTimelineEvent,
} from "metabase-types/api/mocks";
......@@ -26,6 +25,5 @@ describe("EventList", () => {
const getProps = (opts?: Partial<EventListProps>): EventListProps => ({
events: [],
timeline: createMockTimeline(),
collection: createMockCollection(),
...opts,
});
import styled from "@emotion/styled";
export const ModalBody = styled.div`
padding: 2rem;
`;
import React, { useCallback, useMemo } from "react";
import { t } from "ttag";
import { getDefaultTimezone } from "metabase/lib/time";
import { getDefaultTimelineIcon } from "metabase/lib/timelines";
import Form from "metabase/containers/Form";
import forms from "metabase/entities/timeline-events/forms";
import ModalHeader from "metabase/timelines/common/components/ModalHeader";
import { Collection, Timeline, TimelineEvent } from "metabase-types/api";
import { ModalBody } from "./NewEventModal.styled";
export interface NewEventModalProps {
timeline?: Timeline;
collection: Collection;
onSubmit: (
values: Partial<TimelineEvent>,
collection: Collection,
timeline?: Timeline,
) => void;
onCancel: (location: string) => void;
onClose?: () => void;
}
const NewEventModal = ({
timeline,
collection,
onSubmit,
onCancel,
onClose,
}: NewEventModalProps): JSX.Element => {
const form = useMemo(() => forms.details(), []);
const initialValues = useMemo(
() => ({
timeline_id: timeline?.id,
icon: timeline ? timeline.icon : getDefaultTimelineIcon(),
timezone: getDefaultTimezone(),
source: "collections",
time_matters: false,
}),
[timeline],
);
const handleSubmit = useCallback(
async (values: Partial<TimelineEvent>) => {
await onSubmit(values, collection, timeline);
},
[timeline, collection, onSubmit],
);
return (
<div>
<ModalHeader title={t`New event`} onClose={onClose} />
<ModalBody>
<Form
form={form}
initialValues={initialValues}
isModal={true}
onSubmit={handleSubmit}
onClose={onCancel}
/>
</ModalBody>
</div>
);
};
export default NewEventModal;
import React, { FormHTMLAttributes } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMockCollection } from "metabase-types/api/mocks";
import NewEventModal, { NewEventModalProps } from "./NewEventModal";
const FormMock = (props: FormHTMLAttributes<HTMLFormElement>) => (
<form {...props}>
<button>Create</button>
</form>
);
jest.mock("metabase/containers/Form", () => FormMock);
describe("NewEventModal", () => {
it("should submit modal", () => {
const props = getProps();
render(<NewEventModal {...props} />);
userEvent.click(screen.getByText("Create"));
expect(props.onSubmit).toHaveBeenCalled();
});
});
const getProps = (opts?: Partial<NewEventModalProps>): NewEventModalProps => ({
collection: createMockCollection(),
onSubmit: jest.fn(),
onCancel: jest.fn(),
onClose: jest.fn(),
...opts,
});
import styled from "@emotion/styled";
export const ModalBody = styled.div`
padding: 2rem;
`;
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