landscape: refactor dropdown searches

This commit is contained in:
Liam Fitzgerald 2020-11-05 11:53:36 +10:00
parent 38e403e0c3
commit 118f153dc7
No known key found for this signature in database
GPG Key ID: D390E12C61D1CFFB
5 changed files with 139 additions and 130 deletions

View File

@ -4,7 +4,7 @@ import { PatpNoSig, Path } from '~/types/noun';
export function roleForShip(group: Group, ship: PatpNoSig): RoleTags | undefined {
return roleTags.reduce((currRole, role) => {
const roleShips = group.tags.role[role];
const roleShips = group?.tags?.role?.[role];
return roleShips && roleShips.has(ship) ? role : currRole;
}, undefined as RoleTags | undefined);
}

View File

@ -3,13 +3,16 @@ import { useState, useEffect, useMemo, useCallback } from "react";
export function useDropdown<C>(
candidates: C[],
key: (c: C) => string,
searchPred: (query: string, c: C) => boolean
searchPred: (query: string, c: C) => boolean,
isExact: (query: string) => C | undefined
) {
const [options, setOptions] = useState(candidates);
const [selected, setSelected] = useState<C | undefined>();
const search = useCallback(
(s: string) => {
const opts = candidates.filter((c) => searchPred(s, c));
const exactMatch = isExact(s);
const exact = exactMatch ? [exactMatch] : [];
const opts = [...exact,...candidates.filter((c) => searchPred(s, c))]
setOptions(opts);
if (selected) {
const idx = opts.findIndex((c) => key(c) === key(selected));

View File

@ -12,21 +12,14 @@ import {
Box,
Label,
ErrorLabel,
StatelessTextInput as Input
StatelessTextInput as Input,
} from "@tlon/indigo-react";
import { useDropdown } from "~/logic/lib/useDropdown";
import { PropFunc } from "~/types/util";
interface RenderChoiceProps<C> {
candidate: C;
onRemove: () => void;
}
interface DropdownSearchProps<C> {
autoFocus?: boolean;
label?: string;
id: string;
interface DropdownSearchExtraProps<C> {
// check if entry is exact match
isExact: (s: string) => C | undefined;
isExact?: (s: string) => C | undefined;
// Options for dropdown
candidates: C[];
// Present options in dropdown
@ -39,43 +32,57 @@ interface DropdownSearchProps<C> {
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;
placeholder?: string;
}
type DropdownSearchProps<C> = PropFunc<typeof Box> &
DropdownSearchExtraProps<C>;
export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
const textarea = useRef<HTMLTextAreaElement>();
const { candidates, getKey, caption, autoFocus } = props;
const {
candidates,
getKey,
search: searchPred,
onSelect,
isExact,
renderCandidate,
disabled,
placeholder,
...rest
} = props;
const [query, setQuery] = useState("");
const exact = useCallback(
(s: string) => {
return isExact ? isExact(s) : undefined;
},
[isExact]
);
const { next, back, search, selected, options } = useDropdown(
candidates,
getKey,
props.search
searchPred,
exact
);
const onSelect = useCallback(
const handleSelect = useCallback(
(c: C) => {
setQuery("");
props.onSelect(c);
onSelect(c);
},
[setQuery, props.onSelect]
[setQuery, onSelect]
);
const onEnter = useCallback(() => {
if (selected) {
onSelect(selected);
handleSelect(selected);
}
return false;
}, [onSelect, selected]);
}, [handleSelect, selected]);
useEffect(() => {
if (!textarea.current) {
@ -103,7 +110,7 @@ export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
);
const dropdown = useMemo(() => {
const first = props.isExact(query);
const first = props.isExact?.(query);
let opts = options;
if (first) {
opts = options.includes(first) ? opts : [first, ...options];
@ -112,29 +119,26 @@ export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
props.renderCandidate(
o,
!_.isUndefined(selected) && props.getKey(o) === props.getKey(selected),
onSelect
handleSelect
)
);
}, [options, props.getKey, props.renderCandidate, selected]);
return (
<Box position="relative" zIndex={9}>
{props.label && (<Label htmlFor={props.id}>{props.label}</Label>)}
{caption ? <Label mt="2" gray>{caption}</Label> : null}
{!props.disabled && (
<Input
ref={textarea}
onChange={onChange}
value={query}
autocomplete="off"
placeholder={props.placeholder || ""}
autoFocus={autoFocus}
/>
)}
<Box {...rest} position="relative" zIndex={9}>
<Input
ref={textarea}
onChange={onChange}
value={query}
autocomplete="off"
disabled={disabled}
placeholder={placeholder}
/>
{dropdown.length !== 0 && query.length !== 0 && (
<Box
mt={1}
border={1}
mt="1"
border="1"
borderRadius="1"
borderColor="washedGray"
bg="white"
width="100%"
@ -143,15 +147,6 @@ export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
{dropdown}
</Box>
)}
{props.value && (
<Box mt={2} display="flex">
{props.renderChoice({
candidate: props.value,
onRemove: () => props.onRemove(props.value as C),
})}
</Box>
)}
<ErrorLabel>{props.error}</ErrorLabel>
</Box>
);
}

View File

@ -1,5 +1,13 @@
import React, { useMemo, useCallback } from "react";
import { Box, Text } from "@tlon/indigo-react";
import {
Box,
Text,
Label,
Row,
Col,
Icon,
ErrorLabel,
} from "@tlon/indigo-react";
import _ from "lodash";
import { useField } from "formik";
import styled from "styled-components";
@ -21,19 +29,11 @@ interface InviteSearchProps {
}
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 ClickableText = styled(Text)`
cursor: pointer;
`;
const Candidate = ({ title, selected, onClick }) => (
<CandidateBox
onClick={onClick}
@ -41,7 +41,7 @@ const Candidate = ({ title, selected, onClick }) => (
borderColor="washedGray"
color="black"
fontSize={0}
p={1}
p={2}
width="100%"
>
{title}
@ -63,25 +63,26 @@ function renderCandidate(
}
export function GroupSearch(props: InviteSearchProps) {
const groups = useMemo(
() => {
return props.adminOnly
? Object.values(
const { id, caption, label } = props;
const groups: Association[] = useMemo(() => {
return props.adminOnly
? Object.values(
Object.keys(props.associations?.contacts)
.filter(e => roleForShip(props.groups[e], window.ship) === 'admin')
.reduce((obj, key) => {
obj[key] = props.associations?.contacts[key]
return obj;
}, {}) || {}
.filter(
(e) => roleForShip(props.groups[e], window.ship) === "admin"
)
.reduce((obj, key) => {
obj[key] = props.associations?.contacts[key];
return obj;
}, {}) || {}
)
: Object.values(props.associations?.contacts || {});
},
[props.associations?.contacts]
);
: Object.values(props.associations?.contacts || {});
}, [props.associations?.contacts]);
const [{ value }, { error }, { setValue }] = useField(props.id);
const [{ value }, meta, { setValue }] = useField(props.id);
const group = props.associations?.contacts?.[value];
const { title: groupTitle } =
props.associations.contacts?.[value]?.metadata || {};
const onSelect = useCallback(
(a: Association) => {
@ -90,39 +91,49 @@ export function GroupSearch(props: InviteSearchProps) {
[setValue]
);
const onRemove = useCallback(
(a: Association) => {
setValue("");
},
[setValue]
);
const onUnselect = useCallback(() => {
setValue(undefined);
}, [setValue]);
return (
<DropdownSearch<Association>
label={props.label}
id={props.id}
caption={props.caption}
candidates={groups}
isExact={() => undefined}
renderCandidate={renderCandidate}
disabled={value && 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 cursor='default' px={2} py={1} border={1} borderColor="washedGrey" color="black" fontSize={0}>
{candidate.metadata.title}
<ClickableText ml={2} onClick={onRemove} color="black">
x
</ClickableText>
</Box>
<Col>
<Label htmlFor={id}>{label}</Label>
{caption && (
<Label gray mt="2">
{caption}
</Label>
)}
value={group}
error={error}
/>
{value && (
<Row
borderRadius="1"
mt="2"
width="fit-content"
border="1"
borderColor="gray"
height="32px"
px="2"
alignItems="center"
>
<Text mr="2">{groupTitle || value}</Text>
<Icon onClick={onUnselect} icon="X" />
</Row>
)}
{!value && (
<DropdownSearch<Association>
mt="2"
candidates={groups}
renderCandidate={renderCandidate}
search={(s: string, a: Association) =>
a.metadata.title.toLowerCase().startsWith(s.toLowerCase())
}
getKey={(a: Association) => a["group-path"]}
onSelect={onSelect}
/>
)}
<ErrorLabel hasError={!!(meta.touched && meta.error)}>
{meta.error}
</ErrorLabel>
</Col>
);
}

View File

@ -1,5 +1,5 @@
import React, { useMemo, useCallback } from "react";
import { Box, Text, Row, Col } from "@tlon/indigo-react";
import { Box, Label, Icon, Text, Row, Col } from "@tlon/indigo-react";
import _ from "lodash";
import ob from "urbit-ob";
import { useField } from "formik";
@ -23,10 +23,6 @@ interface InviteSearchProps {
maxLength?: number;
}
const ClickableText = styled(Text)`
cursor: pointer;
`;
const Candidate = ({ title, detail, selected, onClick }) => (
<HoverBox
display="flex"
@ -48,7 +44,10 @@ const Candidate = ({ title, detail, selected, onClick }) => (
);
export function ShipSearch(props: InviteSearchProps) {
const [{ value }, { error }, { setValue, setTouched }] = useField<string[]>(props.id);
const { id, label, caption } = props;
const [{ value }, { error }, { setValue, setTouched }] = useField<string[]>(
props.id
);
const onSelect = useCallback(
(s: string) => {
@ -113,20 +112,22 @@ export function ShipSearch(props: InviteSearchProps) {
[nicknames]
);
const maxLength = props.maxLength
return (
<Col>
<Label htmlFor={id}>{label}</Label>
{caption && (
<Label gray mt="2">
{caption}
</Label>
)}
<DropdownSearch<string>
label={props.label}
id={props.id}
mt="2"
isExact={(s) => {
const ship = `~${deSig(s)}`;
const result = ob.isValidPatp(ship);
return result ? deSig(s) : undefined;
}}
placeholder="Search for ships"
caption={props.caption}
candidates={peers}
renderCandidate={renderCandidate}
disabled={props.maxLength ? value.length >= props.maxLength : false}
@ -135,18 +136,14 @@ export function ShipSearch(props: InviteSearchProps) {
}
getKey={(s: string) => s}
onSelect={onSelect}
onRemove={onRemove}
renderChoice={({ candidate, onRemove }) => null}
value={undefined}
error={error}
autoFocus={props.autoFocus}
/>
<Row minHeight="34px" flexWrap="wrap">
{value.map((s) => (
<Box
<Row
fontFamily="mono"
px={2}
alignItems="center"
py={1}
px={2}
border={1}
borderColor="washedGrey"
color="black"
@ -155,10 +152,13 @@ export function ShipSearch(props: InviteSearchProps) {
mr={2}
>
<Text fontFamily="mono">{cite(s)}</Text>
<ClickableText ml={2} onClick={() => onRemove(s)} color="black">
x
</ClickableText>
</Box>
<Icon
icon="X"
ml={2}
onClick={() => onRemove(s)}
cursor="pointer"
/>
</Row>
))}
</Row>
</Col>