mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-12-15 10:02:47 +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