From e96d434f420e87f153590cec9401ed7737f673be Mon Sep 17 00:00:00 2001 From: Alexander Polyankin <alexander.polyankin@metabase.com> Date: Fri, 4 Mar 2022 21:44:07 +0300 Subject: [PATCH] Fix bugs and add tests for timelines in collections (#20798) --- .../src/metabase-types/api/mocks/timeline.ts | 2 +- .../CollectionHeader/CollectionHeader.jsx | 4 +- frontend/src/metabase/components/Modal.jsx | 14 +- .../components/DateSelector/DateSelector.tsx | 12 +- .../core/components/Input/Input.styled.tsx | 5 +- .../containers/EntityObjectLoader.jsx | 11 +- .../DeleteEventModal/DeleteEventModal.tsx | 12 +- .../DeleteEventModal.unit.spec.tsx | 32 ++ .../EditEventModal/EditEventModal.tsx | 26 +- .../EditEventModal.unit.spec.tsx | 41 +++ .../EditTimelineModal/EditTimelineModal.tsx | 18 +- .../EditTimelineModal.unit.spec.tsx | 39 +++ .../components/EventCard/EventCard.styled.tsx | 2 +- .../EventCard/EventCard.unit.spec.tsx | 107 +++++++ .../EventEmptyState.unit.spec.tsx | 11 + .../EventList/EventList.unit.spec.tsx | 31 ++ .../LoadingAndErrorWrapper.styled.tsx | 8 + .../LoadingAndErrorWrapper.tsx | 2 + .../LoadingAndErrorWrapper/index.ts | 1 + .../ModalHeader/ModalHeader.styled.tsx | 11 + .../components/ModalHeader/ModalHeader.tsx | 8 + .../ModalHeader/ModalHeader.unit.spec.tsx | 25 ++ .../NewEventModal/NewEventModal.tsx | 10 +- .../NewEventModal/NewEventModal.unit.spec.tsx | 32 ++ .../NewTimelineModal.unit.spec.tsx | 34 +++ .../TimelineCard/TimelineCard.unit.spec.tsx | 28 ++ .../TimelineDetailsModal.styled.tsx | 2 +- .../TimelineDetailsModal.tsx | 16 +- .../TimelineDetailsModal.unit.spec.tsx | 48 +++ .../TimelineEmptyState.unit.spec.tsx | 32 ++ .../components/TimelineEntryModal/index.ts | 1 - .../TimelineIndexModal.tsx} | 17 +- .../components/TimelineIndexModal/index.ts | 1 + .../TimelineListModal.styled.tsx | 2 +- .../TimelineListModal.unit.spec.tsx | 39 +++ .../DeleteEventModal/DeleteEventModal.tsx | 27 +- .../EditEventModal/EditEventModal.tsx | 37 ++- .../EditTimelineModal/EditTimelineModal.tsx | 21 +- .../NewEventModal/NewEventModal.tsx | 10 +- .../NewEventWithTimelineModal.tsx | 6 +- .../NewTimelineModal/NewTimelineModal.tsx | 2 + .../TimelineArchiveModal.tsx | 8 +- .../TimelineDetailsModal.tsx | 8 +- .../containers/TimelineEntryModal/index.ts | 1 - .../TimelineIndexModal.tsx} | 4 +- .../containers/TimelineIndexModal/index.ts | 1 + frontend/src/metabase/timelines/routes.tsx | 13 +- frontend/test/__support__/e2e/commands.js | 2 + .../api/composite/createTimelineWithEvents.js | 16 + .../__support__/e2e/commands/api/timeline.js | 43 +++ .../collections/timelines.cy.spec.js | 30 ++ .../collections/timelines.cy.spec.js | 274 ++++++++++++++++++ 52 files changed, 1111 insertions(+), 76 deletions(-) create mode 100644 frontend/src/metabase/timelines/components/DeleteEventModal/DeleteEventModal.unit.spec.tsx create mode 100644 frontend/src/metabase/timelines/components/EditEventModal/EditEventModal.unit.spec.tsx create mode 100644 frontend/src/metabase/timelines/components/EditTimelineModal/EditTimelineModal.unit.spec.tsx create mode 100644 frontend/src/metabase/timelines/components/EventCard/EventCard.unit.spec.tsx create mode 100644 frontend/src/metabase/timelines/components/EventEmptyState/EventEmptyState.unit.spec.tsx create mode 100644 frontend/src/metabase/timelines/components/EventList/EventList.unit.spec.tsx create mode 100644 frontend/src/metabase/timelines/components/LoadingAndErrorWrapper/LoadingAndErrorWrapper.styled.tsx create mode 100644 frontend/src/metabase/timelines/components/LoadingAndErrorWrapper/LoadingAndErrorWrapper.tsx create mode 100644 frontend/src/metabase/timelines/components/LoadingAndErrorWrapper/index.ts create mode 100644 frontend/src/metabase/timelines/components/ModalHeader/ModalHeader.unit.spec.tsx create mode 100644 frontend/src/metabase/timelines/components/NewEventModal/NewEventModal.unit.spec.tsx create mode 100644 frontend/src/metabase/timelines/components/NewTimelineModal/NewTimelineModal.unit.spec.tsx create mode 100644 frontend/src/metabase/timelines/components/TimelineCard/TimelineCard.unit.spec.tsx create mode 100644 frontend/src/metabase/timelines/components/TimelineDetailsModal/TimelineDetailsModal.unit.spec.tsx create mode 100644 frontend/src/metabase/timelines/components/TimelineEmptyState/TimelineEmptyState.unit.spec.tsx delete mode 100644 frontend/src/metabase/timelines/components/TimelineEntryModal/index.ts rename frontend/src/metabase/timelines/components/{TimelineEntryModal/TimelineEntryModal.tsx => TimelineIndexModal/TimelineIndexModal.tsx} (60%) create mode 100644 frontend/src/metabase/timelines/components/TimelineIndexModal/index.ts create mode 100644 frontend/src/metabase/timelines/components/TimelineListModal/TimelineListModal.unit.spec.tsx delete mode 100644 frontend/src/metabase/timelines/containers/TimelineEntryModal/index.ts rename frontend/src/metabase/timelines/containers/{TimelineEntryModal/TimelineEntryModal.tsx => TimelineIndexModal/TimelineIndexModal.tsx} (74%) create mode 100644 frontend/src/metabase/timelines/containers/TimelineIndexModal/index.ts create mode 100644 frontend/test/__support__/e2e/commands/api/composite/createTimelineWithEvents.js create mode 100644 frontend/test/__support__/e2e/commands/api/timeline.js create mode 100644 frontend/test/metabase-visual/collections/timelines.cy.spec.js create mode 100644 frontend/test/metabase/scenarios/collections/timelines.cy.spec.js diff --git a/frontend/src/metabase-types/api/mocks/timeline.ts b/frontend/src/metabase-types/api/mocks/timeline.ts index 4f794d4c5b1..809eee1107c 100644 --- a/frontend/src/metabase-types/api/mocks/timeline.ts +++ b/frontend/src/metabase-types/api/mocks/timeline.ts @@ -20,7 +20,7 @@ export const createMockTimelineEvent = ( name: "Christmas", description: null, icon: "star", - timestamp: "2021-12-25T14:48:00.000Z", + timestamp: "2021-12-25T00:00:00Z", timezone: "UTC", time_matters: false, creator: createMockUser(), diff --git a/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.jsx b/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.jsx index 5ef29365671..0f7fa860b84 100644 --- a/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.jsx +++ b/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.jsx @@ -70,11 +70,11 @@ function PermissionsLink({ } function TimelinesLink({ collection }) { - const tooltip = t`Events`; + const title = t`Events`; const link = Urls.timelinesInCollection(collection); return ( - <Tooltip tooltip={tooltip}> + <Tooltip tooltip={title}> <Link to={link}> <IconWrapper> <Icon name="calendar" size={20} /> diff --git a/frontend/src/metabase/components/Modal.jsx b/frontend/src/metabase/components/Modal.jsx index aa1a24a11ff..edde2f125a6 100644 --- a/frontend/src/metabase/components/Modal.jsx +++ b/frontend/src/metabase/components/Modal.jsx @@ -30,11 +30,13 @@ export class WindowModal extends Component { static propTypes = { isOpen: PropTypes.bool, enableMouseEvents: PropTypes.bool, + enableTransition: PropTypes.bool, }; static defaultProps = { className: "Modal", backdropClassName: "Modal-backdrop", + enableTransition: true, }; constructor(props) { @@ -81,7 +83,13 @@ export class WindowModal extends Component { } render() { - const { enableMouseEvents, backdropClassName, isOpen, style } = this.props; + const { + enableMouseEvents, + backdropClassName, + isOpen, + style, + enableTransition, + } = this.props; const backdropClassnames = "flex justify-center align-center fixed top left bottom right"; @@ -92,9 +100,11 @@ export class WindowModal extends Component { > <CSSTransitionGroup transitionName="Modal" - transitionAppear={true} + transitionAppear={enableTransition} transitionAppearTimeout={250} + transitionEnter={enableTransition} transitionEnterTimeout={250} + transitionLeave={enableTransition} transitionLeaveTimeout={250} > {isOpen && ( diff --git a/frontend/src/metabase/core/components/DateSelector/DateSelector.tsx b/frontend/src/metabase/core/components/DateSelector/DateSelector.tsx index b91527f45b3..b671485255f 100644 --- a/frontend/src/metabase/core/components/DateSelector/DateSelector.tsx +++ b/frontend/src/metabase/core/components/DateSelector/DateSelector.tsx @@ -45,13 +45,9 @@ const DateSelector = forwardRef(function DateSelector( const handleDateChange = useCallback( (unused1: string, unused2: string, date: Moment) => { - const newDate = moment({ - year: date.year(), - month: date.month(), - day: date.day(), - hours: value?.hours(), - minutes: value?.minutes(), - }); + const newDate = date.clone(); + newDate.hours(value?.hours() ?? 0); + newDate.minutes(value?.minutes() ?? 0); onChange?.(newDate); }, [value, onChange], @@ -96,7 +92,7 @@ const DateSelector = forwardRef(function DateSelector( </SelectorTimeButton> )} <SelectorSubmitButton primary onClick={onSubmit}> - {t`Save`} + {t`Done`} </SelectorSubmitButton> </SelectorFooter> </div> diff --git a/frontend/src/metabase/core/components/Input/Input.styled.tsx b/frontend/src/metabase/core/components/Input/Input.styled.tsx index eb7dec8296d..3896e2fd096 100644 --- a/frontend/src/metabase/core/components/Input/Input.styled.tsx +++ b/frontend/src/metabase/core/components/Input/Input.styled.tsx @@ -62,13 +62,14 @@ export const InputField = styled.input<InputProps>` export const InputButton = styled(IconButtonWrapper)` position: absolute; color: ${color("text-light")}; + padding: 0.75rem; border-radius: 50%; `; export const InputLeftButton = styled(InputButton)` - left: 0.75rem; + left: 0; `; export const InputRightButton = styled(InputButton)` - right: 0.75rem; + right: 0; `; diff --git a/frontend/src/metabase/entities/containers/EntityObjectLoader.jsx b/frontend/src/metabase/entities/containers/EntityObjectLoader.jsx index d86bd14a90c..d8e72a234f9 100644 --- a/frontend/src/metabase/entities/containers/EntityObjectLoader.jsx +++ b/frontend/src/metabase/entities/containers/EntityObjectLoader.jsx @@ -18,6 +18,7 @@ const CONSUMED_PROPS = [ "wrapped", "properties", "loadingAndErrorWrapper", + "LoadingAndErrorWrapper", "selectorName", ]; @@ -54,6 +55,7 @@ const getMemoizedEntityQuery = createMemoizedSelector( export default class EntityObjectLoader extends React.Component { static defaultProps = { loadingAndErrorWrapper: true, + LoadingAndErrorWrapper: LoadingAndErrorWrapper, reload: false, wrapped: false, dispatchApiErrorEvent: true, @@ -123,7 +125,14 @@ export default class EntityObjectLoader extends React.Component { }); }; render() { - const { entityId, fetched, error, loadingAndErrorWrapper } = this.props; + const { + entityId, + fetched, + error, + loadingAndErrorWrapper, + LoadingAndErrorWrapper, + } = this.props; + return loadingAndErrorWrapper ? ( <LoadingAndErrorWrapper loading={!fetched && entityId != null} diff --git a/frontend/src/metabase/timelines/components/DeleteEventModal/DeleteEventModal.tsx b/frontend/src/metabase/timelines/components/DeleteEventModal/DeleteEventModal.tsx index ef949c329fb..b2f0f32cd40 100644 --- a/frontend/src/metabase/timelines/components/DeleteEventModal/DeleteEventModal.tsx +++ b/frontend/src/metabase/timelines/components/DeleteEventModal/DeleteEventModal.tsx @@ -9,20 +9,26 @@ export interface DeleteEventModalProps { event: TimelineEvent; timeline: Timeline; collection: Collection; - onSubmit: (event: TimelineEvent) => void; + 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); - }, [event, onSubmit]); + await onSubmit(event, timeline, collection); + }, [event, timeline, collection, onSubmit]); return ( <div> diff --git a/frontend/src/metabase/timelines/components/DeleteEventModal/DeleteEventModal.unit.spec.tsx b/frontend/src/metabase/timelines/components/DeleteEventModal/DeleteEventModal.unit.spec.tsx new file mode 100644 index 00000000000..51200403351 --- /dev/null +++ b/frontend/src/metabase/timelines/components/DeleteEventModal/DeleteEventModal.unit.spec.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + createMockCollection, + createMockTimeline, + createMockTimelineEvent, +} from "metabase-types/api/mocks"; +import DeleteEventModal, { DeleteEventModalProps } from "./DeleteEventModal"; + +describe("DeleteEventModal", () => { + it("should submit modal", () => { + const props = getProps(); + + render(<DeleteEventModal {...props} />); + userEvent.click(screen.getByText("Delete")); + + expect(props.onSubmit).toHaveBeenCalled(); + }); +}); + +const getProps = ( + opts?: Partial<DeleteEventModalProps>, +): DeleteEventModalProps => ({ + event: createMockTimelineEvent(), + timeline: createMockTimeline(), + collection: createMockCollection(), + onSubmit: jest.fn(), + onCancel: jest.fn(), + onClose: jest.fn(), + ...opts, +}); diff --git a/frontend/src/metabase/timelines/components/EditEventModal/EditEventModal.tsx b/frontend/src/metabase/timelines/components/EditEventModal/EditEventModal.tsx index 46773da0e65..6de1781a76b 100644 --- a/frontend/src/metabase/timelines/components/EditEventModal/EditEventModal.tsx +++ b/frontend/src/metabase/timelines/components/EditEventModal/EditEventModal.tsx @@ -2,20 +2,32 @@ import React, { useCallback } from "react"; import { t } from "ttag"; import Form from "metabase/containers/Form"; import forms from "metabase/entities/timeline-events/forms"; -import { TimelineEvent } from "metabase-types/api"; +import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; import ModalHeader from "../ModalHeader"; import { ModalBody, ModalDangerButton } from "./EditEventModal.styled"; export interface EditEventModalProps { event: TimelineEvent; - onSubmit: (values: Partial<TimelineEvent>) => void; - onArchive: (event: TimelineEvent) => void; + timeline: Timeline; + collection: Collection; + onSubmit: ( + values: Partial<TimelineEvent>, + timeline: Timeline, + collection: Collection, + ) => void; + onArchive: ( + event: TimelineEvent, + timeline: Timeline, + collection: Collection, + ) => void; onCancel: () => void; onClose?: () => void; } const EditEventModal = ({ event, + timeline, + collection, onSubmit, onArchive, onCancel, @@ -23,14 +35,14 @@ const EditEventModal = ({ }: EditEventModalProps): JSX.Element => { const handleSubmit = useCallback( async (values: Partial<TimelineEvent>) => { - await onSubmit(values); + await onSubmit(values, timeline, collection); }, - [onSubmit], + [timeline, collection, onSubmit], ); const handleArchive = useCallback(async () => { - await onArchive(event); - }, [event, onArchive]); + await onArchive(event, timeline, collection); + }, [event, timeline, collection, onArchive]); return ( <div> diff --git a/frontend/src/metabase/timelines/components/EditEventModal/EditEventModal.unit.spec.tsx b/frontend/src/metabase/timelines/components/EditEventModal/EditEventModal.unit.spec.tsx new file mode 100644 index 00000000000..961f8c0b65e --- /dev/null +++ b/frontend/src/metabase/timelines/components/EditEventModal/EditEventModal.unit.spec.tsx @@ -0,0 +1,41 @@ +import React, { FormHTMLAttributes } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + createMockCollection, + createMockTimeline, + createMockTimelineEvent, +} from "metabase-types/api/mocks"; +import EditEventModal, { EditEventModalProps } from "./EditEventModal"; + +const FormMock = (props: FormHTMLAttributes<HTMLFormElement>) => ( + <form {...props}> + <button>Update</button> + </form> +); + +jest.mock("metabase/containers/Form", () => FormMock); + +describe("EditEventModal", () => { + it("should submit modal", () => { + const props = getProps(); + + render(<EditEventModal {...props} />); + userEvent.click(screen.getByText("Update")); + + expect(props.onSubmit).toHaveBeenCalled(); + }); +}); + +const getProps = ( + opts?: Partial<EditEventModalProps>, +): EditEventModalProps => ({ + event: createMockTimelineEvent(), + timeline: createMockTimeline(), + collection: createMockCollection(), + onSubmit: jest.fn(), + onArchive: jest.fn(), + onCancel: jest.fn(), + onClose: jest.fn(), + ...opts, +}); diff --git a/frontend/src/metabase/timelines/components/EditTimelineModal/EditTimelineModal.tsx b/frontend/src/metabase/timelines/components/EditTimelineModal/EditTimelineModal.tsx index 47f1d3e51da..8d93f0873ad 100644 --- a/frontend/src/metabase/timelines/components/EditTimelineModal/EditTimelineModal.tsx +++ b/frontend/src/metabase/timelines/components/EditTimelineModal/EditTimelineModal.tsx @@ -2,20 +2,22 @@ import React, { useCallback } from "react"; import { t } from "ttag"; import Form from "metabase/containers/Form"; import forms from "metabase/entities/timelines/forms"; -import { Timeline } from "metabase-types/api"; +import { Collection, Timeline } from "metabase-types/api"; import ModalHeader from "../ModalHeader"; -import { ModalBody, ModalDangerButton } from "./EditTimelineModal.styled"; +import { ModalDangerButton, ModalBody } from "./EditTimelineModal.styled"; export interface EditTimelineModalProps { timeline: Timeline; - onSubmit: (values: Partial<Timeline>) => void; - onArchive: (timeline: Timeline) => void; + collection: Collection; + onSubmit: (values: Partial<Timeline>, collection: Collection) => void; + onArchive: (timeline: Timeline, collection: Collection) => void; onCancel: () => void; onClose?: () => void; } const EditTimelineModal = ({ timeline, + collection, onSubmit, onArchive, onCancel, @@ -23,14 +25,14 @@ const EditTimelineModal = ({ }: EditTimelineModalProps): JSX.Element => { const handleSubmit = useCallback( async (values: Partial<Timeline>) => { - await onSubmit(values); + await onSubmit(values, collection); }, - [onSubmit], + [collection, onSubmit], ); const handleArchive = useCallback(async () => { - await onArchive(timeline); - }, [timeline, onArchive]); + await onArchive(timeline, collection); + }, [timeline, collection, onArchive]); return ( <div> diff --git a/frontend/src/metabase/timelines/components/EditTimelineModal/EditTimelineModal.unit.spec.tsx b/frontend/src/metabase/timelines/components/EditTimelineModal/EditTimelineModal.unit.spec.tsx new file mode 100644 index 00000000000..9ad1d233865 --- /dev/null +++ b/frontend/src/metabase/timelines/components/EditTimelineModal/EditTimelineModal.unit.spec.tsx @@ -0,0 +1,39 @@ +import React, { FormHTMLAttributes } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + createMockCollection, + createMockTimeline, +} from "metabase-types/api/mocks"; +import EditTimelineModal, { EditTimelineModalProps } from "./EditTimelineModal"; + +const FormMock = (props: FormHTMLAttributes<HTMLFormElement>) => ( + <form {...props}> + <button>Update</button> + </form> +); + +jest.mock("metabase/containers/Form", () => FormMock); + +describe("EditTimelineModal", () => { + it("should submit modal", () => { + const props = getProps(); + + render(<EditTimelineModal {...props} />); + userEvent.click(screen.getByText("Update")); + + expect(props.onSubmit).toHaveBeenCalled(); + }); +}); + +const getProps = ( + opts?: Partial<EditTimelineModalProps>, +): EditTimelineModalProps => ({ + timeline: createMockTimeline(), + collection: createMockCollection(), + onSubmit: jest.fn(), + onArchive: jest.fn(), + onCancel: jest.fn(), + onClose: jest.fn(), + ...opts, +}); diff --git a/frontend/src/metabase/timelines/components/EventCard/EventCard.styled.tsx b/frontend/src/metabase/timelines/components/EventCard/EventCard.styled.tsx index ecd3d5583f8..a44ac1051e1 100644 --- a/frontend/src/metabase/timelines/components/EventCard/EventCard.styled.tsx +++ b/frontend/src/metabase/timelines/components/EventCard/EventCard.styled.tsx @@ -38,7 +38,7 @@ export const CardThreadStroke = styled.div` export const CardBody = styled.div` flex: 1 1 auto; - padding: 0.25rem 0.75rem 0; + padding: 0.25rem 0.75rem 0.5rem; `; export const CardTitle = styled.div` diff --git a/frontend/src/metabase/timelines/components/EventCard/EventCard.unit.spec.tsx b/frontend/src/metabase/timelines/components/EventCard/EventCard.unit.spec.tsx new file mode 100644 index 00000000000..dff8115b4fa --- /dev/null +++ b/frontend/src/metabase/timelines/components/EventCard/EventCard.unit.spec.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { + createMockCollection, + createMockTimeline, + createMockTimelineEvent, + createMockUser, +} from "metabase-types/api/mocks"; +import EventCard, { EventCardProps } from "./EventCard"; +import userEvent from "@testing-library/user-event"; + +describe("EventCard", () => { + it("should format a day-only event", () => { + const props = getProps({ + event: createMockTimelineEvent({ + timestamp: "2020-12-20T00:00:00Z", + time_matters: false, + }), + }); + + render(<EventCard {...props} />); + + expect(screen.getByText("December 20, 2020")).toBeInTheDocument(); + }); + + it("should format a time-sensitive event", () => { + const props = getProps({ + event: createMockTimelineEvent({ + timestamp: "2020-12-20T10:00:00Z", + time_matters: true, + }), + }); + + render(<EventCard {...props} />); + + expect(screen.getByText("December 20, 2020, 10:00 AM")).toBeInTheDocument(); + }); + + it("should format an event with the user who created the event", () => { + const props = getProps({ + event: createMockTimelineEvent({ + creator: createMockUser({ + common_name: "Testy Test", + }), + created_at: "2020-12-20T10:00:00Z", + }), + }); + + render(<EventCard {...props} />); + + expect( + screen.getByText("Testy Test added this on December 20, 2020"), + ).toBeInTheDocument(); + }); + + it("should not render the menu for read-only users", () => { + const props = getProps({ + collection: createMockCollection({ + can_write: false, + }), + }); + + render(<EventCard {...props} />); + + expect(screen.queryByLabelText("ellipsis icon")).not.toBeInTheDocument(); + }); + + it("should render the menu for users with write permissions", async () => { + const props = getProps({ + collection: createMockCollection({ + can_write: true, + }), + }); + + render(<EventCard {...props} />); + userEvent.click(screen.getByLabelText("ellipsis icon")); + + expect(screen.getByText("Edit event")).toBeInTheDocument(); + expect(screen.getByText("Archive event")).toBeInTheDocument(); + }); + + it("should render the menu for an archived event", () => { + const props = getProps({ + event: createMockTimelineEvent({ + archived: true, + }), + collection: createMockCollection({ + can_write: true, + }), + }); + + render(<EventCard {...props} />); + userEvent.click(screen.getByLabelText("ellipsis icon")); + + expect(screen.getByText("Unarchive event")).toBeInTheDocument(); + expect(screen.getByText("Delete event")).toBeInTheDocument(); + }); +}); + +export const getProps = (opts?: Partial<EventCardProps>): EventCardProps => ({ + event: createMockTimelineEvent(), + timeline: createMockTimeline(), + collection: createMockCollection(), + onArchive: jest.fn(), + onUnarchive: jest.fn(), + ...opts, +}); diff --git a/frontend/src/metabase/timelines/components/EventEmptyState/EventEmptyState.unit.spec.tsx b/frontend/src/metabase/timelines/components/EventEmptyState/EventEmptyState.unit.spec.tsx new file mode 100644 index 00000000000..fb2c32dc1b2 --- /dev/null +++ b/frontend/src/metabase/timelines/components/EventEmptyState/EventEmptyState.unit.spec.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import EventEmptyState from "./EventEmptyState"; + +describe("EventEmptyState", () => { + it("should render correctly", () => { + render(<EventEmptyState />); + + expect(screen.getByLabelText("star icon")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/timelines/components/EventList/EventList.unit.spec.tsx b/frontend/src/metabase/timelines/components/EventList/EventList.unit.spec.tsx new file mode 100644 index 00000000000..98b5e54b594 --- /dev/null +++ b/frontend/src/metabase/timelines/components/EventList/EventList.unit.spec.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { + createMockCollection, + createMockTimeline, + createMockTimelineEvent, +} from "metabase-types/api/mocks"; +import EventList, { EventListProps } from "./EventList"; + +describe("EventList", () => { + it("should render a list of events", () => { + const props = getProps({ + events: [ + createMockTimelineEvent({ id: 1, name: "RC1" }), + createMockTimelineEvent({ id: 2, name: "RC2" }), + ], + }); + + render(<EventList {...props} />); + + expect(screen.getByText("RC1")).toBeInTheDocument(); + expect(screen.getByText("RC2")).toBeInTheDocument(); + }); +}); + +const getProps = (opts?: Partial<EventListProps>): EventListProps => ({ + events: [], + timeline: createMockTimeline(), + collection: createMockCollection(), + ...opts, +}); diff --git a/frontend/src/metabase/timelines/components/LoadingAndErrorWrapper/LoadingAndErrorWrapper.styled.tsx b/frontend/src/metabase/timelines/components/LoadingAndErrorWrapper/LoadingAndErrorWrapper.styled.tsx new file mode 100644 index 00000000000..e23d7928a62 --- /dev/null +++ b/frontend/src/metabase/timelines/components/LoadingAndErrorWrapper/LoadingAndErrorWrapper.styled.tsx @@ -0,0 +1,8 @@ +import styled from "@emotion/styled"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +export const LoadingAndErrorRoot = styled(LoadingAndErrorWrapper)` + display: flex; + flex-direction: column; + min-height: 565px; +`; diff --git a/frontend/src/metabase/timelines/components/LoadingAndErrorWrapper/LoadingAndErrorWrapper.tsx b/frontend/src/metabase/timelines/components/LoadingAndErrorWrapper/LoadingAndErrorWrapper.tsx new file mode 100644 index 00000000000..4d96caadda5 --- /dev/null +++ b/frontend/src/metabase/timelines/components/LoadingAndErrorWrapper/LoadingAndErrorWrapper.tsx @@ -0,0 +1,2 @@ +import { LoadingAndErrorRoot } from "./LoadingAndErrorWrapper.styled"; +export default LoadingAndErrorRoot; diff --git a/frontend/src/metabase/timelines/components/LoadingAndErrorWrapper/index.ts b/frontend/src/metabase/timelines/components/LoadingAndErrorWrapper/index.ts new file mode 100644 index 00000000000..bace309e446 --- /dev/null +++ b/frontend/src/metabase/timelines/components/LoadingAndErrorWrapper/index.ts @@ -0,0 +1 @@ +export { default } from "./LoadingAndErrorWrapper"; diff --git a/frontend/src/metabase/timelines/components/ModalHeader/ModalHeader.styled.tsx b/frontend/src/metabase/timelines/components/ModalHeader/ModalHeader.styled.tsx index a98c3f21233..c4d330404fa 100644 --- a/frontend/src/metabase/timelines/components/ModalHeader/ModalHeader.styled.tsx +++ b/frontend/src/metabase/timelines/components/ModalHeader/ModalHeader.styled.tsx @@ -21,6 +21,17 @@ export const HeaderActions = styled.div` margin-right: 1rem; `; +export const HeaderBackButton = styled(IconButtonWrapper)` + flex: 0 0 auto; + color: ${color("text-dark")}; + margin: 0 0.5rem 0 0; + padding: 0.25rem 0; + + &:hover { + color: ${color("brand")}; + } +`; + export const HeaderCloseButton = styled(IconButtonWrapper)` flex: 0 0 auto; color: ${color("text-light")}; diff --git a/frontend/src/metabase/timelines/components/ModalHeader/ModalHeader.tsx b/frontend/src/metabase/timelines/components/ModalHeader/ModalHeader.tsx index 84cac5f598c..8744407f87b 100644 --- a/frontend/src/metabase/timelines/components/ModalHeader/ModalHeader.tsx +++ b/frontend/src/metabase/timelines/components/ModalHeader/ModalHeader.tsx @@ -2,6 +2,7 @@ import React, { ReactNode } from "react"; import Icon from "metabase/components/Icon"; import { HeaderActions, + HeaderBackButton, HeaderCloseButton, HeaderRoot, HeaderTitle, @@ -11,15 +12,22 @@ export interface ModalHeaderProps { title?: ReactNode; children?: ReactNode; onClose?: () => void; + onGoBack?: () => void; } const ModalHeader = ({ title, children, onClose, + onGoBack, }: ModalHeaderProps): JSX.Element => { return ( <HeaderRoot> + {onGoBack && ( + <HeaderBackButton onClick={onGoBack}> + <Icon name="chevronleft" /> + </HeaderBackButton> + )} <HeaderTitle>{title}</HeaderTitle> {children && <HeaderActions>{children}</HeaderActions>} {onClose && ( diff --git a/frontend/src/metabase/timelines/components/ModalHeader/ModalHeader.unit.spec.tsx b/frontend/src/metabase/timelines/components/ModalHeader/ModalHeader.unit.spec.tsx new file mode 100644 index 00000000000..3e6cdedc52a --- /dev/null +++ b/frontend/src/metabase/timelines/components/ModalHeader/ModalHeader.unit.spec.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import ModalHeader from "./ModalHeader"; + +describe("ModalHeader", () => { + it("should render title", () => { + render(<ModalHeader title="Events" />); + + expect(screen.getByText("Events")).toBeInTheDocument(); + }); + + it("should render with actions", () => { + render(<ModalHeader title="Events">Actions</ModalHeader>); + + expect(screen.getByText("Actions")).toBeInTheDocument(); + }); + + it("should render with close button", () => { + const onClose = jest.fn(); + + render(<ModalHeader title="Events" onClose={onClose} />); + + expect(screen.getByLabelText("close icon")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/timelines/components/NewEventModal/NewEventModal.tsx b/frontend/src/metabase/timelines/components/NewEventModal/NewEventModal.tsx index 43f7d390512..8e519cd45ff 100644 --- a/frontend/src/metabase/timelines/components/NewEventModal/NewEventModal.tsx +++ b/frontend/src/metabase/timelines/components/NewEventModal/NewEventModal.tsx @@ -10,7 +10,11 @@ import { ModalBody } from "./NewEventModal.styled"; export interface NewEventModalProps { timeline?: Timeline; collection: Collection; - onSubmit: (values: Partial<TimelineEvent>, collection: Collection) => void; + onSubmit: ( + values: Partial<TimelineEvent>, + collection: Collection, + timeline?: Timeline, + ) => void; onCancel: (location: string) => void; onClose?: () => void; } @@ -33,9 +37,9 @@ const NewEventModal = ({ const handleSubmit = useCallback( async (values: Partial<TimelineEvent>) => { - await onSubmit(values, collection); + await onSubmit(values, collection, timeline); }, - [collection, onSubmit], + [timeline, collection, onSubmit], ); return ( diff --git a/frontend/src/metabase/timelines/components/NewEventModal/NewEventModal.unit.spec.tsx b/frontend/src/metabase/timelines/components/NewEventModal/NewEventModal.unit.spec.tsx new file mode 100644 index 00000000000..8d943ba636a --- /dev/null +++ b/frontend/src/metabase/timelines/components/NewEventModal/NewEventModal.unit.spec.tsx @@ -0,0 +1,32 @@ +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, +}); diff --git a/frontend/src/metabase/timelines/components/NewTimelineModal/NewTimelineModal.unit.spec.tsx b/frontend/src/metabase/timelines/components/NewTimelineModal/NewTimelineModal.unit.spec.tsx new file mode 100644 index 00000000000..5cb844e46dc --- /dev/null +++ b/frontend/src/metabase/timelines/components/NewTimelineModal/NewTimelineModal.unit.spec.tsx @@ -0,0 +1,34 @@ +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 NewTimelineModal, { NewTimelineModalProps } from "./NewTimelineModal"; + +const FormMock = (props: FormHTMLAttributes<HTMLFormElement>) => ( + <form {...props}> + <button>Create</button> + </form> +); + +jest.mock("metabase/containers/Form", () => FormMock); + +describe("NewTimelineModal", () => { + it("should submit modal", () => { + const props = getProps(); + + render(<NewTimelineModal {...props} />); + userEvent.click(screen.getByText("Create")); + + expect(props.onSubmit).toHaveBeenCalled(); + }); +}); + +const getProps = ( + opts?: Partial<NewTimelineModalProps>, +): NewTimelineModalProps => ({ + collection: createMockCollection(), + onSubmit: jest.fn(), + onCancel: jest.fn(), + onClose: jest.fn(), + ...opts, +}); diff --git a/frontend/src/metabase/timelines/components/TimelineCard/TimelineCard.unit.spec.tsx b/frontend/src/metabase/timelines/components/TimelineCard/TimelineCard.unit.spec.tsx new file mode 100644 index 00000000000..50901b8be6c --- /dev/null +++ b/frontend/src/metabase/timelines/components/TimelineCard/TimelineCard.unit.spec.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { + createMockCollection, + createMockTimeline, + createMockTimelineEvent, +} from "metabase-types/api/mocks"; +import TimelineCard, { TimelineCardProps } from "./TimelineCard"; + +describe("TimelineCard", () => { + it("should render timeline", () => { + const props = getProps({ + timeline: createMockTimeline({ + events: [createMockTimelineEvent(), createMockTimelineEvent()], + }), + }); + + render(<TimelineCard {...props} />); + + expect(screen.getByText("2 events")).toBeInTheDocument(); + }); +}); + +const getProps = (opts?: Partial<TimelineCardProps>): TimelineCardProps => ({ + timeline: createMockTimeline(), + collection: createMockCollection(), + ...opts, +}); diff --git a/frontend/src/metabase/timelines/components/TimelineDetailsModal/TimelineDetailsModal.styled.tsx b/frontend/src/metabase/timelines/components/TimelineDetailsModal/TimelineDetailsModal.styled.tsx index bf51721d3e3..05d427d7359 100644 --- a/frontend/src/metabase/timelines/components/TimelineDetailsModal/TimelineDetailsModal.styled.tsx +++ b/frontend/src/metabase/timelines/components/TimelineDetailsModal/TimelineDetailsModal.styled.tsx @@ -5,7 +5,7 @@ import TextInput from "metabase/components/TextInput"; export const ModalRoot = styled.div` display: flex; flex-direction: column; - min-height: 565px; + min-height: 573px; max-height: 90vh; `; diff --git a/frontend/src/metabase/timelines/components/TimelineDetailsModal/TimelineDetailsModal.tsx b/frontend/src/metabase/timelines/components/TimelineDetailsModal/TimelineDetailsModal.tsx index 17d8cbb8ce1..1fac9413868 100644 --- a/frontend/src/metabase/timelines/components/TimelineDetailsModal/TimelineDetailsModal.tsx +++ b/frontend/src/metabase/timelines/components/TimelineDetailsModal/TimelineDetailsModal.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { t } from "ttag"; import _ from "underscore"; import { parseTimestamp } from "metabase/lib/time"; @@ -25,18 +25,22 @@ export interface TimelineDetailsModalProps { timeline: Timeline; collection: Collection; isArchive?: boolean; + isDefault?: boolean; onArchive?: (event: TimelineEvent) => void; onUnarchive?: (event: TimelineEvent) => void; onClose?: () => void; + onGoBack?: (collection: Collection) => void; } const TimelineDetailsModal = ({ timeline, collection, isArchive = false, + isDefault = false, onArchive, onUnarchive, onClose, + onGoBack, }: TimelineDetailsModalProps): JSX.Element => { const title = isArchive ? t`Archived events` : timeline.name; const [inputText, setInputText] = useState(""); @@ -54,13 +58,21 @@ const TimelineDetailsModal = ({ return getMenuItems(timeline, collection, isArchive); }, [timeline, collection, isArchive]); + const handleGoBack = useCallback(() => { + onGoBack?.(collection); + }, [collection, onGoBack]); + const isNotEmpty = events.length > 0; const isSearching = searchText.length > 0; const canWrite = collection.can_write; return ( <ModalRoot> - <ModalHeader title={title} onClose={onClose}> + <ModalHeader + title={title} + onClose={onClose} + onGoBack={!isDefault ? handleGoBack : undefined} + > {menuItems.length > 0 && ( <EntityMenu items={menuItems} triggerIcon="ellipsis" /> )} diff --git a/frontend/src/metabase/timelines/components/TimelineDetailsModal/TimelineDetailsModal.unit.spec.tsx b/frontend/src/metabase/timelines/components/TimelineDetailsModal/TimelineDetailsModal.unit.spec.tsx new file mode 100644 index 00000000000..f9976b1dce9 --- /dev/null +++ b/frontend/src/metabase/timelines/components/TimelineDetailsModal/TimelineDetailsModal.unit.spec.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + createMockCollection, + createMockTimeline, + createMockTimelineEvent, +} from "metabase-types/api/mocks"; +import TimelineDetailsModal, { + TimelineDetailsModalProps, +} from "./TimelineDetailsModal"; + +describe("TimelineDetailsModal", () => { + it("should search a list of events", async () => { + const props = getProps({ + timeline: createMockTimeline({ + events: [ + createMockTimelineEvent({ name: "RC1" }), + createMockTimelineEvent({ name: "RC2" }), + createMockTimelineEvent({ name: "Release" }), + ], + }), + }); + + render(<TimelineDetailsModal {...props} />); + + userEvent.type(screen.getByPlaceholderText("Search for an event"), "RC"); + await waitFor(() => { + expect(screen.getByText("RC1")).toBeInTheDocument(); + expect(screen.getByText("RC2")).toBeInTheDocument(); + expect(screen.queryByText("Release")).not.toBeInTheDocument(); + }); + + userEvent.type(screen.getByPlaceholderText("Search for an event"), "1"); + await waitFor(() => { + expect(screen.getByText("RC1")).toBeInTheDocument(); + expect(screen.queryByText("RC2")).not.toBeInTheDocument(); + }); + }); +}); + +const getProps = ( + opts?: Partial<TimelineDetailsModalProps>, +): TimelineDetailsModalProps => ({ + timeline: createMockTimeline(), + collection: createMockCollection(), + ...opts, +}); diff --git a/frontend/src/metabase/timelines/components/TimelineEmptyState/TimelineEmptyState.unit.spec.tsx b/frontend/src/metabase/timelines/components/TimelineEmptyState/TimelineEmptyState.unit.spec.tsx new file mode 100644 index 00000000000..27349b6d7cf --- /dev/null +++ b/frontend/src/metabase/timelines/components/TimelineEmptyState/TimelineEmptyState.unit.spec.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { createMockCollection } from "metabase-types/api/mocks"; +import TimelineEmptyState, { + TimelineEmptyStateProps, +} from "./TimelineEmptyState"; + +describe("TimelineEmptyState", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2015, 0, 1)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("should render an empty state with the current date", () => { + const props = getProps(); + + render(<TimelineEmptyState {...props} />); + + expect(screen.getByText("January 1, 2015")); + }); +}); + +const getProps = ( + opts?: Partial<TimelineEmptyStateProps>, +): TimelineEmptyStateProps => ({ + collection: createMockCollection(), + ...opts, +}); diff --git a/frontend/src/metabase/timelines/components/TimelineEntryModal/index.ts b/frontend/src/metabase/timelines/components/TimelineEntryModal/index.ts deleted file mode 100644 index 1e014f3eb5e..00000000000 --- a/frontend/src/metabase/timelines/components/TimelineEntryModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./TimelineEntryModal"; diff --git a/frontend/src/metabase/timelines/components/TimelineEntryModal/TimelineEntryModal.tsx b/frontend/src/metabase/timelines/components/TimelineIndexModal/TimelineIndexModal.tsx similarity index 60% rename from frontend/src/metabase/timelines/components/TimelineEntryModal/TimelineEntryModal.tsx rename to frontend/src/metabase/timelines/components/TimelineIndexModal/TimelineIndexModal.tsx index bf453657725..07254de13a9 100644 --- a/frontend/src/metabase/timelines/components/TimelineEntryModal/TimelineEntryModal.tsx +++ b/frontend/src/metabase/timelines/components/TimelineIndexModal/TimelineIndexModal.tsx @@ -4,23 +4,28 @@ import TimelineDetailsModal from "../../containers/TimelineDetailsModal"; import TimelineListModal from "../../containers/TimelineListModal"; import { ModalParams } from "../../types"; -export interface TimelineEntryModalProps { +export interface TimelineIndexModalProps { timelines: Timeline[]; params: ModalParams; onClose?: () => void; } -const TimelineEntryModal = ({ +const TimelineIndexModal = ({ timelines, params, onClose, -}: TimelineEntryModalProps): JSX.Element => { +}: TimelineIndexModalProps): JSX.Element => { if (timelines.length === 1) { - const newParams = { ...params, timelineId: timelines[0].id }; - return <TimelineDetailsModal params={newParams} onClose={onClose} />; + return ( + <TimelineDetailsModal + params={{ ...params, timelineId: timelines[0].id }} + isDefault={true} + onClose={onClose} + /> + ); } else { return <TimelineListModal params={params} onClose={onClose} />; } }; -export default TimelineEntryModal; +export default TimelineIndexModal; diff --git a/frontend/src/metabase/timelines/components/TimelineIndexModal/index.ts b/frontend/src/metabase/timelines/components/TimelineIndexModal/index.ts new file mode 100644 index 00000000000..b5ab6de59d8 --- /dev/null +++ b/frontend/src/metabase/timelines/components/TimelineIndexModal/index.ts @@ -0,0 +1 @@ +export { default } from "./TimelineIndexModal"; diff --git a/frontend/src/metabase/timelines/components/TimelineListModal/TimelineListModal.styled.tsx b/frontend/src/metabase/timelines/components/TimelineListModal/TimelineListModal.styled.tsx index 5918fe3b3a3..c371ad31f06 100644 --- a/frontend/src/metabase/timelines/components/TimelineListModal/TimelineListModal.styled.tsx +++ b/frontend/src/metabase/timelines/components/TimelineListModal/TimelineListModal.styled.tsx @@ -3,7 +3,7 @@ import styled from "@emotion/styled"; export const ModalRoot = styled.div` display: flex; flex-direction: column; - min-height: 565px; + min-height: 573px; max-height: 90vh; `; diff --git a/frontend/src/metabase/timelines/components/TimelineListModal/TimelineListModal.unit.spec.tsx b/frontend/src/metabase/timelines/components/TimelineListModal/TimelineListModal.unit.spec.tsx new file mode 100644 index 00000000000..d226634f99e --- /dev/null +++ b/frontend/src/metabase/timelines/components/TimelineListModal/TimelineListModal.unit.spec.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { + createMockCollection, + createMockTimeline, +} from "metabase-types/api/mocks"; +import TimelineListModal, { TimelineListModalProps } from "./TimelineListModal"; + +describe("TimelineListModal", () => { + it("should render a list of timelines", () => { + const props = getProps({ + timelines: [createMockTimeline({ name: "Releases" })], + collection: createMockCollection({ can_write: true }), + }); + + render(<TimelineListModal {...props} />); + + expect(screen.getByText("Releases")).toBeInTheDocument(); + }); + + it("should render an empty state when there are no timelines", () => { + const props = getProps({ + timelines: [], + collection: createMockCollection({ can_write: true }), + }); + + render(<TimelineListModal {...props} />); + + expect(screen.getByText("Add an event")).toBeInTheDocument(); + }); +}); + +const getProps = ( + opts?: Partial<TimelineListModalProps>, +): TimelineListModalProps => ({ + timelines: [], + collection: createMockCollection(), + ...opts, +}); diff --git a/frontend/src/metabase/timelines/containers/DeleteEventModal/DeleteEventModal.tsx b/frontend/src/metabase/timelines/containers/DeleteEventModal/DeleteEventModal.tsx index b3ca0527647..354bfb13671 100644 --- a/frontend/src/metabase/timelines/containers/DeleteEventModal/DeleteEventModal.tsx +++ b/frontend/src/metabase/timelines/containers/DeleteEventModal/DeleteEventModal.tsx @@ -1,23 +1,40 @@ import { connect } from "react-redux"; -import { goBack } from "react-router-redux"; +import { goBack, push } from "react-router-redux"; import _ from "underscore"; import * as Urls from "metabase/lib/urls"; +import Collections from "metabase/entities/collections"; +import Timelines from "metabase/entities/timelines"; import TimelineEvents from "metabase/entities/timeline-events"; -import { TimelineEvent } from "metabase-types/api"; +import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; import { State } from "metabase-types/store"; import DeleteEventModal from "../../components/DeleteEventModal"; import { ModalProps } from "../../types"; +const timelineProps = { + id: (state: State, props: ModalProps) => + Urls.extractEntityId(props.params.timelineId), + query: { include: "events" }, +}; + const timelineEventProps = { id: (state: State, props: ModalProps) => Urls.extractEntityId(props.params.timelineEventId), entityAlias: "event", }; +const collectionProps = { + id: (state: State, props: ModalProps) => + Urls.extractCollectionId(props.params.slug), +}; + const mapDispatchToProps = (dispatch: any) => ({ - onSubmit: async (event: TimelineEvent) => { + onSubmit: async ( + event: TimelineEvent, + timeline: Timeline, + collection: Collection, + ) => { await dispatch(TimelineEvents.actions.delete(event)); - dispatch(goBack()); + dispatch(push(Urls.timelineArchiveInCollection(timeline, collection))); }, onCancel: () => { dispatch(goBack()); @@ -25,6 +42,8 @@ const mapDispatchToProps = (dispatch: any) => ({ }); export default _.compose( + Timelines.load(timelineProps), TimelineEvents.load(timelineEventProps), + Collections.load(collectionProps), connect(null, mapDispatchToProps), )(DeleteEventModal); diff --git a/frontend/src/metabase/timelines/containers/EditEventModal/EditEventModal.tsx b/frontend/src/metabase/timelines/containers/EditEventModal/EditEventModal.tsx index af34f9f6efd..4cc20c1763d 100644 --- a/frontend/src/metabase/timelines/containers/EditEventModal/EditEventModal.tsx +++ b/frontend/src/metabase/timelines/containers/EditEventModal/EditEventModal.tsx @@ -1,27 +1,50 @@ import { connect } from "react-redux"; -import { goBack } from "react-router-redux"; +import { goBack, push } from "react-router-redux"; import _ from "underscore"; import * as Urls from "metabase/lib/urls"; +import Collections from "metabase/entities/collections"; +import Timelines from "metabase/entities/timelines"; import TimelineEvents from "metabase/entities/timeline-events"; -import { TimelineEvent } from "metabase-types/api"; +import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; import { State } from "metabase-types/store"; import EditEventModal from "../../components/EditEventModal"; +import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; import { ModalProps } from "../../types"; +const timelineProps = { + id: (state: State, props: ModalProps) => + Urls.extractEntityId(props.params.timelineId), + query: { include: "events" }, +}; + const timelineEventProps = { id: (state: State, props: ModalProps) => Urls.extractEntityId(props.params.timelineEventId), entityAlias: "event", + LoadingAndErrorWrapper, +}; + +const collectionProps = { + id: (state: State, props: ModalProps) => + Urls.extractCollectionId(props.params.slug), }; const mapDispatchToProps = (dispatch: any) => ({ - onSubmit: async (event: TimelineEvent) => { + onSubmit: async ( + event: TimelineEvent, + timeline: Timeline, + collection: Collection, + ) => { await dispatch(TimelineEvents.actions.update(event)); - dispatch(goBack()); + dispatch(push(Urls.timelineInCollection(timeline, collection))); }, - onArchive: async (event: TimelineEvent) => { + onArchive: async ( + event: TimelineEvent, + timeline: Timeline, + collection: Collection, + ) => { await dispatch(TimelineEvents.actions.setArchived(event, true)); - dispatch(goBack()); + dispatch(push(Urls.timelineInCollection(timeline, collection))); }, onCancel: () => { dispatch(goBack()); @@ -29,6 +52,8 @@ const mapDispatchToProps = (dispatch: any) => ({ }); export default _.compose( + Timelines.load(timelineProps), TimelineEvents.load(timelineEventProps), + Collections.load(collectionProps), connect(null, mapDispatchToProps), )(EditEventModal); diff --git a/frontend/src/metabase/timelines/containers/EditTimelineModal/EditTimelineModal.tsx b/frontend/src/metabase/timelines/containers/EditTimelineModal/EditTimelineModal.tsx index c8722d3f1b6..96a72e59b46 100644 --- a/frontend/src/metabase/timelines/containers/EditTimelineModal/EditTimelineModal.tsx +++ b/frontend/src/metabase/timelines/containers/EditTimelineModal/EditTimelineModal.tsx @@ -1,27 +1,35 @@ import { connect } from "react-redux"; -import { goBack } from "react-router-redux"; +import { goBack, push } from "react-router-redux"; import _ from "underscore"; import * as Urls from "metabase/lib/urls"; +import Collections from "metabase/entities/collections"; import Timelines from "metabase/entities/timelines"; -import { Timeline } from "metabase-types/api"; +import { Collection, Timeline } from "metabase-types/api"; import { State } from "metabase-types/store"; import EditTimelineModal from "../../components/EditTimelineModal"; +import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; import { ModalProps } from "../../types"; const timelineProps = { id: (state: State, props: ModalProps) => Urls.extractEntityId(props.params.timelineId), query: { include: "events" }, + LoadingAndErrorWrapper, +}; + +const collectionProps = { + id: (state: State, props: ModalProps) => + Urls.extractCollectionId(props.params.slug), }; const mapDispatchToProps = (dispatch: any) => ({ - onSubmit: async (timeline: Timeline) => { + onSubmit: async (timeline: Timeline, collection: Collection) => { await dispatch(Timelines.actions.update(timeline)); - dispatch(goBack()); + dispatch(push(Urls.timelineInCollection(timeline, collection))); }, - onArchive: async (timeline: Timeline) => { + onArchive: async (timeline: Timeline, collection: Collection) => { await dispatch(Timelines.actions.setArchived(timeline, true)); - dispatch(goBack()); + dispatch(push(Urls.timelinesInCollection(collection))); }, onCancel: () => { dispatch(goBack()); @@ -30,5 +38,6 @@ const mapDispatchToProps = (dispatch: any) => ({ export default _.compose( Timelines.load(timelineProps), + Collections.load(collectionProps), connect(null, mapDispatchToProps), )(EditTimelineModal); diff --git a/frontend/src/metabase/timelines/containers/NewEventModal/NewEventModal.tsx b/frontend/src/metabase/timelines/containers/NewEventModal/NewEventModal.tsx index cf790e4acd3..af8695ef495 100644 --- a/frontend/src/metabase/timelines/containers/NewEventModal/NewEventModal.tsx +++ b/frontend/src/metabase/timelines/containers/NewEventModal/NewEventModal.tsx @@ -8,12 +8,14 @@ import TimelineEvents from "metabase/entities/timeline-events"; import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; import { State } from "metabase-types/store"; import NewEventModal from "../../components/NewEventModal"; +import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; import { ModalProps } from "../../types"; const timelineProps = { id: (state: State, props: ModalProps) => Urls.extractEntityId(props.params.timelineId), query: { include: "events" }, + LoadingAndErrorWrapper, }; const collectionProps = { @@ -22,9 +24,13 @@ const collectionProps = { }; const mapDispatchToProps = (dispatch: any) => ({ - onSubmit: async (values: Partial<TimelineEvent>) => { + onSubmit: async ( + values: Partial<TimelineEvent>, + collection: Collection, + timeline: Timeline, + ) => { await dispatch(TimelineEvents.actions.create(values)); - dispatch(goBack()); + dispatch(push(Urls.timelineInCollection(timeline, collection))); }, onCancel: () => { dispatch(goBack()); diff --git a/frontend/src/metabase/timelines/containers/NewEventWithTimelineModal/NewEventWithTimelineModal.tsx b/frontend/src/metabase/timelines/containers/NewEventWithTimelineModal/NewEventWithTimelineModal.tsx index 68afd53f5a5..729d3642cc0 100644 --- a/frontend/src/metabase/timelines/containers/NewEventWithTimelineModal/NewEventWithTimelineModal.tsx +++ b/frontend/src/metabase/timelines/containers/NewEventWithTimelineModal/NewEventWithTimelineModal.tsx @@ -1,5 +1,5 @@ import { connect } from "react-redux"; -import { goBack } from "react-router-redux"; +import { goBack, push } from "react-router-redux"; import _ from "underscore"; import * as Urls from "metabase/lib/urls"; import Collections from "metabase/entities/collections"; @@ -7,11 +7,13 @@ import Timelines from "metabase/entities/timelines"; import { Collection, TimelineEvent } from "metabase-types/api"; import { State } from "metabase-types/store"; import NewEventModal from "../../components/NewEventModal"; +import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; import { ModalProps } from "../../types"; const collectionProps = { id: (state: State, props: ModalProps) => Urls.extractCollectionId(props.params.slug), + LoadingAndErrorWrapper, }; const mapDispatchToProps = (dispatch: any) => ({ @@ -19,7 +21,7 @@ const mapDispatchToProps = (dispatch: any) => ({ const action = Timelines.actions.createWithEvent(values, collection); const response = await dispatch(action); const timeline = Timelines.HACK_getObjectFromAction(response); - dispatch(goBack()); + dispatch(push(Urls.timelineInCollection(timeline, collection))); }, onCancel: () => { dispatch(goBack()); diff --git a/frontend/src/metabase/timelines/containers/NewTimelineModal/NewTimelineModal.tsx b/frontend/src/metabase/timelines/containers/NewTimelineModal/NewTimelineModal.tsx index 5ff6198c8a3..64fc36693f1 100644 --- a/frontend/src/metabase/timelines/containers/NewTimelineModal/NewTimelineModal.tsx +++ b/frontend/src/metabase/timelines/containers/NewTimelineModal/NewTimelineModal.tsx @@ -7,11 +7,13 @@ import Timelines from "metabase/entities/timelines"; import { Collection, Timeline } from "metabase-types/api"; import { State } from "metabase-types/store"; import NewTimelineModal from "../../components/NewTimelineModal"; +import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; import { ModalProps } from "../../types"; const collectionProps = { id: (state: State, props: ModalProps) => Urls.extractCollectionId(props.params.slug), + LoadingAndErrorWrapper, }; const mapDispatchToProps = (dispatch: any) => ({ diff --git a/frontend/src/metabase/timelines/containers/TimelineArchiveModal/TimelineArchiveModal.tsx b/frontend/src/metabase/timelines/containers/TimelineArchiveModal/TimelineArchiveModal.tsx index ca1918b9327..90b2d1346d3 100644 --- a/frontend/src/metabase/timelines/containers/TimelineArchiveModal/TimelineArchiveModal.tsx +++ b/frontend/src/metabase/timelines/containers/TimelineArchiveModal/TimelineArchiveModal.tsx @@ -1,18 +1,21 @@ import { connect } from "react-redux"; +import { push } from "react-router-redux"; import _ from "underscore"; import * as Urls from "metabase/lib/urls"; import Collections from "metabase/entities/collections"; import Timelines from "metabase/entities/timelines"; import TimelineEvents from "metabase/entities/timeline-events"; -import { TimelineEvent } from "metabase-types/api"; +import { Collection, TimelineEvent } from "metabase-types/api"; import { State } from "metabase-types/store"; import TimelineDetailsModal from "../../components/TimelineDetailsModal"; +import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; import { ModalProps } from "../../types"; const timelineProps = { id: (state: State, props: ModalProps) => Urls.extractEntityId(props.params.timelineId), query: { include: "events", archived: true }, + LoadingAndErrorWrapper, }; const collectionProps = { @@ -28,6 +31,9 @@ const mapDispatchToProps = (dispatch: any) => ({ onUnarchive: async (event: TimelineEvent) => { await dispatch(TimelineEvents.actions.setArchived(event, false)); }, + onGoBack: (collection: Collection) => { + dispatch(push(Urls.timelinesInCollection(collection))); + }, }); export default _.compose( diff --git a/frontend/src/metabase/timelines/containers/TimelineDetailsModal/TimelineDetailsModal.tsx b/frontend/src/metabase/timelines/containers/TimelineDetailsModal/TimelineDetailsModal.tsx index 519886cc80c..8f5b45bd447 100644 --- a/frontend/src/metabase/timelines/containers/TimelineDetailsModal/TimelineDetailsModal.tsx +++ b/frontend/src/metabase/timelines/containers/TimelineDetailsModal/TimelineDetailsModal.tsx @@ -1,18 +1,21 @@ import { connect } from "react-redux"; +import { push } from "react-router-redux"; import _ from "underscore"; import * as Urls from "metabase/lib/urls"; import Collections from "metabase/entities/collections"; import Timelines from "metabase/entities/timelines"; import TimelineEvents from "metabase/entities/timeline-events"; -import { TimelineEvent } from "metabase-types/api"; +import { Collection, TimelineEvent } from "metabase-types/api"; import { State } from "metabase-types/store"; import TimelineDetailsModal from "../../components/TimelineDetailsModal"; +import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; import { ModalProps } from "../../types"; const timelineProps = { id: (state: State, props: ModalProps) => Urls.extractEntityId(props.params.timelineId), query: { include: "events" }, + LoadingAndErrorWrapper, }; const collectionProps = { @@ -24,6 +27,9 @@ const mapDispatchToProps = (dispatch: any) => ({ onArchive: async (event: TimelineEvent) => { await dispatch(TimelineEvents.actions.setArchived(event, true)); }, + onGoBack: (collection: Collection) => { + dispatch(push(Urls.timelinesInCollection(collection))); + }, }); export default _.compose( diff --git a/frontend/src/metabase/timelines/containers/TimelineEntryModal/index.ts b/frontend/src/metabase/timelines/containers/TimelineEntryModal/index.ts deleted file mode 100644 index 1e014f3eb5e..00000000000 --- a/frontend/src/metabase/timelines/containers/TimelineEntryModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./TimelineEntryModal"; diff --git a/frontend/src/metabase/timelines/containers/TimelineEntryModal/TimelineEntryModal.tsx b/frontend/src/metabase/timelines/containers/TimelineIndexModal/TimelineIndexModal.tsx similarity index 74% rename from frontend/src/metabase/timelines/containers/TimelineEntryModal/TimelineEntryModal.tsx rename to frontend/src/metabase/timelines/containers/TimelineIndexModal/TimelineIndexModal.tsx index 2d2d68a5c91..9a0316b370c 100644 --- a/frontend/src/metabase/timelines/containers/TimelineEntryModal/TimelineEntryModal.tsx +++ b/frontend/src/metabase/timelines/containers/TimelineIndexModal/TimelineIndexModal.tsx @@ -1,7 +1,7 @@ import * as Urls from "metabase/lib/urls"; import Timelines from "metabase/entities/timelines"; import { State } from "metabase-types/store"; -import TimelineEntryModal from "../../components/TimelineEntryModal"; +import TimelineIndexModal from "../../components/TimelineIndexModal"; import { ModalProps } from "../../types"; const timelineProps = { @@ -11,4 +11,4 @@ const timelineProps = { }), }; -export default Timelines.loadList(timelineProps)(TimelineEntryModal); +export default Timelines.loadList(timelineProps)(TimelineIndexModal); diff --git a/frontend/src/metabase/timelines/containers/TimelineIndexModal/index.ts b/frontend/src/metabase/timelines/containers/TimelineIndexModal/index.ts new file mode 100644 index 00000000000..b5ab6de59d8 --- /dev/null +++ b/frontend/src/metabase/timelines/containers/TimelineIndexModal/index.ts @@ -0,0 +1 @@ +export { default } from "./TimelineIndexModal"; diff --git a/frontend/src/metabase/timelines/routes.tsx b/frontend/src/metabase/timelines/routes.tsx index 907bf15136d..213f0d12e99 100644 --- a/frontend/src/metabase/timelines/routes.tsx +++ b/frontend/src/metabase/timelines/routes.tsx @@ -8,7 +8,7 @@ import NewEventWithTimelineModal from "./containers/NewEventWithTimelineModal"; import NewTimelineModal from "./containers/NewTimelineModal"; import TimelineArchiveModal from "./containers/TimelineArchiveModal"; import TimelineDetailsModal from "./containers/TimelineDetailsModal"; -import TimelineEntryModal from "./containers/TimelineEntryModal"; +import TimelineIndexModal from "./containers/TimelineIndexModal"; const getRoutes = () => { return ( @@ -16,55 +16,64 @@ const getRoutes = () => { <ModalRoute {...{ path: "timelines", - modal: TimelineEntryModal, + modal: TimelineIndexModal, + modalProps: { enableTransition: false }, }} /> <ModalRoute {...{ path: "timelines/new", modal: NewTimelineModal, + modalProps: { enableTransition: false }, }} /> <ModalRoute {...{ path: "timelines/:timelineId", modal: TimelineDetailsModal, + modalProps: { enableTransition: false }, }} /> <ModalRoute {...{ path: "timelines/:timelineId/edit", modal: EditTimelineModal, + modalProps: { enableTransition: false }, }} /> <ModalRoute {...{ path: "timelines/:timelineId/archive", modal: TimelineArchiveModal, + modalProps: { enableTransition: false }, }} /> <ModalRoute {...{ path: "timelines/new/events/new", modal: NewEventWithTimelineModal, + modalProps: { enableTransition: false }, }} /> <ModalRoute {...{ path: "timelines/:timelineId/events/new", modal: NewEventModal, + modalProps: { enableTransition: false }, }} /> <ModalRoute {...{ path: "timelines/:timelineId/events/:timelineEventId/edit", modal: EditEventModal, + modalProps: { enableTransition: false }, }} /> <ModalRoute {...{ path: "timelines/:timelineId/events/:timelineEventId/delete", modal: DeleteEventModal, + modalProps: { enableTransition: false }, }} /> </Fragment> diff --git a/frontend/test/__support__/e2e/commands.js b/frontend/test/__support__/e2e/commands.js index 068b428b2b3..8c019985ed9 100644 --- a/frontend/test/__support__/e2e/commands.js +++ b/frontend/test/__support__/e2e/commands.js @@ -10,11 +10,13 @@ import "./commands/api/collection"; import "./commands/api/moderation"; import "./commands/api/pulse"; import "./commands/api/user"; +import "./commands/api/timeline"; import "./commands/api/composite/createQuestionAndDashboard"; import "./commands/api/composite/createNativeQuestionAndDashboard"; import "./commands/api/composite/createQuestionAndAddToDashboard"; import "./commands/api/composite/createDashboardWithQuestions"; +import "./commands/api/composite/createTimelineWithEvents"; import "./commands/user/createUser"; import "./commands/user/authentication"; diff --git a/frontend/test/__support__/e2e/commands/api/composite/createTimelineWithEvents.js b/frontend/test/__support__/e2e/commands/api/composite/createTimelineWithEvents.js new file mode 100644 index 00000000000..96110a8ee9e --- /dev/null +++ b/frontend/test/__support__/e2e/commands/api/composite/createTimelineWithEvents.js @@ -0,0 +1,16 @@ +import { cypressWaitAll } from "__support__/e2e/cypress"; + +Cypress.Commands.add("createTimelineWithEvents", ({ timeline, events }) => { + return cy.createTimeline(timeline).then(({ body: timeline }) => { + return cypressWaitAll( + events.map(query => + cy.createTimelineEvent({ ...query, timeline_id: timeline.id }), + ), + ).then(events => { + return { + timeline, + events, + }; + }); + }); +}); diff --git a/frontend/test/__support__/e2e/commands/api/timeline.js b/frontend/test/__support__/e2e/commands/api/timeline.js new file mode 100644 index 00000000000..ab614e70c8f --- /dev/null +++ b/frontend/test/__support__/e2e/commands/api/timeline.js @@ -0,0 +1,43 @@ +Cypress.Commands.add( + "createTimeline", + ({ + name = "Timeline", + description, + icon = "star", + collection_id, + archived = false, + } = {}) => { + return cy.request("POST", "/api/timeline", { + name, + description, + icon, + collection_id, + archived, + }); + }, +); + +Cypress.Commands.add( + "createTimelineEvent", + ({ + name = "Event", + description, + icon = "star", + timestamp = "2020-01-01T00:00:00Z", + time_matters = false, + timezone = "UTC", + timeline_id, + archived = false, + }) => { + return cy.request("POST", "/api/timeline-event", { + name, + description, + icon, + timestamp, + time_matters, + timezone, + timeline_id, + archived, + }); + }, +); diff --git a/frontend/test/metabase-visual/collections/timelines.cy.spec.js b/frontend/test/metabase-visual/collections/timelines.cy.spec.js new file mode 100644 index 00000000000..b2ee8a57362 --- /dev/null +++ b/frontend/test/metabase-visual/collections/timelines.cy.spec.js @@ -0,0 +1,30 @@ +import { restore } from "__support__/e2e/cypress"; + +const EVENTS = [ + { name: "Event 1", timestamp: "2020-01-01", icon: "star" }, + { name: "Event 2", timestamp: "2020-03-01", icon: "mail" }, + { name: "Event 3", timestamp: "2020-02-01", icon: "balloons" }, +]; + +describe("timelines", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + }); + + it("should display empty state", () => { + cy.visit("/collection/root/timelines"); + + cy.findByText("Our analytics events"); + cy.percySnapshot(); + }); + + it("should display timeline events", () => { + cy.createTimelineWithEvents({ events: EVENTS }).then(({ timeline }) => { + cy.visit(`/collection/root/timelines/${timeline.id}`); + + cy.findByText("Timeline"); + cy.percySnapshot(); + }); + }); +}); diff --git a/frontend/test/metabase/scenarios/collections/timelines.cy.spec.js b/frontend/test/metabase/scenarios/collections/timelines.cy.spec.js new file mode 100644 index 00000000000..9b71433e7be --- /dev/null +++ b/frontend/test/metabase/scenarios/collections/timelines.cy.spec.js @@ -0,0 +1,274 @@ +import { restore } from "__support__/e2e/cypress"; + +describe("scenarios > collections > timelines", () => { + beforeEach(() => { + restore(); + }); + + describe("as admin", () => { + beforeEach(() => { + cy.signInAsAdmin(); + }); + + it("should create the first event and timeline", () => { + cy.visit("/collection/root"); + + cy.findByLabelText("calendar icon").click(); + + cy.findByText("Add an event").click(); + cy.findByLabelText("Event name").type("RC1"); + cy.findByLabelText("Date").type("10/20/2020"); + cy.button("Create").click(); + + cy.findByText("RC1"); + cy.findByText("October 20, 2020"); + cy.findByLabelText("star icon"); + + cy.findByText("Add an event").click(); + cy.findByLabelText("Event name").type("RC2"); + cy.findByLabelText("Date").type("5/12/2021"); + cy.findByText("Star").click(); + cy.findByText("Balloons").click(); + cy.button("Create").click(); + + cy.findByText("RC2"); + cy.findByText("May 12, 2021"); + cy.findByLabelText("balloons icon"); + }); + + it("should search for events", () => { + cy.createTimelineWithEvents({ + events: [ + { name: "RC1" }, + { name: "RC2" }, + { name: "v1.0" }, + { name: "v1.1" }, + ], + }); + + cy.visit("/collection/root/timelines"); + + cy.findByPlaceholderText("Search for an event").type("V1"); + cy.findByText("v1.0"); + cy.findByText("v1.1"); + cy.findByText("RC1").should("not.exist"); + cy.findByText("RC2").should("not.exist"); + }); + + it("should create an event with date", () => { + cy.visit("/collection/root"); + + cy.findByLabelText("calendar icon").click(); + cy.findByText("Add an event").click(); + + cy.findByLabelText("Event name").type("RC1"); + cy.findByRole("button", { name: "calendar icon" }).click(); + cy.findByText("15").click(); + cy.findByText("Done").click(); + cy.findByText("Create").click(); + + cy.findByText("Our analytics events"); + cy.findByText("RC1"); + cy.findByText(/15/); + }); + + it("should create an event with date and time", () => { + cy.visit("/collection/root"); + + cy.findByLabelText("calendar icon").click(); + cy.findByText("Add an event").click(); + + cy.findByLabelText("Event name").type("RC1"); + cy.findByRole("button", { name: "calendar icon" }).click(); + cy.findByText("15").click(); + cy.findByText("Add time").click(); + cy.findByLabelText("Hours") + .clear() + .type("10"); + cy.findByLabelText("Minutes") + .clear() + .type("20"); + cy.findByText("Done").click(); + cy.findByText("Create").click(); + + cy.findByText("Our analytics events"); + cy.findByText("RC1"); + cy.findByText(/15/); + cy.findByText(/10:20 AM/); + }); + + it("should edit an event", () => { + cy.createTimelineWithEvents({ events: [{ name: "RC1" }] }); + cy.visit("/collection/root/timelines"); + + openEventMenu("RC1"); + cy.findByText("Edit event").click(); + cy.findByLabelText("Event name") + .clear() + .type("RC2"); + cy.button("Update").click(); + + cy.findByText("RC2"); + }); + + it("should archive an event when editing this event", () => { + cy.createTimelineWithEvents({ + timeline: { name: "Releases" }, + events: [{ name: "RC1" }, { name: "RC2" }], + }); + + cy.visit("/collection/root/timelines"); + + openEventMenu("RC1"); + cy.findByText("Edit event").click(); + cy.findByText("Archive event").click(); + + cy.findByText("Releases"); + cy.findByText("RC1").should("not.exist"); + }); + + it("should archive an event from the timeline and undo", () => { + cy.createTimelineWithEvents({ + timeline: { name: "Releases" }, + events: [{ name: "RC1" }, { name: "RC2" }], + }); + + cy.visit("/collection/root/timelines"); + + openEventMenu("RC1"); + cy.findByText("Archive event").click(); + cy.findByText("RC1").should("not.exist"); + cy.findByText("Undo").click(); + cy.findByText("RC1"); + }); + + it("should unarchive an event from the archive and undo", () => { + cy.createTimelineWithEvents({ + timeline: { name: "Releases" }, + events: [{ name: "RC1", archived: true }], + }); + + cy.visit("/collection/root/timelines"); + openTimelineMenu("Releases"); + cy.findByText("View archived events").click(); + + cy.findByText("Archived events"); + openEventMenu("RC1"); + cy.findByText("Unarchive event").click(); + cy.findByText("No events found"); + + cy.findByText("Undo").click(); + cy.findByText("RC1"); + }); + + it("should delete an event", () => { + cy.createTimelineWithEvents({ + timeline: { name: "Releases" }, + events: [{ name: "RC1", archived: true }], + }); + + cy.visit("/collection/root/timelines"); + openTimelineMenu("Releases"); + cy.findByText("View archived events").click(); + + cy.findByText("Archived events"); + openEventMenu("RC1"); + cy.findByText("Delete event").click(); + cy.findByText("Delete").click(); + cy.findByText("No events found"); + }); + + it("should create an additional timeline", () => { + cy.createTimelineWithEvents({ + timeline: { name: "Releases" }, + events: [{ name: "RC1" }], + }); + + cy.visit("/collection/root/timelines"); + openTimelineMenu("Releases"); + cy.findByText("New timeline").click(); + cy.findByLabelText("Timeline name").type("Launches"); + cy.findByText("Create").click(); + + cy.findByText("Launches"); + cy.findByText("Add an event"); + }); + + it("should edit a timeline", () => { + cy.createTimelineWithEvents({ + timeline: { name: "Releases" }, + events: [{ name: "RC1" }], + }); + + cy.visit("/collection/root/timelines"); + openTimelineMenu("Releases"); + cy.findByText("Edit timeline details").click(); + cy.findByLabelText("Timeline name") + .clear() + .type("Launches"); + cy.findByText("Update").click(); + + cy.findByText("Launches"); + }); + + it("should archive and unarchive a timeline", () => { + cy.createTimelineWithEvents({ + timeline: { name: "Releases" }, + events: [{ name: "RC1" }, { name: "RC2" }], + }); + + cy.visit("/collection/root/timelines"); + openTimelineMenu("Releases"); + cy.findByText("Edit timeline details").click(); + cy.findByText("Archive timeline and all events").click(); + cy.findByText("Our analytics events"); + cy.findByText("Add an event"); + + cy.findByText("Undo").click(); + cy.findByText("Releases"); + cy.findByText("RC1"); + cy.findByText("RC2"); + }); + }); + + describe("as readonly user", () => { + it("should not allow creating new timelines in collections", () => { + cy.signIn("readonly"); + cy.visit("/collection/root"); + + cy.findByLabelText("calendar icon").click(); + cy.findByText("Our analytics events"); + cy.findByText("Add an event").should("not.exist"); + }); + + it("should not allow creating new events in existing timelines", () => { + cy.signInAsAdmin(); + cy.createTimelineWithEvents({ + timeline: { name: "Releases" }, + events: [{ name: "RC1" }], + }); + cy.signOut(); + + cy.signIn("readonly"); + cy.visit("/collection/root"); + cy.findByLabelText("calendar icon").click(); + cy.findByText("Releases"); + cy.findByText("Add an event").should("not.exist"); + }); + }); +}); + +const openEventMenu = name => { + return cy + .findByText(name) + .parent() + .parent() + .within(() => cy.findByLabelText("ellipsis icon").click()); +}; + +const openTimelineMenu = name => { + return cy + .findByText(name) + .parent() + .within(() => cy.findByLabelText("ellipsis icon").click()); +}; -- GitLab