mirror of
https://github.com/urbit/shrub.git
synced 2024-11-28 22:33:06 +03:00
publish: rewrite new notebook in indigo react
This commit is contained in:
parent
e3d2a52883
commit
3f81b30f36
@ -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'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 'subscribers' 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;
|
137
pkg/interface/src/apps/publish/components/lib/new.tsx
Normal file
137
pkg/interface/src/apps/publish/components/lib/new.tsx
Normal 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;
|
171
pkg/interface/src/components/DropdownSearch.tsx
Normal file
171
pkg/interface/src/components/DropdownSearch.tsx
Normal 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;
|
106
pkg/interface/src/components/GroupSearch.tsx
Normal file
106
pkg/interface/src/components/GroupSearch.tsx
Normal 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;
|
58
pkg/interface/src/lib/useDropdown.ts
Normal file
58
pkg/interface/src/lib/useDropdown.ts
Normal 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,
|
||||
};
|
||||
}
|
36
pkg/interface/src/lib/useWaitForProps.ts
Normal file
36
pkg/interface/src/lib/useWaitForProps.ts
Normal 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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user