publish: rewrite new notebook in indigo react

This commit is contained in:
Liam Fitzgerald 2020-08-11 10:03:42 +10:00
parent e3d2a52883
commit 3f81b30f36
6 changed files with 508 additions and 198 deletions

View File

@ -1,198 +0,0 @@
import React, { Component } from 'react';
import { InviteSearch } from '../../../../components/InviteSearch';
import { Spinner } from '../../../../components/Spinner';
import { Link } from 'react-router-dom';
import { stringToSymbol } from '../../../../lib/util';
export class NewScreen extends Component {
constructor(props) {
super(props);
this.state = {
idName: '',
description: '',
invites: {
groups: [],
ships: []
},
disabled: false,
createGroup: false,
awaiting: false
};
this.idChange = this.idChange.bind(this);
this.descriptionChange = this.descriptionChange.bind(this);
this.setInvite = this.setInvite.bind(this);
}
componentDidUpdate(prevProps) {
const { props, state } = this;
if (props.notebooks && (('~' + window.ship) in props.notebooks)) {
if (state.awaiting in props.notebooks['~' + window.ship]) {
const notebook = `/~${window.ship}/${state.awaiting}`;
props.history.push('/~publish/notebook' + notebook);
}
}
}
idChange(event) {
this.setState({
idName: event.target.value
});
}
descriptionChange(event) {
this.setState({
description: event.target.value
});
}
setInvite(value) {
this.setState({ invites: value });
}
onClickCreate() {
const { props, state } = this;
const bookId = stringToSymbol(state.idName);
let groupInfo = null;
if (state.invites.groups.length > 0) {
groupInfo = {
'group-path': state.invites.groups[0],
'invitees': [],
'use-preexisting': true,
'make-managed': false
};
} else if (this.state.createGroup) {
groupInfo = {
'group-path': `/ship/~${window.ship}/${bookId}`,
'invitees': state.invites.ships,
'use-preexisting': false,
'make-managed': true
};
} else {
groupInfo = {
'group-path': `/ship/~${window.ship}/${bookId}`,
'invitees': state.invites.ships,
'use-preexisting': false,
'make-managed': false
};
}
const action = {
'new-book': {
book: bookId,
title: state.idName,
about: state.description,
coms: true,
group: groupInfo
}
};
this.setState({ awaiting: bookId, disabled: true }, () => {
props.api.publish.publishAction(action).then(() => {
});
});
}
render() {
let createClasses = 'pointer db f9 green2 bg-gray0-d ba pv3 ph4 mv7 b--green2';
if (!this.state.idName || this.state.disabled) {
createClasses = 'db f9 gray2 ba bg-gray0-d pa2 pv3 ph4 mv7 b--gray3';
}
let idErrElem = <span />;
if (this.state.idError) {
idErrElem = (
<span className="f9 inter red2 db pt2">
Notebook must have a valid name.
</span>
);
}
return (
<div
className={
'h-100 w-100 mw6 pa3 pt4 overflow-x-hidden flex flex-column white-d'
}
>
<div className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8">
<Link to="/~publish/">{'⟵ All Notebooks'}</Link>
</div>
<h2 className="mb3 f8">New Notebook</h2>
<div className="w-100">
<p className="f8 mt3 lh-copy db">Name</p>
<p className="f9 gray2 db mb2 pt1">
Provide a name for your notebook
</p>
<textarea
className={
'f7 ba bg-gray0-d white-d pa3 db w-100 ' +
'focus-b--black focus-b--white-d b--gray3 b--gray2-d'
}
placeholder="eg. My Journal"
rows={1}
style={{
resize: 'none'
}}
onChange={this.idChange}
value={this.state.idName}
/>
{idErrElem}
<p className="f8 mt4 lh-copy db">
Description
<span className="gray3 ml1">(Optional)</span>
</p>
<p className="f9 gray2 db mb2 pt1">
What&apos;s your notebook about?
</p>
<textarea
className={
'f7 ba bg-gray0-d white-d pa3 db w-100 ' +
'focus-b--black focus-b--white-d b--gray3 b--gray2-d'
}
placeholder="Notebook description"
rows={1}
style={{
resize: 'none'
}}
onChange={this.descriptionChange}
value={this.state.description}
/>
<div className="mt4 db relative">
<p className="f8">
Invite
<span className="gray3"> (Optional)</span>
</p>
<Link className="green2 absolute right-0 bottom-0 f9" to="/~groups/new">Create Group</Link>
<p className="f9 gray2 db mv1 pb4">
Selected ships or group will be invited to read your notebook. Additional writers can be added from the &apos;subscribers&apos; panel.
</p>
</div>
<InviteSearch
associations={this.props.associations}
groupResults={true}
shipResults={true}
groups={this.props.groups}
contacts={this.props.contacts}
invites={this.state.invites}
setInvite={this.setInvite}
/>
<button
disabled={this.state.disabled}
onClick={this.onClickCreate.bind(this)}
className={createClasses}
>
Create Notebook
</button>
<Spinner
awaiting={this.state.awaiting}
classes="mt3"
text="Creating notebook..."
/>
</div>
</div>
);
}
}
export default NewScreen;

View File

@ -0,0 +1,137 @@
import React, { useCallback, useState, useEffect } from "react";
import {
Box,
Input,
Text,
Button,
Col,
ErrorMessage,
} from "@tlon/indigo-react";
import { Formik, Form } from "formik";
import * as Yup from "yup";
import GlobalApi from "../../../../api/global";
import { useWaitForProps } from "../../../../lib/useWaitForProps";
import DropdownSearch, {
InviteSearch,
} from "../../../../components/InviteSearch";
import { Spinner } from "../../../../components/Spinner";
import { Link, RouteComponentProps } from "react-router-dom";
import { stringToSymbol } from "../../../../lib/util";
import GroupSearch from "../../../../components/GroupSearch";
import { Associations } from "../../../../types/metadata-update";
import { Notebooks } from "../../../../types/publish-update";
interface FormSchema {
name: string;
description: string;
group: string;
}
const formSchema = Yup.object({
name: Yup.string().required("Notebook must have a name"),
description: Yup.string(),
group: Yup.string().required("Notebook must be part of a group"),
});
type NewScreenProps = RouteComponentProps<{}> & {
api: GlobalApi;
associations: Associations;
notebooks: Notebooks;
};
export function NewScreen(props: NewScreenProps) {
const { api } = props;
const waiter = useWaitForProps(props, 10000);
const onSubmit = useCallback(
async (values: FormSchema, actions) => {
const bookId = stringToSymbol(values.name);
const groupInfo = {
"group-path": values.group,
invitees: [],
"use-preexisting": true,
"make-managed": true,
};
const action = {
"new-book": {
book: bookId,
title: values.name,
about: values.description,
coms: true,
group: groupInfo,
},
};
try {
await api.publish.publishAction(action);
await waiter((p) => {
return Boolean(p?.notebooks?.[`~${window.ship}`]?.[bookId]);
});
actions.setSubmitting(false);
props.history.push(`/~publish/notebook/~${window.ship}/${bookId}`);
} catch (e) {
console.error(e);
actions.setSubmitting(false);
actions.setStatus({ error: "Notebook creation failed" });
}
},
[api, waiter, props.history]
);
return (
<Col p={3}>
<Box fontSize={0} mb={4}>New Notebook</Box>
<Formik
validationSchema={formSchema}
initialValues={{ name: "", description: "", group: "" }}
onSubmit={onSubmit}
>
{({ isSubmitting, status }) => (
<Form>
<Box
display="grid"
gridTemplateRows="auto"
gridRowGap={2}
gridTemplateColumns="300px"
>
<Input
id="name"
label="Name"
caption="Provide a name for your notebook"
placeholder="eg. My Journal"
/>
<Input
id="description"
label="Description"
caption="What's your notebook about?"
placeholder="Notebook description"
/>
<GroupSearch
caption="Provide a group to associate to the notebook"
associations={props.associations}
label="Group"
id="group"
/>
<Box justifySelf="start">
<Button type="submit" border>
Create Notebook
</Button>
</Box>
<Spinner
awaiting={isSubmitting}
classes="mt3"
text="Creating notebook..."
/>
{status && status.error && (
<ErrorMessage>{status.error}</ErrorMessage>
)}
</Box>
</Form>
)}
</Formik>
</Col>
);
}
export default NewScreen;

View File

@ -0,0 +1,171 @@
import React, {
useRef,
useState,
useMemo,
useCallback,
useEffect,
ChangeEvent,
} from "react";
import _ from "lodash";
import Mousetrap from "mousetrap";
import {
Box,
InputLabel,
ErrorMessage,
InputCaption,
} from "@tlon/indigo-react";
import { useDropdown } from "../lib/useDropdown";
import styled from "styled-components";
import { space, color, layout, border } from "styled-system";
interface RenderChoiceProps<C> {
candidate: C;
onRemove: () => void;
}
interface DropdownSearchProps<C> {
label: string;
id: string;
// Options for dropdown
candidates: C[];
// Present options in dropdown
renderCandidate: (
c: C,
selected: boolean,
onSelect: (c: C) => void
) => React.ReactNode;
// get a unique key for comparisons/react lists
getKey: (c: C) => string;
// search predicate
search: (s: string, c: C) => boolean;
// render selected candidate
renderChoice: (props: RenderChoiceProps<C>) => React.ReactNode;
onSelect: (c: C) => void;
onRemove: (c: C) => void;
value: C | undefined;
caption?: string;
disabled?: boolean;
error?: 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;
const [query, setQuery] = useState("");
const { next, back, search, selected, options } = useDropdown(
candidates,
getKey,
props.search
);
const onSelect = useCallback(
(c: C) => {
setQuery("");
props.onSelect(c);
},
[setQuery, props.onSelect]
);
const onEnter = useCallback(() => {
if (selected) {
onSelect(selected);
}
return false;
}, [onSelect, selected]);
useEffect(() => {
if (!textarea.current) {
return;
}
const mousetrap = Mousetrap(textarea.current);
mousetrap.bind(["down", "tab"], next);
mousetrap.bind(["up", "shift+tab"], back);
mousetrap.bind("enter", onEnter);
return () => {
mousetrap.unbind(["down", "tab"]);
mousetrap.unbind(["up", "shift+tab"]);
mousetrap.unbind("enter", onEnter);
};
}, [textarea.current, next, back, onEnter]);
const onChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
search(e.target.value);
setQuery(e.target.value);
},
[setQuery]
);
const dropdown = useMemo(
() =>
_.take(options, 5).map((o, idx) =>
props.renderCandidate(
o,
!_.isUndefined(selected) &&
props.getKey(o) === props.getKey(selected),
onSelect
)
),
[options, props.getKey, props.renderCandidate, selected]
);
return (
<Box position="relative">
<InputLabel htmlFor={props.id}>{props.label}</InputLabel>
{caption ? <InputCaption>{caption}</InputCaption> : null}
{!props.disabled && (
<TextArea
ref={textarea}
border={1}
borderColor="washedGray"
borderRadius={2}
onChange={onChange}
value={query}
autocomplete="off"
/>
)}
{options.length !== 0 && query.length !== 0 && (
<Box
mt={1}
border={1}
borderColor="washedGray"
bg="white"
width="100%"
position="absolute"
>
{dropdown}
</Box>
)}
{props.value && (
<Box mt={2} display="flex">
{props.renderChoice({
candidate: props.value,
onRemove: () => props.onRemove(props.value as C),
})}
</Box>
)}
<ErrorMessage>{props.error}</ErrorMessage>
</Box>
);
}
export default DropdownSearch;

View File

@ -0,0 +1,106 @@
import React, { useMemo, useCallback } from "react";
import { Box, Text } from "@tlon/indigo-react";
import _ from "lodash";
import { useField } from "formik";
import styled from "styled-components";
import { DropdownSearch } from "./DropdownSearch";
import { Associations, Association } from "../types/metadata-update";
interface InviteSearchProps {
disabled?: boolean;
associations: Associations;
label: string;
caption?: string;
id: string;
}
const CandidateBox = styled(Box)<{ selected: boolean }>`
background-color: ${(p) =>
p.selected ? p.theme.colors.washedGray : p.theme.colors.white};
pointer: cursor;
&:hover {
background-color: ${(p) => p.theme.colors.washedGray};
}
`;
const Candidate = ({ title, selected, onClick }) => (
<CandidateBox
onClick={onClick}
selected={selected}
borderColor="washedGray"
fontSize={0}
p={1}
width="100%"
>
{title}
InviteSearch</CandidateBox>
);
function renderCandidate(
a: Association,
selected: boolean,
onSelect: (a: Association) => void
) {
const { title } = a.metadata;
const onClick = () => {
onSelect(a);
};
return <Candidate title={title} selected={selected} onClick={onClick} />;
}
export function GroupSearch(props: InviteSearchProps) {
const groups = useMemo(
() => Object.values(props.associations?.contacts || {}),
[props.associations?.contacts]
);
const [{ value }, { error }, { setValue }] = useField(props.id);
const group = props.associations?.contacts?.[value];
const onSelect = useCallback(
(a: Association) => {
setValue(a["group-path"]);
},
[setValue]
);
const onRemove = useCallback(
(a: Association) => {
setValue("");
},
[setValue]
);
return (
<DropdownSearch<Association>
label={props.label}
id={props.id}
caption={props.caption}
candidates={groups}
renderCandidate={renderCandidate}
disabled={value.length !== 0}
search={(s: string, a: Association) =>
a.metadata.title.toLowerCase().startsWith(s.toLowerCase())
}
getKey={(a: Association) => a["group-path"]}
onSelect={onSelect}
onRemove={onRemove}
renderChoice={({ candidate, onRemove }) => (
<Box px={2} py={1} border={1} borderColor="washedGrey" fontSize={0}>
{candidate.metadata.title}
<Text ml={2} onClick={onRemove}>
x
</Text>
</Box>
)}
value={group}
error={error}
/>
);
}
export default GroupSearch;

View File

@ -0,0 +1,58 @@
import { useState, useEffect, useMemo, useCallback } from "react";
export function useDropdown<C>(
candidates: C[],
key: (c: C) => string,
searchPred: (query: string, c: C) => boolean
) {
const [options, setOptions] = useState(candidates);
const [selected, setSelected] = useState<C | undefined>();
const search = useCallback(
(s: string) => {
const opts = candidates.filter((c) => searchPred(s, c));
setOptions(opts);
if (selected) {
const idx = opts.findIndex((c) => key(c) === key(selected));
console.log(idx);
if (idx < 0) {
setSelected(undefined);
}
}
},
[candidates, searchPred, key, selected, setOptions, setSelected]
);
const changeSelection = useCallback(
(backward = false) => {
const select = (idx: number) => {
setSelected(options[idx]);
};
if(!selected) { select(0); return false; }
const idx = options.findIndex((c) => key(c) === key(selected));
if (
idx === -1 ||
(options.length - 1 <= idx && !backward)
) {
select(0);
} else if (idx === 0 && backward) {
select(options.length - 1);
} else {
select(idx + (backward ? -1 : 1));
}
return false;
},
[options, setSelected, selected]
);
const next = useCallback(() => changeSelection(), [changeSelection]);
const back = useCallback(() => changeSelection(true), [changeSelection]);
return {
next,
back,
search,
selected,
options,
};
}

View File

@ -0,0 +1,36 @@
import { useState, useEffect, useCallback } from 'react';
export function useWaitForProps<P>(props: P, timeout: number) {
const [resolve, setResolve] = useState<() => void>(() => () => {});
const [ready, setReady] = useState<(p: P) => boolean | undefined>();
useEffect(() => {
if (typeof ready === "function" && ready(props)) {
resolve();
}
}, [props, ready, resolve]);
/**
* Waits until some predicate is true
*
* @param r - Predicate to wait for
* @returns A promise that resolves when `r` returns true, or rejects if the
* waiting times out
*
*/
const waiter = useCallback(
(r: (props: P) => boolean) => {
setReady(() => r);
return new Promise<void>((resolve, reject) => {
setResolve(() => resolve);
setTimeout(() => {
reject(new Error("Timed out"));
}, timeout);
});
},
[setResolve, setReady, timeout]
);
return waiter;
}