Polls: Creation form & start event (#7001)

* PSFD-423: Permission check for polls dialog

* PSFD-423: Implement compound scrollable dialog and skeleton create poll

* PSFD-325: Ask the question

* PSFD-328: Ask for options

* PSFD-423: Ensure form submission semantics work for dialogs

* PSFD-328: Option semantics

* Can delete all option to end up with zero
* Minimum 2 to submit the form

* PSFD-316: Send poll start event

* Appease the linter

* PSFD-328: Reduce padding between options to account for field size

* Iterate per design

* Fix submission
This commit is contained in:
Travis Ralston 2021-11-01 23:44:42 -06:00 committed by GitHub
parent 5202c0a237
commit 73731cc478
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 546 additions and 23 deletions

View file

@ -0,0 +1,116 @@
/*
Copyright 2021 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, { FormEvent } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Key } from "../../../Keyboard";
import { IDialogProps } from "./IDialogProps";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import FocusLock from "react-focus-lock";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
export interface IScrollableBaseState {
canSubmit: boolean;
title: string;
actionLabel: string;
}
/**
* Scrollable dialog base from Compound (Web Components).
*/
export default abstract class ScrollableBaseModal<TProps extends IDialogProps, TState extends IScrollableBaseState>
extends React.PureComponent<TProps, TState> {
protected constructor(props: TProps) {
super(props);
}
protected get matrixClient(): MatrixClient {
return MatrixClientPeg.get();
}
private onKeyDown = (e: KeyboardEvent | React.KeyboardEvent): void => {
if (e.key === Key.ESCAPE) {
e.stopPropagation();
e.preventDefault();
this.cancel();
}
};
private onCancel = () => {
this.cancel();
};
private onSubmit = (e: MouseEvent | FormEvent) => {
e.stopPropagation();
e.preventDefault();
if (!this.state.canSubmit) return; // pretend the submit button was disabled
this.submit();
};
protected abstract cancel(): void;
protected abstract submit(): void;
protected abstract renderContent(): React.ReactNode;
public render(): JSX.Element {
return (
<MatrixClientContext.Provider value={this.matrixClient}>
<FocusLock
returnFocus={true}
lockProps={{
onKeyDown: this.onKeyDown,
role: "dialog",
["aria-labelledby"]: "mx_CompoundDialog_title",
// Like BaseDialog, we'll just point this at the whole content
["aria-describedby"]: "mx_CompoundDialog_content",
}}
className="mx_CompoundDialog mx_ScrollableBaseDialog"
>
<div className="mx_CompoundDialog_header">
<h1>{ this.state.title }</h1>
<AccessibleButton
onClick={this.onCancel}
className="mx_CompoundDialog_cancelButton"
aria-label={_t("Close dialog")}
/>
</div>
<form onSubmit={this.onSubmit}>
<div className="mx_CompoundDialog_content">
{ this.renderContent() }
</div>
<div className="mx_CompoundDialog_footer">
<AccessibleButton onClick={this.onCancel} kind="primary_outline">
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton
onClick={this.onSubmit}
kind="primary"
disabled={!this.state.canSubmit}
type="submit"
element="button"
className="mx_Dialog_nonDialogButton"
>
{ this.state.actionLabel }
</AccessibleButton>
</div>
</form>
</FocusLock>
</MatrixClientContext.Provider>
);
}
}

View file

@ -45,6 +45,9 @@ interface IProps {
label?: string;
// The field's placeholder string. Defaults to the label.
placeholder?: string;
// When true (default false), the placeholder will be shown instead of the label when
// the component is unfocused & empty.
usePlaceholderAsHint?: boolean;
// Optional component to include inside the field before the input.
prefixComponent?: React.ReactNode;
// Optional component to include inside the field after the input.
@ -226,6 +229,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { element, prefixComponent, postfixComponent, className, onValidate, children,
tooltipContent, forceValidity, tooltipClassName, list, validateOnBlur, validateOnChange, validateOnFocus,
usePlaceholderAsHint,
...inputProps } = this.props;
// Set some defaults for the <input> element
@ -256,7 +260,8 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
// If we have a prefix element, leave the label always at the top left and
// don't animate it, as it looks a bit clunky and would add complexity to do
// properly.
mx_Field_labelAlwaysTopLeft: prefixComponent,
mx_Field_labelAlwaysTopLeft: prefixComponent || usePlaceholderAsHint,
mx_Field_placeholderIsHint: usePlaceholderAsHint,
mx_Field_valid: hasValidationFlag ? forceValidity : onValidate && this.state.valid === true,
mx_Field_invalid: hasValidationFlag
? !forceValidity

View file

@ -0,0 +1,144 @@
/*
Copyright 2021 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 ScrollableBaseModal, { IScrollableBaseState } from "../dialogs/ScrollableBaseModal";
import { IDialogProps } from "../dialogs/IDialogProps";
import React, { ChangeEvent, createRef } from "react";
import { _t } from "../../../languageHandler";
import { Room } from "matrix-js-sdk/src/models/room";
import { arrayFastClone, arraySeed } from "../../../utils/arrays";
import Field from "./Field";
import AccessibleButton from "./AccessibleButton";
import { makePollContent, POLL_KIND_DISCLOSED, POLL_START_EVENT_TYPE } from "../../../polls/consts";
interface IProps extends IDialogProps {
room: Room;
}
interface IState extends IScrollableBaseState {
question: string;
options: string[];
busy: boolean;
}
const MIN_OPTIONS = 2;
const MAX_OPTIONS = 20;
const DEFAULT_NUM_OPTIONS = 2;
export default class PollCreateDialog extends ScrollableBaseModal<IProps, IState> {
private addOptionRef = createRef<HTMLDivElement>();
public constructor(props: IProps) {
super(props);
this.state = {
title: _t("Create poll"),
actionLabel: _t("Create Poll"),
canSubmit: false, // need to add a question and at least one option first
question: "",
options: arraySeed("", DEFAULT_NUM_OPTIONS),
busy: false,
};
}
private checkCanSubmit() {
this.setState({
canSubmit:
!this.state.busy &&
this.state.question.trim().length > 0 &&
this.state.options.filter(op => op.trim().length > 0).length >= MIN_OPTIONS,
});
}
private onQuestionChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({ question: e.target.value }, () => this.checkCanSubmit());
};
private onOptionChange = (i: number, e: ChangeEvent<HTMLInputElement>) => {
const newOptions = arrayFastClone(this.state.options);
newOptions[i] = e.target.value;
this.setState({ options: newOptions }, () => this.checkCanSubmit());
};
private onOptionRemove = (i: number) => {
const newOptions = arrayFastClone(this.state.options);
newOptions.splice(i, 1);
this.setState({ options: newOptions }, () => this.checkCanSubmit());
};
private onOptionAdd = () => {
const newOptions = arrayFastClone(this.state.options);
newOptions.push("");
this.setState({ options: newOptions }, () => {
// Scroll the button into view after the state update to ensure we don't experience
// a pop-in effect, and to avoid the button getting cut off due to a mid-scroll render.
this.addOptionRef.current?.scrollIntoView();
});
};
protected submit(): void {
this.setState({ busy: true, canSubmit: false });
this.matrixClient.sendEvent(
this.props.room.roomId,
POLL_START_EVENT_TYPE.name,
makePollContent(this.state.question, this.state.options, POLL_KIND_DISCLOSED.name),
).then(() => this.props.onFinished(true)).catch(e => {
console.error("Failed to submit poll event:", e);
this.setState({ busy: false, canSubmit: true });
});
}
protected cancel(): void {
this.props.onFinished(false);
}
protected renderContent(): React.ReactNode {
return <div className="mx_PollCreateDialog">
<h2>{ _t("What is your poll question or topic?") }</h2>
<Field
value={this.state.question}
label={_t("Question or topic")}
placeholder={_t("Write something...")}
onChange={this.onQuestionChange}
usePlaceholderAsHint={true}
/>
<h2>{ _t("Create options") }</h2>
{
this.state.options.map((op, i) => <div key={`option_${i}`} className="mx_PollCreateDialog_option">
<Field
value={op}
label={_t("Option %(number)s", { number: i + 1 })}
placeholder={_t("Write an option")}
onChange={e => this.onOptionChange(i, e)}
usePlaceholderAsHint={true}
/>
<AccessibleButton
onClick={() => this.onOptionRemove(i)}
className="mx_PollCreateDialog_removeOption"
/>
</div>)
}
<AccessibleButton
onClick={this.onOptionAdd}
disabled={this.state.options.length >= MAX_OPTIONS}
kind="secondary"
className="mx_PollCreateDialog_addOption"
inputRef={this.addOptionRef}
>{ _t("Add option") }</AccessibleButton>
</div>;
}
}

View file

@ -53,9 +53,11 @@ import EmojiPicker from '../emojipicker/EmojiPicker';
import MemberStatusMessageAvatar from "../avatars/MemberStatusMessageAvatar";
import UIStore, { UI_EVENTS } from '../../../stores/UIStore';
import Modal from "../../../Modal";
import InfoDialog from "../dialogs/InfoDialog";
import { RelationType } from 'matrix-js-sdk/src/@types/event';
import RoomContext from '../../../contexts/RoomContext';
import { POLL_START_EVENT_TYPE } from "../../../polls/consts";
import ErrorDialog from "../dialogs/ErrorDialog";
import PollCreateDialog from "../elements/PollCreateDialog";
let instanceCount = 0;
const NARROW_MODE_BREAKPOINT = 500;
@ -197,18 +199,26 @@ class UploadButton extends React.Component<IUploadButtonProps> {
}
}
// TODO: [polls] Make this component actually do something
class PollButton extends React.PureComponent {
interface IPollButtonProps {
room: Room;
}
class PollButton extends React.PureComponent<IPollButtonProps> {
private onCreateClick = () => {
Modal.createTrackedDialog('Polls', 'Not Yet Implemented', InfoDialog, {
// XXX: Deliberately not translated given this dialog is meant to be replaced and we don't
// want to clutter the language files with short-lived strings.
title: "Polls are currently in development",
description: "" +
"Thanks for testing polls! We haven't quite gotten a chance to write the feature yet " +
"though. Check back later for updates.",
hasCloseButton: true,
});
const canSend = this.props.room.currentState.maySendEvent(
POLL_START_EVENT_TYPE.name,
MatrixClientPeg.get().getUserId(),
);
if (!canSend) {
Modal.createTrackedDialog('Polls', 'permissions error: cannot start', ErrorDialog, {
title: _t("Permission Required"),
description: _t("You do not have permission to start polls in this room."),
});
} else {
Modal.createTrackedDialog('Polls', 'create', PollCreateDialog, {
room: this.props.room,
}, 'mx_CompoundDialog');
}
};
render() {
@ -465,7 +475,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
if (!this.state.haveRecording) {
if (SettingsStore.getValue("feature_polls")) {
buttons.push(
<PollButton key="polls" />,
<PollButton key="polls" room={this.props.room} />,
);
}
buttons.push(