mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-12-13 20:04:35 +03:00
landscape: refactor dropdown searches
This commit is contained in:
parent
38e403e0c3
commit
118f153dc7
@ -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);
|
||||
}
|
||||
|
@ -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));
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user