InvitePopover: match spec

This commit is contained in:
Liam Fitzgerald 2020-10-06 16:14:03 +10:00
parent 14b8564b8c
commit 3ba8e7bce1
6 changed files with 186 additions and 30 deletions

View File

@ -1959,6 +1959,12 @@
"source-map": "^0.6.1"
}
},
"@types/yup": {
"version": "0.29.7",
"resolved": "https://registry.npmjs.org/@types/yup/-/yup-0.29.7.tgz",
"integrity": "sha512-x3Zeh8/qLZ6fG4S1EztI1S1mLj6N1pSUV1PAj/9finZba48d3Maxtyz4WYNUY0NE76u1KSukfNLkjcRlb+O00g==",
"dev": true
},
"@typescript-eslint/eslint-plugin": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.8.0.tgz",

View File

@ -59,6 +59,7 @@
"@types/react-router-dom": "^5.1.5",
"@types/styled-components": "^5.1.2",
"@types/styled-system": "^5.1.10",
"@types/yup": "^0.29.7",
"@typescript-eslint/eslint-plugin": "^3.8.0",
"@typescript-eslint/parser": "^3.8.0",
"babel-eslint": "^10.1.0",

View File

@ -0,0 +1,137 @@
import React, {
useCallback,
useState,
ReactNode,
SyntheticEvent,
useEffect,
useRef,
} from "react";
import {
Box,
Label,
Row,
Col,
StatelessTextInput as Input,
ErrorLabel
} from "@tlon/indigo-react";
import { useField } from "formik";
import Mousetrap from "mousetrap";
import * as Yup from "yup";
function Chip(props: { children: ReactNode }) {
return (
<Row
alignItems="center"
height="24px"
borderRadius="1"
my="1"
p="1"
bg="blue"
color="white"
>
{props.children}
</Row>
);
}
interface ChipInputProps {
id: string;
label: string;
caption?: string;
placeholder: string;
breakOnSpace?: boolean;
}
export function ChipInput(props: ChipInputProps) {
const { id, label, caption, placeholder } = props;
const [{ onBlur, value }, meta, { setError, setValue }] = useField<string[]>(
id
);
const [newChip, setNextChip] = useState("");
const onChange = useCallback(
(e: any) => {
setNextChip(e.target.value);
},
[setValue]
);
const addNewChip = useCallback(() => {
setValue([...value, newChip]);
setNextChip("");
}, [setValue, value, newChip, setNextChip]);
const removeLastChip = useCallback(() => {
setValue(value.slice(0, value.length - 1));
}, [value, setValue]);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!inputRef.current) {
return () => {};
}
const mousetrap = Mousetrap(inputRef.current);
mousetrap.bind("backspace", (e) => {
if (newChip.length === 0) {
removeLastChip();
return false;
}
return true;
});
mousetrap.bind("tab", (e) => {
addNewChip();
return false;
});
mousetrap.bind("space", (e) => {
if (props.breakOnSpace) {
addNewChip();
return false;
}
return true;
});
return () => {
mousetrap.unbind("tab");
mousetrap.unbind("backspace");
mousetrap.unbind("space");
};
}, [inputRef.current, addNewChip, newChip]);
return (
<Col gapY="2">
<Label htmlFor={id}>{label}</Label>
{caption && <Label gray>{caption}</Label>}
<Row
border="1"
borderColor="washedGray"
borderRadius="1"
pl="2"
gapX="2"
width="100%"
flexWrap="wrap"
minHeight="32px"
>
{value.map((c, idx) => (
<Chip key={idx}>{c}</Chip>
))}
<Input
width="auto"
height="24px"
flexShrink="1"
flexGrow="1"
pl="0"
ref={inputRef}
onChange={onChange}
value={newChip}
onBlur={onBlur}
placeholder={placeholder}
border="0"
my="1"
py="1"
/>
</Row>
<ErrorLabel mt="2" hasError={!!(meta.touched && meta.error)}>
{meta.error}
</ErrorLabel>
</Col>
);
}

View File

@ -12,10 +12,9 @@ import {
Box,
Label,
ErrorLabel,
StatelessTextInput as Input
} from "@tlon/indigo-react";
import { useDropdown } from "~/logic/lib/useDropdown";
import styled from "styled-components";
import { space, color, layout, border } from "styled-system";
interface RenderChoiceProps<C> {
candidate: C;
@ -47,23 +46,9 @@ interface DropdownSearchProps<C> {
caption?: string;
disabled?: boolean;
error?: string;
placeholder?: string;
}
const TextArea = styled.input`
box-sizing: border-box;
min-width: 0;
width: 100%;
resize: none;
margin-top: ${(p) => p.theme.space[1]}px;
padding: ${(p) => p.theme.space[2]}px;
font-size: ${(p) => p.theme.fontSizes[0]}px;
line-height: 1.2;
${space}
${color}
${layout}
${border}
`;
export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
const textarea = useRef<HTMLTextAreaElement>();
const { candidates, getKey, caption } = props;
@ -136,16 +121,12 @@ export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
<Label htmlFor={props.id}>{props.label}</Label>
{caption ? <Label mt="2" gray>{caption}</Label> : null}
{!props.disabled && (
<TextArea
<Input
ref={textarea}
border={1}
borderColor="washedGray"
bg="white"
color="black"
borderRadius={2}
onChange={onChange}
value={query}
autocomplete="off"
placeholder={props.placeholder || ""}
/>
)}
{dropdown.length !== 0 && query.length !== 0 && (

View File

@ -134,7 +134,7 @@ export function ShipSearch(props: InviteSearchProps) {
value={undefined}
error={error}
/>
<Row flexWrap="wrap">
<Row minHeight="34px" flexWrap="wrap">
{value.map((s) => (
<Box
fontFamily="mono"

View File

@ -1,5 +1,6 @@
import React, { useCallback, useRef, useMemo } from "react";
import { Box, Text, Col, Button, Row } from "@tlon/indigo-react";
import * as Yup from 'yup';
import { ShipSearch } from "~/views/components/ShipSearch";
import { Association } from "~/types/metadata-update";
@ -11,6 +12,7 @@ import { FormError } from "~/views/components/FormError";
import { resourceFromPath } from "~/logic/lib/group";
import GlobalApi from "~/logic/api/global";
import { Groups, Rolodex } from "~/types";
import { ChipInput } from "~/views/components/ChipInput";
interface InvitePopoverProps {
baseUrl: string;
@ -20,11 +22,21 @@ interface InvitePopoverProps {
api: GlobalApi;
}
interface FormSchema {
emails: string[];
ships: string[];
}
const formSchema = Yup.object({
emails: Yup.array(Yup.string().email("Invalid email")),
ships: Yup.array(Yup.string())
});
export function InvitePopover(props: InvitePopoverProps) {
const { baseUrl, api, association } = props;
const relativePath = (p: string) => baseUrl + p;
const { title } = association?.metadata || '';
const { title } = association?.metadata || "";
const innerRef = useRef(null);
const history = useHistory();
@ -33,7 +45,8 @@ export function InvitePopover(props: InvitePopoverProps) {
}, [history.push, props.baseUrl]);
useOutsideClick(innerRef, onOutsideClick);
const onSubmit = async ({ ships }: { ships: string[] }, actions) => {
const onSubmit = async ({ ships, emails }: { ships: string[] }, actions) => {
// TODO: how to invite via email?
try {
const resource = resourceFromPath(association["group-path"]);
await ships.reduce(
@ -48,6 +61,9 @@ export function InvitePopover(props: InvitePopoverProps) {
}
};
const initialValues: FormSchema = { ships: [], emails: [] };
return (
<Switch>
<Route path={[relativePath("/invites")]}>
@ -72,10 +88,14 @@ export function InvitePopover(props: InvitePopoverProps) {
width="380px"
bg="white"
>
<Formik initialValues={{ ships: [] }} onSubmit={onSubmit}>
<Formik
initialValues={initialValues}
onSubmit={onSubmit}
validationSchema={formSchema}
>
<Form>
<Col p={3}>
<Box mb={2}>
<Col gapY="3" p={3}>
<Box>
<Text>Invite to </Text>
<Text fontWeight="800">{title}</Text>
</Box>
@ -86,13 +106,24 @@ export function InvitePopover(props: InvitePopoverProps) {
label=""
/>
<FormError message="Failed to invite" />
<ChipInput
id="emails"
label="Invite via Email"
caption="Send an Urbit ID and invite them to this group"
placeholder="name@example.com"
breakOnSpace
/>
</Col>
<Row
borderTop={1}
borderTopColor="washedGray"
justifyContent="flex-end"
>
<AsyncButton border={0} color="blue" loadingText="Inviting...">
<AsyncButton
border={0}
color="blue"
loadingText="Inviting..."
>
Send
</AsyncButton>
</Row>