Poll history - filter by active or ended (#10098)

* wip

* remove dupe

* use poll model relations in all cases

* update mpollbody tests to use poll instance

* update poll fetching login in pinned messages card

* add pinned polls to room polls state

* add spinner while relations are still loading

* handle no poll in end poll dialog

* strict errors

* render a poll body that errors for poll end events

* add fetching logic to pollend tile

* extract poll testing utilities

* test mpollend

* strict fix

* more strict fix

* strict fix for forwardref

* add filter component

* update poll test utils

* add unstyled filter tab group

* filtertabgroup snapshot

* lint

* update test util setupRoomWithPollEvents to allow testing multiple polls in one room

* style filter tabs

* test error message for past polls

* sort polls list by latest

* move FilterTabGroup into generic components

* comments

* Update src/components/views/dialogs/polls/types.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
Kerry 2023-02-13 09:19:45 +13:00 committed by GitHub
parent f0f50485d7
commit 18ab325eaf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 388 additions and 61 deletions

View file

@ -19,6 +19,7 @@
@import "./components/views/context_menus/_KebabContextMenu.pcss";
@import "./components/views/dialogs/polls/_PollListItem.pcss";
@import "./components/views/elements/_FilterDropdown.pcss";
@import "./components/views/elements/_FilterTabGroup.pcss";
@import "./components/views/elements/_LearnMore.pcss";
@import "./components/views/location/_EnableLiveShare.pcss";
@import "./components/views/location/_LiveDurationDropdown.pcss";

View file

@ -0,0 +1,46 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_FilterTabGroup {
color: $primary-content;
label {
margin-right: $spacing-12;
cursor: pointer;
span {
display: inline-block;
line-height: $font-24px;
}
}
input[type="radio"] {
appearance: none;
margin: 0;
padding: 0;
&:focus,
&:hover {
& + span {
color: $secondary-content;
}
}
&:checked + span {
color: $accent;
font-weight: $font-semi-bold;
// underline
box-shadow: 0 1.5px 0 0 currentColor;
}
}
}

View file

@ -14,26 +14,47 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { useEffect, useState } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent, Poll } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../languageHandler";
import BaseDialog from "../BaseDialog";
import { IDialogProps } from "../IDialogProps";
import { PollHistoryList } from "./PollHistoryList";
import { getPolls } from "./usePollHistory";
import { PollHistoryFilter } from "./types";
import { usePolls } from "./usePollHistory";
type PollHistoryDialogProps = Pick<IDialogProps, "onFinished"> & {
roomId: string;
matrixClient: MatrixClient;
};
const sortEventsByLatest = (left: MatrixEvent, right: MatrixEvent): number => right.getTs() - left.getTs();
const filterPolls =
(filter: PollHistoryFilter) =>
(poll: Poll): boolean =>
(filter === "ACTIVE") !== poll.isEnded;
const filterAndSortPolls = (polls: Map<string, Poll>, filter: PollHistoryFilter): MatrixEvent[] => {
return [...polls.values()]
.filter(filterPolls(filter))
.map((poll) => poll.rootEvent)
.sort(sortEventsByLatest);
};
export const PollHistoryDialog: React.FC<PollHistoryDialogProps> = ({ roomId, matrixClient, onFinished }) => {
const pollStartEvents = getPolls(roomId, matrixClient);
const { polls } = usePolls(roomId, matrixClient);
const [filter, setFilter] = useState<PollHistoryFilter>("ACTIVE");
const [pollStartEvents, setPollStartEvents] = useState(filterAndSortPolls(polls, filter));
useEffect(() => {
setPollStartEvents(filterAndSortPolls(polls, filter));
}, [filter, polls]);
return (
<BaseDialog title={_t("Polls history")} onFinished={onFinished}>
<div className="mx_PollHistoryDialog_content">
<PollHistoryList pollStartEvents={pollStartEvents} />
<PollHistoryList pollStartEvents={pollStartEvents} filter={filter} onFilterChange={setFilter} />
</div>
</BaseDialog>
);

View file

@ -19,13 +19,26 @@ import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import PollListItem from "./PollListItem";
import { _t } from "../../../../languageHandler";
import { FilterTabGroup } from "../../elements/FilterTabGroup";
import { PollHistoryFilter } from "./types";
type PollHistoryListProps = {
pollStartEvents: MatrixEvent[];
filter: PollHistoryFilter;
onFilterChange: (filter: PollHistoryFilter) => void;
};
export const PollHistoryList: React.FC<PollHistoryListProps> = ({ pollStartEvents }) => {
export const PollHistoryList: React.FC<PollHistoryListProps> = ({ pollStartEvents, filter, onFilterChange }) => {
return (
<div className="mx_PollHistoryList">
<FilterTabGroup<PollHistoryFilter>
name="PollHistoryDialog_filter"
value={filter}
onFilterChange={onFilterChange}
tabs={[
{ id: "ACTIVE", label: "Active polls" },
{ id: "ENDED", label: "Past polls" },
]}
/>
{!!pollStartEvents.length ? (
<ol className="mx_PollHistoryList_list">
{pollStartEvents.map((pollStartEvent) => (
@ -33,7 +46,11 @@ export const PollHistoryList: React.FC<PollHistoryListProps> = ({ pollStartEvent
))}
</ol>
) : (
<span className="mx_PollHistoryList_noResults">{_t("There are no polls in this room")}</span>
<span className="mx_PollHistoryList_noResults">
{filter === "ACTIVE"
? _t("There are no active polls in this room")
: _t("There are no past polls in this room")}
</span>
)}
</div>
);

View file

@ -0,0 +1,22 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Possible values for the "filter" setting in the poll history dialog
*
* Ended polls have a valid M_POLL_END event
*/
export type PollHistoryFilter = "ACTIVE" | "ENDED";

View file

@ -14,27 +14,32 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { M_POLL_START } from "matrix-js-sdk/src/@types/polls";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { Poll, PollEvent } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { useEventEmitterState } from "../../../../hooks/useEventEmitter";
/**
* Get poll start events in a rooms live timeline
* Get poll instances from a room
* @param roomId - id of room to retrieve polls for
* @param matrixClient - client
* @returns {MatrixEvent[]} - array fo poll start events
* @returns {Map<string, Poll>} - Map of Poll instances
*/
export const getPolls = (roomId: string, matrixClient: MatrixClient): MatrixEvent[] => {
export const usePolls = (
roomId: string,
matrixClient: MatrixClient,
): {
polls: Map<string, Poll>;
} => {
const room = matrixClient.getRoom(roomId);
if (!room) {
throw new Error("Cannot find room");
}
// @TODO(kerrya) poll history will be actively fetched in PSG-1043
// for now, just display polls that are in the current timeline
const timelineEvents = room.getLiveTimeline().getEvents();
const pollStartEvents = timelineEvents.filter((event) => M_POLL_START.matches(event.getType()));
const polls = useEventEmitterState(room, PollEvent.New, () => room.polls);
return pollStartEvents;
// @TODO(kerrya) watch polls for end events, trigger refiltering
return { polls };
};

View file

@ -0,0 +1,57 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FieldsetHTMLAttributes, ReactNode } from "react";
export type FilterTab<T> = {
label: string | ReactNode;
id: T;
};
type FilterTabGroupProps<T extends string = string> = FieldsetHTMLAttributes<any> & {
// group name used for radio buttons
name: string;
onFilterChange: (id: T) => void;
// active tab's id
value: T;
// tabs to display
tabs: FilterTab<T>[];
};
/**
* React component which styles a set of content filters as tabs
*
* This is used in displays which show a list of content items, and the user can select between one of several
* filters for those items. For example, in the Poll History dialog, the user can select between "Active" and "Ended"
* polls.
*
* Type `T` is used for the `value` attribute for the buttons in the radio group.
*/
export const FilterTabGroup = <T extends string = string>({
name,
value,
tabs,
onFilterChange,
...rest
}: FilterTabGroupProps<T>): JSX.Element => (
<fieldset {...rest} className="mx_FilterTabGroup">
{tabs.map(({ label, id }) => (
<label data-testid={`filter-tab-${name}-${id}`} key={id}>
<input type="radio" name={name} value={id} onChange={() => onFilterChange(id)} checked={value === id} />
<span>{label}</span>
</label>
))}
</fieldset>
);

View file

@ -3144,7 +3144,8 @@
"<b>Warning</b>: You should only set up key backup from a trusted computer.": "<b>Warning</b>: You should only set up key backup from a trusted computer.",
"Access your secure message history and set up secure messaging by entering your Security Key.": "Access your secure message history and set up secure messaging by entering your Security Key.",
"If you've forgotten your Security Key you can <button>set up new recovery options</button>": "If you've forgotten your Security Key you can <button>set up new recovery options</button>",
"There are no polls in this room": "There are no polls in this room",
"There are no active polls in this room": "There are no active polls in this room",
"There are no past polls in this room": "There are no past polls in this room",
"Send custom account data event": "Send custom account data event",
"Send custom room account data event": "Send custom room account data event",
"Event Type": "Event Type",

View file

@ -15,15 +15,17 @@ limitations under the License.
*/
import React from "react";
import { render } from "@testing-library/react";
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { fireEvent, render } from "@testing-library/react";
import { Room } from "matrix-js-sdk/src/matrix";
import { PollHistoryDialog } from "../../../../../src/components/views/dialogs/polls/PollHistoryDialog";
import {
getMockClientWithEventEmitter,
makePollEndEvent,
makePollStartEvent,
mockClientMethodsUser,
mockIntlDateTimeFormat,
setupRoomWithPollEvents,
unmockIntlDateTimeFormat,
} from "../../../../test-utils";
@ -33,6 +35,8 @@ describe("<PollHistoryDialog />", () => {
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
getRoom: jest.fn(),
relations: jest.fn(),
decryptEventIfNeeded: jest.fn(),
});
const room = new Room(roomId, mockClient, userId);
@ -49,6 +53,7 @@ describe("<PollHistoryDialog />", () => {
beforeEach(() => {
mockClient.getRoom.mockReturnValue(room);
mockClient.relations.mockResolvedValue({ events: [] });
const timeline = room.getLiveTimeline();
jest.spyOn(timeline, "getEvents").mockReturnValue([]);
});
@ -63,24 +68,58 @@ describe("<PollHistoryDialog />", () => {
expect(() => getComponent()).toThrow("Cannot find room");
});
it("renders a no polls message when there are no polls in the timeline", () => {
it("renders a no polls message when there are no active polls in the timeline", () => {
const { getByText } = getComponent();
expect(getByText("There are no polls in this room")).toBeTruthy();
expect(getByText("There are no active polls in this room")).toBeTruthy();
});
it("renders a list of polls when there are polls in the timeline", async () => {
it("renders a no past polls message when there are no past polls in the timeline", () => {
const { getByText } = getComponent();
fireEvent.click(getByText("Past polls"));
expect(getByText("There are no past polls in this room")).toBeTruthy();
});
it("renders a list of active polls when there are polls in the timeline", async () => {
const timestamp = 1675300825090;
const pollStart1 = makePollStartEvent("Question?", userId, undefined, { ts: timestamp, id: "$1" });
const pollStart2 = makePollStartEvent("Where?", userId, undefined, { ts: timestamp + 10000, id: "$2" });
const pollStart3 = makePollStartEvent("What?", userId, undefined, { ts: timestamp + 70000, id: "$3" });
const pollEnd3 = makePollEndEvent(pollStart3.getId()!, roomId, userId, timestamp + 1);
await setupRoomWithPollEvents([pollStart2, pollStart3, pollStart1], [], [pollEnd3], mockClient, room);
const { container, queryByText, getByTestId } = getComponent();
expect(getByTestId("filter-tab-PollHistoryDialog_filter-ACTIVE").firstElementChild).toBeChecked();
expect(container).toMatchSnapshot();
// this poll is ended, and default filter is ACTIVE
expect(queryByText("What?")).not.toBeInTheDocument();
});
it("filters ended polls", async () => {
const pollStart1 = makePollStartEvent("Question?", userId, undefined, { ts: 1675300825090, id: "$1" });
const pollStart2 = makePollStartEvent("Where?", userId, undefined, { ts: 1675300725090, id: "$2" });
const pollStart3 = makePollStartEvent("What?", userId, undefined, { ts: 1675200725090, id: "$3" });
const message = new MatrixEvent({
type: "m.room.message",
content: {},
});
const timeline = room.getLiveTimeline();
jest.spyOn(timeline, "getEvents").mockReturnValue([pollStart1, pollStart2, pollStart3, message]);
const { container } = getComponent();
const pollEnd3 = makePollEndEvent(pollStart3.getId()!, roomId, userId, 1675200725090 + 1);
await setupRoomWithPollEvents([pollStart1, pollStart2, pollStart3], [], [pollEnd3], mockClient, room);
expect(container).toMatchSnapshot();
const { getByText, queryByText, getByTestId } = getComponent();
expect(getByText("Question?")).toBeInTheDocument();
expect(getByText("Where?")).toBeInTheDocument();
// this poll is ended, and default filter is ACTIVE
expect(queryByText("What?")).not.toBeInTheDocument();
fireEvent.click(getByText("Past polls"));
expect(getByTestId("filter-tab-PollHistoryDialog_filter-ENDED").firstElementChild).toBeChecked();
// active polls no longer shown
expect(queryByText("Question?")).not.toBeInTheDocument();
expect(queryByText("Where?")).not.toBeInTheDocument();
// this poll is ended
expect(getByText("What?")).toBeInTheDocument();
});
});

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<PollHistoryDialog /> renders a list of polls when there are polls in the timeline 1`] = `
exports[`<PollHistoryDialog /> renders a list of active polls when there are polls in the timeline 1`] = `
<div>
<div
data-focus-guard="true"
@ -35,25 +35,38 @@ exports[`<PollHistoryDialog /> renders a list of polls when there are polls in t
<div
class="mx_PollHistoryList"
>
<fieldset
class="mx_FilterTabGroup"
>
<label
data-testid="filter-tab-PollHistoryDialog_filter-ACTIVE"
>
<input
checked=""
name="PollHistoryDialog_filter"
type="radio"
value="ACTIVE"
/>
<span>
Active polls
</span>
</label>
<label
data-testid="filter-tab-PollHistoryDialog_filter-ENDED"
>
<input
name="PollHistoryDialog_filter"
type="radio"
value="ENDED"
/>
<span>
Past polls
</span>
</label>
</fieldset>
<ol
class="mx_PollHistoryList_list"
>
<li
class="mx_PollListItem"
data-testid="pollListItem-$1"
>
<span>
02/02/23
</span>
<div
class="mx_PollListItem_icon"
/>
<span
class="mx_PollListItem_question"
>
Question?
</span>
</li>
<li
class="mx_PollListItem"
data-testid="pollListItem-$2"
@ -72,10 +85,10 @@ exports[`<PollHistoryDialog /> renders a list of polls when there are polls in t
</li>
<li
class="mx_PollListItem"
data-testid="pollListItem-$3"
data-testid="pollListItem-$1"
>
<span>
31/01/23
02/02/23
</span>
<div
class="mx_PollListItem_icon"
@ -83,7 +96,7 @@ exports[`<PollHistoryDialog /> renders a list of polls when there are polls in t
<span
class="mx_PollListItem_question"
>
What?
Question?
</span>
</li>
</ol>

View file

@ -0,0 +1,54 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { fireEvent, render } from "@testing-library/react";
import { FilterTabGroup } from "../../../../src/components/views/elements/FilterTabGroup";
describe("<FilterTabGroup />", () => {
enum TestOption {
Apple = "Apple",
Banana = "Banana",
Orange = "Orange",
}
const defaultProps = {
"name": "test",
"value": TestOption.Apple,
"onFilterChange": jest.fn(),
"tabs": [
{ id: TestOption.Apple, label: `Label for ${TestOption.Apple}` },
{ id: TestOption.Banana, label: `Label for ${TestOption.Banana}` },
{ id: TestOption.Orange, label: `Label for ${TestOption.Orange}` },
],
"data-testid": "test",
};
const getComponent = (props = {}) => <FilterTabGroup<TestOption> {...defaultProps} {...props} />;
it("renders options", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("calls onChange handler on selection", () => {
const onFilterChange = jest.fn();
const { getByText } = render(getComponent({ onFilterChange }));
fireEvent.click(getByText("Label for Banana"));
expect(onFilterChange).toHaveBeenCalledWith(TestOption.Banana);
});
});

View file

@ -0,0 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FilterTabGroup /> renders options 1`] = `
<div>
<fieldset
class="mx_FilterTabGroup"
data-testid="test"
>
<label
data-testid="filter-tab-test-Apple"
>
<input
checked=""
name="test"
type="radio"
value="Apple"
/>
<span>
Label for Apple
</span>
</label>
<label
data-testid="filter-tab-test-Banana"
>
<input
name="test"
type="radio"
value="Banana"
/>
<span>
Label for Banana
</span>
</label>
<label
data-testid="filter-tab-test-Orange"
>
<input
name="test"
type="radio"
value="Orange"
/>
<span>
Label for Orange
</span>
</label>
</fieldset>
</div>
`;

View file

@ -227,7 +227,7 @@ describe("MPollBody", () => {
content: newPollStart(undefined, undefined, true),
});
const props = getMPollBodyPropsFromEvent(mxEvent);
const room = await setupRoomWithPollEvents(mxEvent, votes, [], mockClient);
const room = await setupRoomWithPollEvents([mxEvent], votes, [], mockClient);
const renderResult = renderMPollBodyWithWrapper(props);
// wait for /relations promise to resolve
await flushPromises();
@ -255,7 +255,7 @@ describe("MPollBody", () => {
content: newPollStart(undefined, undefined, true),
});
const props = getMPollBodyPropsFromEvent(mxEvent);
const room = await setupRoomWithPollEvents(mxEvent, votes, [], mockClient);
const room = await setupRoomWithPollEvents([mxEvent], votes, [], mockClient);
const renderResult = renderMPollBodyWithWrapper(props);
// wait for /relations promise to resolve
await flushPromises();
@ -700,7 +700,7 @@ describe("MPollBody", () => {
});
const ends = [newPollEndEvent("@me:example.com", 25)];
await setupRoomWithPollEvents(pollEvent, [], ends, mockClient);
await setupRoomWithPollEvents([pollEvent], [], ends, mockClient);
const poll = mockClient.getRoom(pollEvent.getRoomId()!)!.polls.get(pollEvent.getId()!)!;
// start fetching, dont await
poll.getResponses();
@ -920,7 +920,7 @@ async function newMPollBodyFromEvent(
): Promise<RenderResult> {
const props = getMPollBodyPropsFromEvent(mxEvent);
await setupRoomWithPollEvents(mxEvent, relationEvents, endEvents, mockClient);
await setupRoomWithPollEvents([mxEvent], relationEvents, endEvents, mockClient);
return renderMPollBodyWithWrapper(props);
}
@ -1036,7 +1036,7 @@ async function runIsPollEnded(ends: MatrixEvent[]) {
content: newPollStart(),
});
await setupRoomWithPollEvents(pollEvent, [], ends, mockClient);
await setupRoomWithPollEvents([pollEvent], [], ends, mockClient);
return isPollEnded(pollEvent, mockClient);
}

View file

@ -50,7 +50,7 @@ describe("<MPollEndBody />", () => {
const setupRoomWithEventsTimeline = async (pollEnd: MatrixEvent, pollStart?: MatrixEvent): Promise<Room> => {
if (pollStart) {
await setupRoomWithPollEvents(pollStart, [], [pollEnd], mockClient);
await setupRoomWithPollEvents([pollStart], [], [pollEnd], mockClient);
}
const room = mockClient.getRoom(roomId) || new Room(roomId, mockClient, userId);

View file

@ -89,13 +89,14 @@ export const makePollEndEvent = (pollStartEventId: string, roomId: string, sende
* @returns
*/
export const setupRoomWithPollEvents = async (
mxEvent: MatrixEvent,
pollStartEvents: MatrixEvent[],
relationEvents: Array<MatrixEvent>,
endEvents: Array<MatrixEvent> = [],
mockClient: Mocked<MatrixClient>,
existingRoom?: Room,
): Promise<Room> => {
const room = new Room(mxEvent.getRoomId()!, mockClient, mockClient.getSafeUserId());
room.processPollEvents([mxEvent, ...relationEvents, ...endEvents]);
const room = existingRoom || new Room(pollStartEvents[0].getRoomId()!, mockClient, mockClient.getSafeUserId());
room.processPollEvents([...pollStartEvents, ...relationEvents, ...endEvents]);
// set redaction allowed for current user only
// poll end events are validated against this
@ -106,8 +107,10 @@ export const setupRoomWithPollEvents = async (
// wait for events to process on room
await flushPromises();
mockClient.getRoom.mockReturnValue(room);
mockClient.relations.mockResolvedValue({
events: [...relationEvents, ...endEvents],
mockClient.relations.mockImplementation(async (_roomId: string, eventId: string) => {
return {
events: [...relationEvents, ...endEvents].filter((event) => event.getRelation()?.event_id === eventId),
};
});
return room;
};