feat: UI refactor on dropdown for better customization (#216)
* wip: combobox refactor * refactor: new combobox styling * refactor: rename new combobox * wip: refactor combobox factory * refactor: new combobox single component * wip: refactor form combobox * refactor: github branches typing * refactor: ens field form combobox * fix: readd removed branch icon * refactor: replace nfa picker combobox * refactor: remove old combobox * refactor: rename combobox factory * fix: remove leftover combobox code * refactor: query filter by keys * fix: create ap form title * fix: max combobox options height * feat: add chevron on combobox field * refactor: optmize elements generation structure * chore: add todo comments for things thats going to change * fix: code comment addressed on pr
This commit is contained in:
parent
fdedc18b02
commit
9934ad1cfd
|
|
@ -10,23 +10,20 @@ export abstract class AvatarStyles {
|
|||
verticalAlign: 'middle',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
width: '$5',
|
||||
height: '$5',
|
||||
borderRadius: '100%',
|
||||
backgroundColor: '$slate2',
|
||||
mr: '$2',
|
||||
});
|
||||
|
||||
static readonly Image = styled(Avatar.Image, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
objectFit: 'cover',
|
||||
borderRadius: 'inherit',
|
||||
});
|
||||
|
||||
static readonly Fallback = styled(Avatar.Fallback, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
|
@ -37,36 +34,44 @@ export abstract class AvatarStyles {
|
|||
});
|
||||
}
|
||||
|
||||
export type AvatarProps = React.ComponentProps<typeof AvatarStyles.Root> & {
|
||||
/**
|
||||
* Fallback node.
|
||||
* In case of string, transformed to upper case and sliced to second letter.
|
||||
*/
|
||||
fallback?: React.ReactNode;
|
||||
/**
|
||||
* Source of the image.
|
||||
* If not provided, fallback will be used.
|
||||
*/
|
||||
src?: AvatarImageProps['src'];
|
||||
/**
|
||||
* Alt text of the image.
|
||||
*/
|
||||
alt?: AvatarImageProps['alt'];
|
||||
export namespace AvatarStyles {
|
||||
export type RootProps = React.ComponentPropsWithRef<
|
||||
typeof AvatarStyles.Root
|
||||
> & {
|
||||
/**
|
||||
* Fallback node.
|
||||
* In case of string, transformed to upper case and sliced to second letter.
|
||||
*/
|
||||
fallback?: React.ReactNode;
|
||||
/**
|
||||
* Source of the image.
|
||||
* If not provided, fallback will be used.
|
||||
*/
|
||||
src?: ImageProps['src'];
|
||||
/**
|
||||
* Alt text of the image.
|
||||
*/
|
||||
alt?: ImageProps['alt'];
|
||||
|
||||
/**
|
||||
* Props of the image tag.
|
||||
* @see {@link AvatarImageProps}
|
||||
* @default {}
|
||||
*/
|
||||
imageProps?: AvatarImageProps;
|
||||
/**
|
||||
* Props of the fallback tag.
|
||||
* @see {@link AvatarFallbackProps}
|
||||
* @default {}
|
||||
*/
|
||||
fallbackProps?: AvatarFallbackProps;
|
||||
};
|
||||
export type AvatarImageProps = React.ComponentProps<typeof AvatarStyles.Image>;
|
||||
export type AvatarFallbackProps = React.ComponentProps<
|
||||
typeof AvatarStyles.Fallback
|
||||
>;
|
||||
/**
|
||||
* Props of the image tag.
|
||||
* @see {@link AvatarImageProps}
|
||||
* @default {}
|
||||
*/
|
||||
imageProps?: ImageProps;
|
||||
/**
|
||||
* Props of the fallback tag.
|
||||
* @see {@link AvatarFallbackProps}
|
||||
* @default {}
|
||||
*/
|
||||
fallbackProps?: FallbackProps;
|
||||
};
|
||||
|
||||
export type ImageProps = React.ComponentPropsWithRef<
|
||||
typeof AvatarStyles.Image
|
||||
>;
|
||||
|
||||
export type FallbackProps = React.ComponentPropsWithRef<
|
||||
typeof AvatarStyles.Fallback
|
||||
>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { forwardStyledRef } from '@/theme';
|
||||
|
||||
import { AvatarProps, AvatarStyles } from './avatar.styles';
|
||||
import { AvatarStyles as AS } from './avatar.styles';
|
||||
|
||||
export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
|
||||
({ imageProps = {}, src, alt, css, ...rootProps }, ref) => {
|
||||
export const Avatar = forwardStyledRef<HTMLDivElement, AS.RootProps>(
|
||||
({ imageProps = {}, src, alt, ...rootProps }, ref) => {
|
||||
return (
|
||||
<AvatarStyles.Root {...rootProps} ref={ref} css={css}>
|
||||
<AvatarStyles.Image src={src} alt={alt} {...imageProps} />
|
||||
</AvatarStyles.Root>
|
||||
<AS.Root {...rootProps} ref={ref}>
|
||||
<AS.Image src={src} alt={alt} {...imageProps} />
|
||||
</AS.Root>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
import { Combobox as HeadlessCombobox } from '@headlessui/react';
|
||||
|
||||
import { styled } from '@/theme';
|
||||
|
||||
import { Icon } from '../icon';
|
||||
import { IconStyles } from '../icon/icon.styles';
|
||||
import { InputStyled } from '../input';
|
||||
|
||||
export const ComboboxStyles = {
|
||||
Wrapper: styled('div', {
|
||||
position: 'relative',
|
||||
|
||||
variants: {
|
||||
unattached: {
|
||||
true: {
|
||||
position: 'static',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
Option: styled(HeadlessCombobox.Option, {
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '$3',
|
||||
cursor: 'pointer',
|
||||
padding: '$2 $3',
|
||||
borderRadius: '$lg',
|
||||
color: '$slate11',
|
||||
transition: '$all-200',
|
||||
|
||||
'&[data-headlessui-state*="selected"]': {
|
||||
backgroundColor: '$slate3',
|
||||
},
|
||||
|
||||
'&[data-headlessui-state*="active"]': {
|
||||
backgroundColor: '$slate2',
|
||||
color: '$slate12',
|
||||
},
|
||||
}),
|
||||
|
||||
Options: styled(HeadlessCombobox.Options, {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'absolute',
|
||||
border: '1px solid $slate6',
|
||||
backgroundColor: '$black',
|
||||
boxSizing: 'border-box',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 'calc(100% + $3)',
|
||||
padding: '$3',
|
||||
gap: '$2',
|
||||
borderRadius: '$lg',
|
||||
zIndex: 10,
|
||||
maxHeight: '30vh',
|
||||
overflow: 'auto',
|
||||
}),
|
||||
|
||||
Field: styled(HeadlessCombobox.Button, InputStyled, {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
gap: '$3',
|
||||
|
||||
'&:focus-within': {
|
||||
outline: 'none',
|
||||
borderColor: '$blue9',
|
||||
},
|
||||
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}),
|
||||
|
||||
Input: styled(HeadlessCombobox.Input, {
|
||||
width: '100%',
|
||||
color: '$slate11',
|
||||
backgroundColor: 'transparent',
|
||||
outline: 'none',
|
||||
}),
|
||||
|
||||
RightPositionedIcon: styled(Icon, {
|
||||
position: 'absolute',
|
||||
right: '$3',
|
||||
}),
|
||||
|
||||
Message: styled('span', {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '$2',
|
||||
color: '$slate8',
|
||||
fontStyle: 'italic',
|
||||
}),
|
||||
|
||||
InnerSearchContainer: styled('div', {
|
||||
position: 'sticky',
|
||||
top: '-$3',
|
||||
padding: '$3 $2 $3 $2',
|
||||
margin: '-$3 0 0 0',
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '$3',
|
||||
borderBottom: '1px solid $slate6',
|
||||
backgroundColor: '$black',
|
||||
|
||||
[`${IconStyles.Container}`]: {
|
||||
fontSize: '1.5em',
|
||||
color: '$slate8',
|
||||
},
|
||||
|
||||
'input::placeholder': {
|
||||
color: '$slate8',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
@ -1,279 +1,219 @@
|
|||
import {
|
||||
Combobox as ComboboxLib,
|
||||
ComboboxInputProps as ComboboxLibInputProps,
|
||||
Transition,
|
||||
Combobox as HeadlessCombobox,
|
||||
ComboboxInputProps,
|
||||
} from '@headlessui/react';
|
||||
import React, { forwardRef, Fragment, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Icon, IconName } from '@/components/core/icon';
|
||||
import { Flex } from '@/components/layout';
|
||||
import { useDebounce } from '@/hooks/use-debounce';
|
||||
import { Spinner } from '@/components/spinner';
|
||||
import { createContext } from '@/utils';
|
||||
|
||||
import { Separator } from '../separator.styles';
|
||||
import { cleanString } from './combobox.utils';
|
||||
import { Icon } from '../icon';
|
||||
import { ComboboxStyles as CS } from './combobox.styles';
|
||||
|
||||
type ComboboxInputProps = ComboboxLibInputProps<'input', ComboboxItem>;
|
||||
const EmptyMessage = <CS.Message>No items found</CS.Message>;
|
||||
const LoadingMessage = (
|
||||
<CS.Message>
|
||||
<Spinner /> Searching...
|
||||
</CS.Message>
|
||||
);
|
||||
|
||||
const ComboboxInput: React.FC<ComboboxInputProps> = ({
|
||||
const [Provider, useContext] = createContext<Combobox.Context<unknown>>({
|
||||
name: 'ComboboxContext',
|
||||
hookName: 'useComboboxContext',
|
||||
providerName: 'ComboboxProvider',
|
||||
});
|
||||
|
||||
const Input = <T,>(props: Combobox.InputProps<T>): JSX.Element => {
|
||||
const {
|
||||
query: [, setQuery],
|
||||
} = useContext() as Combobox.Context<T>;
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setQuery(event.target.value);
|
||||
if (props.onChange) props.onChange(event);
|
||||
};
|
||||
|
||||
return <CS.Input {...props} onChange={onChange} />;
|
||||
};
|
||||
|
||||
const Options = <T,>({
|
||||
disableSearch,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxInputProps) => (
|
||||
<div className="relative w-full">
|
||||
<Icon
|
||||
name="search"
|
||||
size="sm"
|
||||
css={{
|
||||
position: 'absolute',
|
||||
left: '$3',
|
||||
top: '$3',
|
||||
fontSize: '$xl',
|
||||
color: '$slate8',
|
||||
}}
|
||||
/>
|
||||
<ComboboxLib.Input
|
||||
placeholder="Search"
|
||||
className={`w-full h-9 py-3 px-10 text-sm bg-transparent leading-5 text-slate11 outline-none `}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}: Combobox.OptionsProps<T>): JSX.Element => {
|
||||
const {
|
||||
query: [query],
|
||||
loading,
|
||||
selected: [selected],
|
||||
items,
|
||||
queryFilter,
|
||||
} = useContext() as Combobox.Context<T>;
|
||||
|
||||
type ComboboxOptionProps = {
|
||||
option: ComboboxItem;
|
||||
};
|
||||
|
||||
const ComboboxOption: React.FC<ComboboxOptionProps> = ({
|
||||
option,
|
||||
}: ComboboxOptionProps) => (
|
||||
<ComboboxLib.Option
|
||||
value={option}
|
||||
className={({ active }) =>
|
||||
`relative cursor-default select-none py-2 px-3.5 text-slate11 rounded-xl mb-2 text-sm ${
|
||||
active ? 'bg-slate5 text-slate12' : 'bg-transparent'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<Flex css={{ justifyContent: 'space-between' }}>
|
||||
<Flex css={{ flexDirection: 'row', maxWidth: '95%' }}>
|
||||
{option.icon}
|
||||
<span
|
||||
className={`${active ? 'text-slate12' : 'text-slate11'} ${
|
||||
option.icon ? 'max-w-70' : 'max-w-full'
|
||||
} whitespace-nowrap text-ellipsis overflow-hidden`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</Flex>
|
||||
{selected && <Icon name="check" color="white" />}
|
||||
</Flex>
|
||||
)}
|
||||
</ComboboxLib.Option>
|
||||
);
|
||||
|
||||
export const NoResults: React.FC = ({ css }: { css?: string }) => (
|
||||
<div
|
||||
className={`relative cursor-default select-none pt-2 px-3.5 pb-4 text-slate11 ${css}`}
|
||||
>
|
||||
Nothing found.
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ComboboxItem = {
|
||||
/**
|
||||
* The key of the item.
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* The label to display of the item.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Optional icon to display on the left of the item.
|
||||
*/
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export type ComboboxProps = {
|
||||
/**
|
||||
* List of items to be displayed in the combobox.
|
||||
*/
|
||||
items: ComboboxItem[];
|
||||
/**
|
||||
* The selected value of the combobox.
|
||||
*/
|
||||
selectedValue: ComboboxItem | undefined;
|
||||
/**
|
||||
* If true, the combobox will add the input if it doesn't exist in the list of items.
|
||||
*/
|
||||
withAutocomplete?: boolean;
|
||||
/**
|
||||
* Name of the left icon to display in the input. Defualt is "search".
|
||||
*/
|
||||
leftIcon?: IconName;
|
||||
/**
|
||||
* Callback when the selected value changes.
|
||||
*/
|
||||
onChange: (option: ComboboxItem) => void;
|
||||
/**
|
||||
* Function to handle the input blur
|
||||
*/
|
||||
onBlur?: () => void;
|
||||
/**
|
||||
* Value to indicate it's invalid
|
||||
*/
|
||||
error?: boolean;
|
||||
css?: string; //tailwind css
|
||||
};
|
||||
|
||||
export const Combobox: React.FC<ComboboxProps> = ({
|
||||
items,
|
||||
selectedValue = { value: '', label: '' },
|
||||
withAutocomplete = false,
|
||||
leftIcon = 'search',
|
||||
onChange,
|
||||
onBlur,
|
||||
error = false,
|
||||
css,
|
||||
}) => {
|
||||
const [filteredItems, setFilteredItems] = useState<ComboboxItem[]>([]);
|
||||
const [autocompleteItems, setAutocompleteItems] = useState<ComboboxItem[]>(
|
||||
[]
|
||||
const [
|
||||
optionRenderer,
|
||||
EmptyRender = EmptyMessage,
|
||||
LoadingRender = LoadingMessage,
|
||||
] = useMemo(
|
||||
() => (Array.isArray(children) ? children : [children]),
|
||||
[children]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// If the selected value doesn't exist in the list of items, we add it
|
||||
if (
|
||||
items.filter((item) => item === selectedValue).length === 0 &&
|
||||
selectedValue.value !== undefined &&
|
||||
autocompleteItems.length === 0 &&
|
||||
withAutocomplete
|
||||
) {
|
||||
setAutocompleteItems([selectedValue]);
|
||||
}
|
||||
}, [autocompleteItems.length, items, selectedValue, withAutocomplete]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredItems(items);
|
||||
}, [items]);
|
||||
|
||||
const handleSearch = useDebounce((searchValue: string) => {
|
||||
if (searchValue === '') {
|
||||
setFilteredItems(items);
|
||||
|
||||
if (withAutocomplete) {
|
||||
setAutocompleteItems([]);
|
||||
handleComboboxChange({} as ComboboxItem);
|
||||
}
|
||||
} else {
|
||||
const filteredValues = items.filter((item) =>
|
||||
cleanString(item.label).startsWith(cleanString(searchValue))
|
||||
);
|
||||
|
||||
if (withAutocomplete && filteredValues.length === 0) {
|
||||
// If the search value doesn't exist in the list of items, we add it
|
||||
setAutocompleteItems([{ value: searchValue, label: searchValue }]);
|
||||
}
|
||||
setFilteredItems(filteredValues);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
const handleInputChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
): void => {
|
||||
event.stopPropagation();
|
||||
handleSearch(event.target.value);
|
||||
};
|
||||
|
||||
const handleComboboxChange = (optionSelected: ComboboxItem): void => {
|
||||
onChange(optionSelected);
|
||||
};
|
||||
|
||||
const handleLeaveTransition = (): void => {
|
||||
setFilteredItems(items);
|
||||
if (selectedValue.value === undefined && withAutocomplete) {
|
||||
setAutocompleteItems([]);
|
||||
handleComboboxChange({} as ComboboxItem);
|
||||
}
|
||||
};
|
||||
const filteredItems = useMemo(
|
||||
() => items.filter((item) => queryFilter(query, item)),
|
||||
[items, query, queryFilter]
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboboxLib
|
||||
value={selectedValue}
|
||||
by="value"
|
||||
onChange={handleComboboxChange}
|
||||
>
|
||||
{({ open }) => (
|
||||
<div className={`relative w-full ${css ? css : ''}`}>
|
||||
<div className="relative w-full">
|
||||
<Icon
|
||||
name={leftIcon}
|
||||
size="sm"
|
||||
css={{
|
||||
position: 'absolute',
|
||||
left: '$3',
|
||||
top: '$3',
|
||||
fontSize: '$xl',
|
||||
color: 'slate8',
|
||||
}}
|
||||
/>
|
||||
<ComboboxLib.Button
|
||||
className={`w-full text-left border-solid border rounded-xl h-11 py-3 px-10 text-sm leading-5 text-slate11 outline-none ${
|
||||
error ? 'border-red9' : 'border-slate7'
|
||||
}`}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{selectedValue && selectedValue.label
|
||||
? selectedValue.label
|
||||
: 'Search'}
|
||||
</ComboboxLib.Button>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<Icon name="chevron-down" css={{ fontSize: '$xs' }} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
enter="transition duration-400 ease-out"
|
||||
leave="transition ease-out duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
afterLeave={handleLeaveTransition}
|
||||
>
|
||||
<div className="absolute max-h-60 mt-2 w-full z-10 overflow-auto rounded-xl border-solid border-slate6 border bg-black pt-2 px-3 text-base focus:outline-none sm:text-sm">
|
||||
<ComboboxInput onChange={handleInputChange} onBlur={onBlur} />
|
||||
<Separator />
|
||||
<ComboboxLib.Options className="mt-1 z-20">
|
||||
{[...autocompleteItems, ...filteredItems].length === 0 ||
|
||||
filteredItems === undefined ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<>
|
||||
{autocompleteItems.length > 0 && <span>Create new</span>}
|
||||
{autocompleteItems.map(
|
||||
(autocompleteOption: ComboboxItem) => (
|
||||
<ComboboxOption
|
||||
key={autocompleteOption.value}
|
||||
option={autocompleteOption}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{autocompleteItems.length > 0 &&
|
||||
filteredItems.length > 0 && (
|
||||
<Separator css={{ mb: '$2' }} />
|
||||
)}
|
||||
{filteredItems.map((option: ComboboxItem) => (
|
||||
<ComboboxOption key={option.value} option={option} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ComboboxLib.Options>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<CS.Options {...props}>
|
||||
{!disableSearch && (
|
||||
<CS.InnerSearchContainer>
|
||||
<Icon name="search" />
|
||||
<Input placeholder="Search..." />
|
||||
</CS.InnerSearchContainer>
|
||||
)}
|
||||
</ComboboxLib>
|
||||
|
||||
{filteredItems.map((item) => (
|
||||
<CS.Option key={JSON.stringify(item)} value={item}>
|
||||
{optionRenderer(item, selected === item)}
|
||||
{selected === item && <CS.RightPositionedIcon name="check" />}
|
||||
</CS.Option>
|
||||
))}
|
||||
|
||||
{!loading && filteredItems.length === 0 && EmptyRender}
|
||||
|
||||
{loading && LoadingRender}
|
||||
</CS.Options>
|
||||
);
|
||||
};
|
||||
|
||||
Combobox.displayName = 'Combobox';
|
||||
const Field = <T,>({
|
||||
children,
|
||||
disableChevron,
|
||||
...props
|
||||
}: Combobox.FieldProps<T>): JSX.Element => {
|
||||
const {
|
||||
selected: [selected],
|
||||
} = useContext() as Combobox.Context<T>;
|
||||
|
||||
return (
|
||||
<CS.Field {...props}>
|
||||
{children(selected)}
|
||||
{!disableChevron && <CS.RightPositionedIcon name="chevron-down" />}
|
||||
</CS.Field>
|
||||
);
|
||||
};
|
||||
|
||||
export const Combobox = <T,>({
|
||||
children,
|
||||
selected,
|
||||
isLoading: loading = false,
|
||||
items,
|
||||
queryKey,
|
||||
...props
|
||||
}: Combobox.RootProps<T>): JSX.Element => {
|
||||
const [value, setValue] = selected;
|
||||
const query = useState('');
|
||||
|
||||
const queryFilter = useCallback(
|
||||
(query: string, item: T): boolean => {
|
||||
if (typeof queryKey === 'undefined')
|
||||
return `${item}`.includes(query.toLowerCase());
|
||||
|
||||
const keys = Array.isArray(queryKey) ? queryKey : [queryKey];
|
||||
|
||||
const searchString = keys
|
||||
.reduce((acc, key) => {
|
||||
const value = item[key];
|
||||
return `${acc} ${value}`;
|
||||
}, '')
|
||||
.toLowerCase();
|
||||
|
||||
return searchString.includes(query.toLowerCase());
|
||||
},
|
||||
[queryKey]
|
||||
);
|
||||
|
||||
const TypedProvider = Provider as React.Provider<Combobox.Context<T>>;
|
||||
|
||||
return (
|
||||
<HeadlessCombobox
|
||||
as={CS.Wrapper}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
{...props}
|
||||
>
|
||||
{({ open }) => (
|
||||
<TypedProvider
|
||||
value={{
|
||||
selected,
|
||||
query,
|
||||
loading,
|
||||
open,
|
||||
items,
|
||||
queryFilter,
|
||||
}}
|
||||
>
|
||||
{children({
|
||||
Options: Options<T>,
|
||||
Input: Input<T>,
|
||||
Field: Field<T>,
|
||||
Message: CS.Message,
|
||||
})}
|
||||
</TypedProvider>
|
||||
)}
|
||||
</HeadlessCombobox>
|
||||
);
|
||||
};
|
||||
|
||||
export namespace Combobox {
|
||||
export type Context<T> = {
|
||||
items: T[];
|
||||
selected: [T | undefined, (newState: T | undefined) => void];
|
||||
query: ReactState<string>;
|
||||
loading: boolean;
|
||||
open: boolean;
|
||||
queryFilter: (query: string, item: T) => boolean;
|
||||
};
|
||||
|
||||
export type OptionsProps<T> = Omit<
|
||||
React.ComponentPropsWithRef<typeof CS.Options>,
|
||||
'children'
|
||||
> & {
|
||||
disableSearch?: boolean;
|
||||
children:
|
||||
| ((item: T, selected: boolean) => React.ReactNode)
|
||||
| [
|
||||
(item: T, selected: boolean) => React.ReactNode,
|
||||
React.ReactNode?,
|
||||
React.ReactNode?
|
||||
];
|
||||
};
|
||||
|
||||
export type InputProps<T> = ComboboxInputProps<'input', T | undefined>;
|
||||
|
||||
export type FieldProps<T> = Omit<
|
||||
React.ComponentPropsWithRef<typeof CS.Field>,
|
||||
'children'
|
||||
> & {
|
||||
children: (item: T | undefined) => React.ReactElement | React.ReactNode;
|
||||
disableChevron?: boolean;
|
||||
};
|
||||
|
||||
export type Elements<T> = {
|
||||
Options: React.FC<OptionsProps<T>>;
|
||||
Input: React.FC<InputProps<T>>;
|
||||
Field: React.FC<FieldProps<T>>;
|
||||
Message: typeof CS.Message;
|
||||
};
|
||||
|
||||
export type RootProps<T> = Omit<
|
||||
React.ComponentPropsWithRef<typeof CS.Wrapper>,
|
||||
'defaultValue' | 'onChange' | 'children'
|
||||
> &
|
||||
Pick<Context<T>, 'selected' | 'items'> & {
|
||||
isLoading?: boolean;
|
||||
children: (elements: Elements<T>) => React.ReactNode;
|
||||
} & (T extends object
|
||||
? { queryKey: keyof T | (keyof T)[] }
|
||||
: { queryKey?: undefined });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
export const cleanString = (str: string): string =>
|
||||
str.toLowerCase().replace(/\s+/g, '');
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
export * from './combobox';
|
||||
export * from './dropdown';
|
||||
export * from './combobox';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { forwardStyledRef } from '@/theme';
|
||||
|
||||
import { IconStyles } from './icon.styles';
|
||||
import { IconLibrary, IconName, IconType } from './icon-library';
|
||||
|
|
@ -8,19 +8,18 @@ export type IconProps = {
|
|||
iconElementCss?: React.CSSProperties;
|
||||
} & React.ComponentProps<typeof IconStyles.Container>;
|
||||
|
||||
export const Icon: React.FC<IconProps> = forwardRef<HTMLSpanElement, IconProps>(
|
||||
(props, ref) => {
|
||||
const { name, iconElementCss, ...rest } = props;
|
||||
const IconElement: IconType<typeof name> = IconLibrary[name];
|
||||
export const Icon: React.FC<IconProps> = forwardStyledRef<
|
||||
HTMLSpanElement,
|
||||
IconProps
|
||||
>((props, ref) => {
|
||||
const { name, iconElementCss, ...rest } = props;
|
||||
const IconElement: IconType<typeof name> = IconLibrary[name];
|
||||
|
||||
return (
|
||||
<IconStyles.Container {...rest} ref={ref}>
|
||||
<IconElement
|
||||
style={{ width: '1em', height: '1em', ...iconElementCss }}
|
||||
/>
|
||||
</IconStyles.Container>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<IconStyles.Container {...rest} ref={ref}>
|
||||
<IconElement style={{ width: '1em', height: '1em', ...iconElementCss }} />
|
||||
</IconStyles.Container>
|
||||
);
|
||||
});
|
||||
|
||||
Icon.displayName = 'Icon';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { forwardStyledRef } from '@/theme';
|
||||
|
||||
import { IconName } from '../icon';
|
||||
import { InputIconStyled, InputStyled, TextareaStyled } from './input.styles';
|
||||
|
|
@ -10,24 +12,26 @@ export const LogoFileInput = StyledInputFile;
|
|||
|
||||
type InputProps = {
|
||||
leftIcon?: IconName;
|
||||
css?: string; //tailwind css
|
||||
wrapperClassName?: string; //tailwind css
|
||||
} & React.ComponentPropsWithRef<typeof InputStyled>;
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
||||
const { leftIcon, css, ...ownProps } = props;
|
||||
export const Input = forwardStyledRef<HTMLInputElement, InputProps>(
|
||||
(props, ref) => {
|
||||
const { leftIcon, wrapperClassName: css = '', ...ownProps } = props;
|
||||
|
||||
return (
|
||||
<div className={`relative ${css ? css : ''}`}>
|
||||
{leftIcon && (
|
||||
<InputIconStyled name={leftIcon} css={{ fontSize: '$lg' }} />
|
||||
)}
|
||||
<InputStyled
|
||||
{...props}
|
||||
ref={ref}
|
||||
css={{ ...(leftIcon && { pl: '$10' }), ...(ownProps.css || {}) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div className={`relative ${css}`}>
|
||||
{leftIcon && (
|
||||
<InputIconStyled name={leftIcon} css={{ fontSize: '$lg' }} />
|
||||
)}
|
||||
<InputStyled
|
||||
{...props}
|
||||
ref={ref}
|
||||
css={{ ...(leftIcon && { pl: '$10' }), ...(ownProps.css || {}) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import React, { forwardRef, useMemo, useState } from 'react';
|
|||
import { hasValidator } from '@/utils';
|
||||
import { fileToBase64 } from '@/views/mint/nfa-step/form-step/form.utils';
|
||||
|
||||
import { ColorPicker, Combobox, ComboboxItem } from '../core';
|
||||
import { ColorPicker, Combobox } from '../core';
|
||||
import { Input, LogoFileInput, Textarea } from '../core/input';
|
||||
import {
|
||||
FormProvider,
|
||||
|
|
@ -134,7 +134,10 @@ export abstract class Form {
|
|||
}
|
||||
);
|
||||
|
||||
static readonly Combobox: React.FC<Form.ComboboxProps> = (props) => {
|
||||
static readonly Combobox = <T,>({
|
||||
handleValue,
|
||||
...props
|
||||
}: Form.ComboboxProps<T>): JSX.Element => {
|
||||
const {
|
||||
id,
|
||||
validators,
|
||||
|
|
@ -142,21 +145,16 @@ export abstract class Form {
|
|||
validationEnabled: [validationEnabled, setValidationEnabled],
|
||||
} = useFormFieldContext();
|
||||
|
||||
const comboboxValue = useMemo(() => {
|
||||
// if it's with autocomplete maybe won't be on the items list
|
||||
const item = props.items.find((item) => item.label === value);
|
||||
if (props.withAutocomplete && !item && value !== '') {
|
||||
//return the selected value if the item doesn't exist
|
||||
return { label: value, value: value };
|
||||
}
|
||||
const selected = useMemo(() => {
|
||||
const item = props.items.find((item) => handleValue(item) === value);
|
||||
return item;
|
||||
}, [props.items, props.withAutocomplete, value]);
|
||||
}, [props.items, value, handleValue]);
|
||||
|
||||
const isValid = useFormFieldValidatorValue(id, validators, value);
|
||||
|
||||
const handleComboboxChange = (option: ComboboxItem): void => {
|
||||
const setSelected = (option: T): void => {
|
||||
if (props.onChange) props.onChange(option);
|
||||
setValue(option.label);
|
||||
setValue(handleValue(option));
|
||||
};
|
||||
|
||||
const handleComboboxBlur = (): void => {
|
||||
|
|
@ -166,8 +164,7 @@ export abstract class Form {
|
|||
return (
|
||||
<Combobox
|
||||
{...props}
|
||||
onChange={handleComboboxChange}
|
||||
selectedValue={comboboxValue || ({} as ComboboxItem)}
|
||||
selected={[selected, setSelected]}
|
||||
onBlur={handleComboboxBlur}
|
||||
error={validationEnabled && !isValid}
|
||||
/>
|
||||
|
|
@ -255,7 +252,7 @@ export abstract class Form {
|
|||
|
||||
const handleFileInputChange = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
): void => {
|
||||
): Promise<void> => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
//Convert to string base64 to send to contract
|
||||
|
|
@ -294,12 +291,10 @@ export namespace Form {
|
|||
'value' | 'error'
|
||||
>;
|
||||
|
||||
export type ComboboxProps = {
|
||||
onChange?: (option: ComboboxItem) => void;
|
||||
} & Omit<
|
||||
React.ComponentProps<typeof Combobox>,
|
||||
'error' | 'selectedValue' | 'onChange'
|
||||
>;
|
||||
export type ComboboxProps<T> = Omit<Combobox.RootProps<T>, 'selected'> & {
|
||||
handleValue: (item: T) => string;
|
||||
onChange?: (item: T) => void;
|
||||
};
|
||||
|
||||
export type LogoFileInputProps = Omit<
|
||||
React.ComponentProps<typeof LogoFileInput>,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { ComboboxItem } from '@/components';
|
||||
|
||||
const listSites = [
|
||||
{
|
||||
|
|
@ -29,13 +28,13 @@ const listSites = [
|
|||
},
|
||||
];
|
||||
|
||||
const listBranches: ComboboxItem[] = [
|
||||
const listBranches = [
|
||||
{ value: '4573495837458934', label: 'main' },
|
||||
{ value: '293857439857348', label: 'develop' },
|
||||
{ value: '12344', label: 'feat/nabvar' },
|
||||
];
|
||||
|
||||
export const fetchMintedSites = async (): Promise<ComboboxItem[]> => {
|
||||
export const fetchMintedSites = async (): Promise<typeof listBranches> => {
|
||||
return new Promise((resolved) => {
|
||||
setTimeout(() => {
|
||||
resolved(listBranches);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import { ComboboxItem } from '@/components';
|
||||
import { githubActions, RootState } from '@/store';
|
||||
import { AppLog } from '@/utils';
|
||||
|
||||
|
|
@ -25,7 +24,7 @@ export const fetchBranchesThunk = createAsyncThunk<void, FetchBranches>(
|
|||
|
||||
const branches = await githubClient.fetchBranches(owner, repository);
|
||||
|
||||
dispatch(githubActions.setBranches(branches as ComboboxItem[]));
|
||||
dispatch(githubActions.setBranches(branches));
|
||||
} catch (error) {
|
||||
AppLog.errorToast(
|
||||
'We have a problem trying to get your branches. Please try again later.'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
|
|||
import { RootState } from '@/store';
|
||||
import { AppLog } from '@/utils';
|
||||
|
||||
import { GithubClient, UserData } from '../github-client';
|
||||
import { GithubClient } from '../github-client';
|
||||
import { githubActions } from '../github-slice';
|
||||
|
||||
export const fetchUserAndOrgsThunk = createAsyncThunk(
|
||||
|
|
@ -19,24 +19,22 @@ export const fetchUserAndOrgsThunk = createAsyncThunk(
|
|||
|
||||
const githubClient = new GithubClient(token);
|
||||
|
||||
const response = await Promise.all([
|
||||
const [userResponse, orgsResponse] = await Promise.all([
|
||||
githubClient.fetchUser(),
|
||||
githubClient.fetchOrgs(),
|
||||
]);
|
||||
const userResponse = response[0];
|
||||
const orgsResponse = response[1];
|
||||
|
||||
let comboboxItems: UserData[] = [];
|
||||
const items: GithubClient.UserData[] = [];
|
||||
|
||||
if (userResponse) {
|
||||
comboboxItems.push(userResponse);
|
||||
items.push(userResponse);
|
||||
}
|
||||
|
||||
if (orgsResponse) {
|
||||
comboboxItems = [...comboboxItems, ...orgsResponse];
|
||||
items.push(...orgsResponse);
|
||||
}
|
||||
|
||||
dispatch(githubActions.setUserAndOrgs(comboboxItems));
|
||||
dispatch(githubActions.setUserAndOrgs(items));
|
||||
} catch (error) {
|
||||
AppLog.errorToast('We have a problem. Please try again later.');
|
||||
dispatch(githubActions.setQueryState('failed'));
|
||||
|
|
|
|||
|
|
@ -1,15 +1,7 @@
|
|||
import { Octokit } from 'octokit';
|
||||
|
||||
import { DropdownItem } from '@/components';
|
||||
|
||||
import { GithubState } from './github-slice';
|
||||
|
||||
export type UserData = {
|
||||
value: string;
|
||||
label: string;
|
||||
avatar: string;
|
||||
};
|
||||
|
||||
export class GithubClient {
|
||||
octokit: Octokit;
|
||||
token: string;
|
||||
|
|
@ -21,7 +13,7 @@ export class GithubClient {
|
|||
}));
|
||||
}
|
||||
|
||||
async fetchUser(): Promise<UserData> {
|
||||
async fetchUser(): Promise<GithubClient.UserData> {
|
||||
const { data: userData } = await this.octokit.request('GET /user');
|
||||
|
||||
return {
|
||||
|
|
@ -31,7 +23,7 @@ export class GithubClient {
|
|||
};
|
||||
}
|
||||
|
||||
async fetchOrgs(): Promise<UserData[]> {
|
||||
async fetchOrgs(): Promise<GithubClient.UserData[]> {
|
||||
const { data: organizationsData } = await this.octokit.request(
|
||||
'GET /user/orgs'
|
||||
);
|
||||
|
|
@ -63,7 +55,10 @@ export class GithubClient {
|
|||
);
|
||||
}
|
||||
|
||||
async fetchBranches(owner: string, repo: string): Promise<DropdownItem[]> {
|
||||
async fetchBranches(
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<GithubClient.Branch[]> {
|
||||
const branches = await this.octokit
|
||||
.request('GET /repos/{owner}/{repo}/branches', {
|
||||
owner,
|
||||
|
|
@ -72,8 +67,8 @@ export class GithubClient {
|
|||
.then((res) =>
|
||||
res.data.map((branch) => {
|
||||
return {
|
||||
label: branch.name,
|
||||
value: branch.commit.sha,
|
||||
name: branch.name,
|
||||
commit: branch.commit.sha,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
@ -81,3 +76,16 @@ export class GithubClient {
|
|||
return branches;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace GithubClient {
|
||||
export type UserData = {
|
||||
value: string;
|
||||
label: string;
|
||||
avatar: string;
|
||||
};
|
||||
|
||||
export type Branch = {
|
||||
name: string;
|
||||
commit: string;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { ComboboxItem } from '@/components';
|
||||
import { RootState } from '@/store';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
|
||||
import * as asyncThunk from './async-thunk';
|
||||
import { UserData } from './github-client';
|
||||
import { GithubClient } from './github-client';
|
||||
|
||||
export namespace GithubState {
|
||||
export type Token = string;
|
||||
|
|
@ -20,7 +19,7 @@ export namespace GithubState {
|
|||
|
||||
export type QueryLoading = 'idle' | 'loading' | 'failed' | 'success';
|
||||
|
||||
export type UserAndOrganizations = Array<UserData>;
|
||||
export type UserAndOrganizations = Array<GithubClient.UserData>;
|
||||
|
||||
export type Repository = {
|
||||
name: string;
|
||||
|
|
@ -30,7 +29,7 @@ export namespace GithubState {
|
|||
|
||||
export type Repositories = Array<Repository>;
|
||||
|
||||
export type Branches = Array<ComboboxItem>;
|
||||
export type Branches = Array<GithubClient.Branch>;
|
||||
}
|
||||
|
||||
export interface GithubState {
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * from './github-slice';
|
||||
export * from './github-client';
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
import { Card, Grid, Icon, IconButton, Stepper } from '@/components';
|
||||
|
||||
import { CreateAccessPoint } from './create-ap.context';
|
||||
import { CreateAccessPointFormBody } from './create-ap.form-body';
|
||||
|
||||
export const CreateAccessPointForm: React.FC = () => {
|
||||
const { prevStep } = Stepper.useContext();
|
||||
|
||||
const { nfa } = CreateAccessPoint.useContext();
|
||||
|
||||
return (
|
||||
<Card.Container css={{ width: '$107h' }}>
|
||||
<Card.Heading
|
||||
title={`Create Access Point - ${nfa.label || ''}`}
|
||||
title="Create Access Point"
|
||||
leftIcon={
|
||||
<IconButton
|
||||
aria-label="Add"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { ComboboxItem } from '@/components';
|
||||
import { Token } from '@/graphclient';
|
||||
import { EthereumHooks } from '@/integrations';
|
||||
import { useFleekERC721Billing } from '@/store';
|
||||
import { AppLog, createContext, pushToast } from '@/utils';
|
||||
|
||||
type NFA = Pick<Token, 'id' | 'name'>;
|
||||
|
||||
export type AccessPointContext = {
|
||||
billing: string | undefined;
|
||||
nfa: ComboboxItem;
|
||||
setNfa: (nfa: ComboboxItem) => void;
|
||||
nfa: NFA | undefined;
|
||||
setNfa: ReactState<NFA | undefined>[1];
|
||||
};
|
||||
|
||||
const [CreateAPProvider, useContext] = createContext<AccessPointContext>({
|
||||
|
|
@ -29,7 +31,7 @@ export abstract class CreateAccessPoint {
|
|||
children,
|
||||
}) => {
|
||||
const [billing] = useFleekERC721Billing('AddAccessPoint');
|
||||
const [nfa, setNfa] = useState<ComboboxItem>({} as ComboboxItem);
|
||||
const [nfa, setNfa] = useState<NFA>();
|
||||
|
||||
const value = {
|
||||
billing,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useQuery } from '@apollo/client';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Combobox, ComboboxItem } from '@/components';
|
||||
import { Combobox } from '@/components';
|
||||
import { getLatestNFAsDocument } from '@/graphclient';
|
||||
import { AppLog } from '@/utils';
|
||||
|
||||
|
|
@ -11,25 +11,26 @@ export const NfaPicker: React.FC = () => {
|
|||
const { nfa, setNfa } = CreateAccessPoint.useContext();
|
||||
const { data, loading, error } = useQuery(getLatestNFAsDocument);
|
||||
|
||||
const handleNfaChange = (item: ComboboxItem): void => {
|
||||
setNfa(item);
|
||||
};
|
||||
|
||||
const items = useMemo(() => {
|
||||
return data
|
||||
? data.tokens.map(
|
||||
(nfa) => ({ value: nfa.id, label: nfa.name } as ComboboxItem)
|
||||
)
|
||||
: [];
|
||||
}, [data]);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
const items = useMemo(() => data?.tokens || [], [data]);
|
||||
|
||||
if (error) {
|
||||
AppLog.errorToast('Error loading NFA list');
|
||||
AppLog.errorToast('Error loading NFA list', error);
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox items={items} selectedValue={nfa} onChange={handleNfaChange} />
|
||||
<Combobox
|
||||
isLoading={loading}
|
||||
items={items}
|
||||
selected={[nfa, setNfa]}
|
||||
queryKey={['name', 'id']}
|
||||
>
|
||||
{({ Field, Options }) => (
|
||||
<>
|
||||
<Field>{(selected) => selected?.name || 'Select NFA'}</Field>
|
||||
|
||||
<Options>{(item) => item.name}</Options>
|
||||
</>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,53 +1,74 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { Combobox, ComboboxItem, Flex } from '@/components';
|
||||
import { Combobox, Flex, Icon, IconName } from '@/components';
|
||||
|
||||
const itemsCombobox = [
|
||||
{ label: 'Item 1', value: 'item-1' },
|
||||
{ label: 'Item 2', value: 'item-2' },
|
||||
{ label: 'Item 3', value: 'item-3' },
|
||||
type Item = { id: number; label: string; icon: IconName };
|
||||
|
||||
const Items: Item[] = [
|
||||
{ id: 1, label: 'Option 1', icon: 'branch' },
|
||||
{ id: 2, label: 'Option 2', icon: 'ethereum' },
|
||||
{ id: 3, label: 'Option 3', icon: 'metamask' },
|
||||
];
|
||||
|
||||
export const ComboboxTest: React.FC = () => {
|
||||
const [selectedValue, setSelectedValue] = useState({} as ComboboxItem);
|
||||
const [selectedValueAutocomplete, setSelectedValueAutocomplete] = useState(
|
||||
{} as ComboboxItem
|
||||
);
|
||||
|
||||
const handleComboboxChange = (value: ComboboxItem): void => {
|
||||
setSelectedValue(value);
|
||||
};
|
||||
|
||||
const handleComboboxChangeAutocomplete = (value: ComboboxItem): void => {
|
||||
setSelectedValueAutocomplete(value);
|
||||
};
|
||||
const selected = useState<Item>();
|
||||
|
||||
return (
|
||||
<Flex
|
||||
css={{
|
||||
position: 'relative',
|
||||
flexDirection: 'column',
|
||||
margin: '100px',
|
||||
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
width: '600px',
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
>
|
||||
<h1>Components Test</h1>
|
||||
<Flex css={{ width: '600px', gap: '$2' }}>
|
||||
<Combobox
|
||||
items={itemsCombobox}
|
||||
selectedValue={selectedValue}
|
||||
onChange={handleComboboxChange}
|
||||
leftIcon="github"
|
||||
/>
|
||||
<Combobox unattached items={Items} selected={selected} queryKey="label">
|
||||
{({ Field, Options }) => (
|
||||
<>
|
||||
<Field>
|
||||
{(selected) => (
|
||||
<>
|
||||
<Icon name={selected?.icon || 'search'} />
|
||||
{selected?.label || 'Select an option'}
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Combobox
|
||||
items={itemsCombobox}
|
||||
selectedValue={selectedValueAutocomplete}
|
||||
onChange={handleComboboxChangeAutocomplete}
|
||||
withAutocomplete
|
||||
/>
|
||||
</Flex>
|
||||
<Options>
|
||||
{(item) => (
|
||||
<>
|
||||
<Icon name={item.icon} /> {item.label}
|
||||
</>
|
||||
)}
|
||||
</Options>
|
||||
</>
|
||||
)}
|
||||
</Combobox>
|
||||
|
||||
<Combobox unattached items={Items} selected={selected} queryKey="label">
|
||||
{({ Field, Options }) => (
|
||||
<>
|
||||
<Field>
|
||||
{(selected) => (
|
||||
<>
|
||||
<Icon name={selected?.icon || 'search'} />
|
||||
{selected?.label || 'Select an option'}
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Options>
|
||||
{(item) => (
|
||||
<>
|
||||
<Icon name={item.icon} /> {item.label}
|
||||
</>
|
||||
)}
|
||||
</Options>
|
||||
</>
|
||||
)}
|
||||
</Combobox>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useQuery } from '@apollo/client';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Flex, NFACard, NFACardSkeleton, NoResults } from '@/components';
|
||||
import { Flex, NFACard, NFACardSkeleton } from '@/components';
|
||||
import { lastNFAsPaginatedDocument } from '@/graphclient';
|
||||
import { useWindowScrollEnd } from '@/hooks';
|
||||
|
||||
|
|
@ -75,7 +75,14 @@ export const NFAListFragment: React.FC = () => {
|
|||
<NFACard data={token} key={token.id} />
|
||||
))}
|
||||
{isLoading && <LoadingSkeletons />}
|
||||
{!isLoading && tokens.length === 0 && <NoResults />}
|
||||
{!isLoading && tokens.length === 0 && (
|
||||
// TODO: update this after designs are done
|
||||
<div
|
||||
className={`relative cursor-default select-none pt-2 px-3.5 pb-4 text-slate11 text-center`}
|
||||
>
|
||||
Nothing found.
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { ComboboxItem, Flex, Form, Spinner } from '@/components';
|
||||
import { githubActions, useAppDispatch, useGithubStore } from '@/store';
|
||||
import { Flex, Form, Icon, Spinner } from '@/components';
|
||||
import {
|
||||
githubActions,
|
||||
GithubClient,
|
||||
useAppDispatch,
|
||||
useGithubStore,
|
||||
} from '@/store';
|
||||
import { AppLog } from '@/utils';
|
||||
import { Mint } from '@/views/mint/mint.context';
|
||||
import { useMintFormContext } from '@/views/mint/nfa-step/form-step';
|
||||
|
|
@ -24,33 +29,34 @@ export const RepoBranchCommitFields: React.FC = () => {
|
|||
const { repositoryName, selectedUserOrg } = Mint.useContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (queryLoading === 'idle') {
|
||||
dispatch(
|
||||
githubActions.fetchBranchesThunk({
|
||||
owner: selectedUserOrg.label,
|
||||
repository: repositoryName.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [queryLoading, dispatch, selectedUserOrg.label, repositoryName.name]);
|
||||
if (!(queryLoading === 'idle' && selectedUserOrg && repositoryName)) return;
|
||||
dispatch(
|
||||
githubActions.fetchBranchesThunk({
|
||||
owner: selectedUserOrg?.label,
|
||||
repository: repositoryName.name,
|
||||
})
|
||||
);
|
||||
}, [queryLoading, dispatch, selectedUserOrg, repositoryName]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (
|
||||
queryLoading === 'success' &&
|
||||
branches.length > 0 &&
|
||||
repositoryName.defaultBranch !== undefined &&
|
||||
gitBranch === '' //we only set the default branch the first time
|
||||
) {
|
||||
const defaultBranch = branches.find(
|
||||
(branch) =>
|
||||
branch.label.toLowerCase() ===
|
||||
repositoryName.defaultBranch.toLowerCase()
|
||||
);
|
||||
if (defaultBranch) {
|
||||
setGitBranch(defaultBranch.label);
|
||||
setGitCommit(defaultBranch.value);
|
||||
}
|
||||
queryLoading !== 'success' ||
|
||||
branches.length === 0 ||
|
||||
!repositoryName ||
|
||||
gitBranch !== ''
|
||||
)
|
||||
return;
|
||||
|
||||
const defaultBranch = branches.find(
|
||||
(branch) =>
|
||||
branch.name.toLowerCase() ===
|
||||
repositoryName.defaultBranch.toLowerCase()
|
||||
);
|
||||
|
||||
if (defaultBranch) {
|
||||
setGitBranch(defaultBranch.name);
|
||||
setGitCommit(defaultBranch.commit);
|
||||
}
|
||||
} catch (error) {
|
||||
AppLog.errorToast('We had a problem. Try again');
|
||||
|
|
@ -58,7 +64,7 @@ export const RepoBranchCommitFields: React.FC = () => {
|
|||
}, [
|
||||
queryLoading,
|
||||
branches,
|
||||
repositoryName.defaultBranch,
|
||||
repositoryName,
|
||||
gitBranch,
|
||||
setGitBranch,
|
||||
setGitCommit,
|
||||
|
|
@ -78,9 +84,9 @@ export const RepoBranchCommitFields: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
const handleBranchChange = (branch: ComboboxItem): void => {
|
||||
setGitBranch(branch.label);
|
||||
setGitCommit(branch.value);
|
||||
const handleBranchChange = (branch: GithubClient.Branch): void => {
|
||||
setGitBranch(branch.name);
|
||||
setGitCommit(branch.commit);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -88,10 +94,26 @@ export const RepoBranchCommitFields: React.FC = () => {
|
|||
<Form.Field context={gitBranchContext}>
|
||||
<Form.Label>Git Branch</Form.Label>
|
||||
<Form.Combobox
|
||||
leftIcon="branch"
|
||||
items={branches}
|
||||
onChange={handleBranchChange}
|
||||
/>
|
||||
queryKey="name"
|
||||
handleValue={(item) => item.name}
|
||||
>
|
||||
{({ Field, Options }) => (
|
||||
<>
|
||||
<Field>
|
||||
{(selected) => (
|
||||
<>
|
||||
<Icon name="branch" />
|
||||
{selected?.name || 'Select a branch'}
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Options>{(item) => item.name}</Options>
|
||||
</>
|
||||
)}
|
||||
</Form.Combobox>
|
||||
</Form.Field>
|
||||
<Form.Field context={gitCommitContext}>
|
||||
<Form.Label>Git Commit</Form.Label>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
ComboboxItem,
|
||||
Flex,
|
||||
Grid,
|
||||
Icon,
|
||||
IconButton,
|
||||
Spinner,
|
||||
} from '@/components';
|
||||
import { Card, Flex, Grid, Icon, IconButton, Spinner } from '@/components';
|
||||
import { Input } from '@/components/core/input';
|
||||
import { useDebounce } from '@/hooks/use-debounce';
|
||||
import { useGithubStore } from '@/store';
|
||||
|
|
@ -50,7 +42,7 @@ export const GithubRepositoryConnection: React.FC = () => {
|
|||
|
||||
const handlePrevStepClick = (): void => {
|
||||
setGithubStep(1);
|
||||
setSelectedUserOrg({} as ComboboxItem);
|
||||
setSelectedUserOrg(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -79,13 +71,13 @@ export const GithubRepositoryConnection: React.FC = () => {
|
|||
/>
|
||||
<Card.Body css={{ pt: '$4' }}>
|
||||
<Grid css={{ rowGap: '$2' }}>
|
||||
<Flex css={{ gap: '$4', pr: '$3h' }}>
|
||||
<Flex css={{ gap: '$4', pr: '$3h', position: 'relative' }}>
|
||||
<UserOrgsCombobox />
|
||||
<Input
|
||||
leftIcon="search"
|
||||
placeholder="Search repo"
|
||||
onChange={handleSearchChange}
|
||||
css="flex-1"
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
</Flex>
|
||||
{queryLoading === 'loading' ||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { Flex, NoResults } from '@/components';
|
||||
import { Flex } from '@/components';
|
||||
import { githubActions, useAppDispatch, useGithubStore } from '@/store';
|
||||
import { Mint } from '@/views/mint/mint.context';
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ export const RepositoriesList: React.FC<RepositoriesListProps> = ({
|
|||
}, [searchValue, repositories]);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryLoading === 'idle' && selectedUserOrg.value) {
|
||||
if (queryLoading === 'idle' && selectedUserOrg?.value) {
|
||||
dispatch(githubActions.fetchRepositoriesThunk(selectedUserOrg.value));
|
||||
}
|
||||
}, [queryLoading, dispatch, selectedUserOrg]);
|
||||
|
|
@ -56,7 +56,12 @@ export const RepositoriesList: React.FC<RepositoriesListProps> = ({
|
|||
/>
|
||||
))
|
||||
) : (
|
||||
<NoResults css="text-center" />
|
||||
// TODO: update this after designs are done
|
||||
<div
|
||||
className={`relative cursor-default select-none pt-2 px-3.5 pb-4 text-slate11 text-center`}
|
||||
>
|
||||
Nothing found.
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,37 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { Avatar, Combobox, ComboboxItem } from '@/components';
|
||||
import { githubActions, useAppDispatch, useGithubStore } from '@/store';
|
||||
import { Avatar, Combobox, Icon } from '@/components';
|
||||
import {
|
||||
githubActions,
|
||||
GithubClient,
|
||||
useAppDispatch,
|
||||
useGithubStore,
|
||||
} from '@/store';
|
||||
import { AppLog } from '@/utils';
|
||||
import { Mint } from '@/views/mint/mint.context';
|
||||
|
||||
const renderSelected = (selected?: GithubClient.UserData): JSX.Element => (
|
||||
<>
|
||||
{selected ? (
|
||||
<Avatar
|
||||
src={selected.avatar}
|
||||
alt={selected.label}
|
||||
css={{ fontSize: '$2xl' }}
|
||||
/>
|
||||
) : (
|
||||
<Icon name="github" css={{ fontSize: '$2xl' }} />
|
||||
)}
|
||||
{selected?.label || 'Select'}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderItem = (item: GithubClient.UserData): JSX.Element => (
|
||||
<>
|
||||
<Avatar src={item.avatar} alt={item.label} />
|
||||
{item.label}
|
||||
</>
|
||||
);
|
||||
|
||||
export const UserOrgsCombobox: React.FC = () => {
|
||||
const { queryUserAndOrganizations, userAndOrganizations } = useGithubStore();
|
||||
const dispatch = useAppDispatch();
|
||||
|
|
@ -17,7 +44,9 @@ export const UserOrgsCombobox: React.FC = () => {
|
|||
}
|
||||
}, [dispatch, queryUserAndOrganizations]);
|
||||
|
||||
const handleUserOrgChange = (item: ComboboxItem): void => {
|
||||
const handleUserOrgChange = (
|
||||
item: GithubClient.UserData | undefined
|
||||
): void => {
|
||||
if (item) {
|
||||
dispatch(githubActions.fetchRepositoriesThunk(item.value));
|
||||
setSelectedUserOrg(item);
|
||||
|
|
@ -29,10 +58,10 @@ export const UserOrgsCombobox: React.FC = () => {
|
|||
useEffect(() => {
|
||||
if (
|
||||
queryUserAndOrganizations === 'success' &&
|
||||
selectedUserOrg.value === undefined &&
|
||||
selectedUserOrg?.value === undefined &&
|
||||
userAndOrganizations.length > 0
|
||||
) {
|
||||
//SET first user
|
||||
// sets the first user
|
||||
setSelectedUserOrg(userAndOrganizations[0]);
|
||||
}
|
||||
}, [
|
||||
|
|
@ -44,18 +73,18 @@ export const UserOrgsCombobox: React.FC = () => {
|
|||
|
||||
return (
|
||||
<Combobox
|
||||
items={userAndOrganizations.map(
|
||||
(item) =>
|
||||
({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
icon: <Avatar src={item.avatar} />,
|
||||
} as ComboboxItem)
|
||||
items={userAndOrganizations}
|
||||
unattached
|
||||
css={{ flex: 1 }}
|
||||
selected={[selectedUserOrg, handleUserOrgChange]}
|
||||
queryKey="label"
|
||||
>
|
||||
{({ Field, Options }) => (
|
||||
<>
|
||||
<Field>{renderSelected}</Field>
|
||||
<Options>{renderItem}</Options>
|
||||
</>
|
||||
)}
|
||||
selectedValue={selectedUserOrg}
|
||||
onChange={handleUserOrgChange}
|
||||
leftIcon="github"
|
||||
css="flex-1"
|
||||
/>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { ComboboxItem } from '@/components';
|
||||
import { EthereumHooks } from '@/integrations';
|
||||
import { GithubState, useFleekERC721Billing } from '@/store';
|
||||
import { GithubClient, GithubState, useFleekERC721Billing } from '@/store';
|
||||
import { AppLog, createContext } from '@/utils';
|
||||
|
||||
export type MintContext = {
|
||||
billing: string | undefined;
|
||||
selectedUserOrg: ComboboxItem;
|
||||
repositoryName: GithubState.Repository;
|
||||
selectedUserOrg: GithubClient.UserData | undefined;
|
||||
repositoryName: GithubState.Repository | undefined;
|
||||
githubStep: number;
|
||||
nfaStep: number;
|
||||
verifyNFA: boolean;
|
||||
setGithubStep: (step: number) => void;
|
||||
setNfaStep: (step: number) => void;
|
||||
setSelectedUserOrg: (userOrgValue: ComboboxItem) => void;
|
||||
setRepositoryName: (repo: GithubState.Repository) => void;
|
||||
setSelectedUserOrg: (userOrgValue: GithubClient.UserData | undefined) => void;
|
||||
setRepositoryName: (repo: GithubState.Repository | undefined) => void;
|
||||
setVerifyNFA: (verify: boolean) => void;
|
||||
};
|
||||
|
||||
|
|
@ -35,9 +34,10 @@ export abstract class Mint {
|
|||
|
||||
static readonly Provider: React.FC<Mint.ProviderProps> = ({ children }) => {
|
||||
//Github Connection
|
||||
const [selectedUserOrg, setSelectedUserOrg] = useState({} as ComboboxItem);
|
||||
const [selectedUserOrg, setSelectedUserOrg] =
|
||||
useState<GithubClient.UserData>();
|
||||
const [repositoryName, setRepositoryName] =
|
||||
useState<GithubState.Repository>({} as GithubState.Repository);
|
||||
useState<GithubState.Repository>();
|
||||
const [githubStep, setGithubStepContext] = useState(1);
|
||||
|
||||
//NFA Details
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { DomainField } from './domain-field';
|
|||
import { EnsField } from './ens-field';
|
||||
|
||||
export const EnsDomainField: React.FC = () => (
|
||||
<Flex css={{ columnGap: '$4' }}>
|
||||
<Flex css={{ columnGap: '$4', position: 'relative' }}>
|
||||
<EnsField />
|
||||
<DomainField />
|
||||
</Flex>
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ import { useCallback, useEffect, useMemo } from 'react';
|
|||
import { useAccount } from 'wagmi';
|
||||
|
||||
import { getENSNamesDocument } from '@/../.graphclient';
|
||||
import { ComboboxItem, Form } from '@/components';
|
||||
import { Form } from '@/components';
|
||||
import { AppLog } from '@/utils';
|
||||
|
||||
import { useMintFormContext } from '../../mint-form.context';
|
||||
|
||||
export const EnsField: React.FC = () => {
|
||||
const { address } = useAccount();
|
||||
const { data, error } = useQuery(getENSNamesDocument, {
|
||||
const { data, error, loading } = useQuery(getENSNamesDocument, {
|
||||
variables: {
|
||||
address: address?.toLowerCase() || '', //should skip if undefined
|
||||
},
|
||||
|
|
@ -34,25 +34,30 @@ export const EnsField: React.FC = () => {
|
|||
}, [error, showError]);
|
||||
|
||||
const ensNames = useMemo(() => {
|
||||
const ensList: ComboboxItem[] = [];
|
||||
if (data && data.account && data.account.domains) {
|
||||
data.account.domains.forEach((ens) => {
|
||||
const { name } = ens;
|
||||
if (name) {
|
||||
ensList.push({
|
||||
label: name,
|
||||
value: name,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return ensList;
|
||||
if (!(data && data.account && data.account.domains)) return [];
|
||||
return data.account.domains.map((ens) => ens.name as string);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Form.Field context={ens} css={{ flex: 1 }}>
|
||||
<Form.Label>ENS</Form.Label>
|
||||
<Form.Combobox items={ensNames} />
|
||||
<Form.Combobox
|
||||
unattached
|
||||
isLoading={loading}
|
||||
items={ensNames}
|
||||
handleValue={(item) => item}
|
||||
>
|
||||
{({ Field, Options, Message }) => (
|
||||
<>
|
||||
<Field>{(selected) => selected || 'Select an ENS'}</Field>
|
||||
|
||||
<Options>
|
||||
{(item) => item}
|
||||
<Message>No owned ENS names found</Message>
|
||||
</Options>
|
||||
</>
|
||||
)}
|
||||
</Form.Combobox>
|
||||
<Form.Overline />
|
||||
</Form.Field>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue