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:
Felipe Mendes 2023-04-17 11:40:31 -03:00 committed by GitHub
parent fdedc18b02
commit 9934ad1cfd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 689 additions and 542 deletions

View File

@ -10,23 +10,20 @@ export abstract class AvatarStyles {
verticalAlign: 'middle', verticalAlign: 'middle',
overflow: 'hidden', overflow: 'hidden',
userSelect: 'none', userSelect: 'none',
width: '$5',
height: '$5',
borderRadius: '100%', borderRadius: '100%',
backgroundColor: '$slate2', backgroundColor: '$slate2',
mr: '$2',
}); });
static readonly Image = styled(Avatar.Image, { static readonly Image = styled(Avatar.Image, {
width: '100%', width: '1em',
height: '100%', height: '1em',
objectFit: 'cover', objectFit: 'cover',
borderRadius: 'inherit', borderRadius: 'inherit',
}); });
static readonly Fallback = styled(Avatar.Fallback, { static readonly Fallback = styled(Avatar.Fallback, {
width: '100%', width: '1em',
height: '100%', height: '1em',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@ -37,36 +34,44 @@ export abstract class AvatarStyles {
}); });
} }
export type AvatarProps = React.ComponentProps<typeof AvatarStyles.Root> & { export namespace AvatarStyles {
/** export type RootProps = React.ComponentPropsWithRef<
* Fallback node. typeof AvatarStyles.Root
* In case of string, transformed to upper case and sliced to second letter. > & {
*/ /**
fallback?: React.ReactNode; * Fallback node.
/** * In case of string, transformed to upper case and sliced to second letter.
* Source of the image. */
* If not provided, fallback will be used. fallback?: React.ReactNode;
*/ /**
src?: AvatarImageProps['src']; * Source of the image.
/** * If not provided, fallback will be used.
* Alt text of the image. */
*/ src?: ImageProps['src'];
alt?: AvatarImageProps['alt']; /**
* Alt text of the image.
*/
alt?: ImageProps['alt'];
/** /**
* Props of the image tag. * Props of the image tag.
* @see {@link AvatarImageProps} * @see {@link AvatarImageProps}
* @default {} * @default {}
*/ */
imageProps?: AvatarImageProps; imageProps?: ImageProps;
/** /**
* Props of the fallback tag. * Props of the fallback tag.
* @see {@link AvatarFallbackProps} * @see {@link AvatarFallbackProps}
* @default {} * @default {}
*/ */
fallbackProps?: AvatarFallbackProps; fallbackProps?: FallbackProps;
}; };
export type AvatarImageProps = React.ComponentProps<typeof AvatarStyles.Image>;
export type AvatarFallbackProps = React.ComponentProps< export type ImageProps = React.ComponentPropsWithRef<
typeof AvatarStyles.Fallback typeof AvatarStyles.Image
>; >;
export type FallbackProps = React.ComponentPropsWithRef<
typeof AvatarStyles.Fallback
>;
}

View File

@ -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>( export const Avatar = forwardStyledRef<HTMLDivElement, AS.RootProps>(
({ imageProps = {}, src, alt, css, ...rootProps }, ref) => { ({ imageProps = {}, src, alt, ...rootProps }, ref) => {
return ( return (
<AvatarStyles.Root {...rootProps} ref={ref} css={css}> <AS.Root {...rootProps} ref={ref}>
<AvatarStyles.Image src={src} alt={alt} {...imageProps} /> <AS.Image src={src} alt={alt} {...imageProps} />
</AvatarStyles.Root> </AS.Root>
); );
} }
); );

View File

@ -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',
},
}),
};

View File

@ -1,279 +1,219 @@
import { import {
Combobox as ComboboxLib, Combobox as HeadlessCombobox,
ComboboxInputProps as ComboboxLibInputProps, ComboboxInputProps,
Transition,
} from '@headlessui/react'; } 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 { Spinner } from '@/components/spinner';
import { Flex } from '@/components/layout'; import { createContext } from '@/utils';
import { useDebounce } from '@/hooks/use-debounce';
import { Separator } from '../separator.styles'; import { Icon } from '../icon';
import { cleanString } from './combobox.utils'; 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 ...props
}: ComboboxInputProps) => ( }: Combobox.OptionsProps<T>): JSX.Element => {
<div className="relative w-full"> const {
<Icon query: [query],
name="search" loading,
size="sm" selected: [selected],
css={{ items,
position: 'absolute', queryFilter,
left: '$3', } = useContext() as Combobox.Context<T>;
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>
);
type ComboboxOptionProps = { const [
option: ComboboxItem; optionRenderer,
}; EmptyRender = EmptyMessage,
LoadingRender = LoadingMessage,
const ComboboxOption: React.FC<ComboboxOptionProps> = ({ ] = useMemo(
option, () => (Array.isArray(children) ? children : [children]),
}: ComboboxOptionProps) => ( [children]
<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[]>(
[]
); );
useEffect(() => { const filteredItems = useMemo(
// If the selected value doesn't exist in the list of items, we add it () => items.filter((item) => queryFilter(query, item)),
if ( [items, query, queryFilter]
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);
}
};
return ( return (
<ComboboxLib <CS.Options {...props}>
value={selectedValue} {!disableSearch && (
by="value" <CS.InnerSearchContainer>
onChange={handleComboboxChange} <Icon name="search" />
> <Input placeholder="Search..." />
{({ open }) => ( </CS.InnerSearchContainer>
<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>
)} )}
</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 });
}

View File

@ -1,2 +0,0 @@
export const cleanString = (str: string): string =>
str.toLowerCase().replace(/\s+/g, '');

View File

@ -1,2 +1,2 @@
export * from './combobox';
export * from './dropdown'; export * from './dropdown';
export * from './combobox';

View File

@ -1,4 +1,4 @@
import { forwardRef } from 'react'; import { forwardStyledRef } from '@/theme';
import { IconStyles } from './icon.styles'; import { IconStyles } from './icon.styles';
import { IconLibrary, IconName, IconType } from './icon-library'; import { IconLibrary, IconName, IconType } from './icon-library';
@ -8,19 +8,18 @@ export type IconProps = {
iconElementCss?: React.CSSProperties; iconElementCss?: React.CSSProperties;
} & React.ComponentProps<typeof IconStyles.Container>; } & React.ComponentProps<typeof IconStyles.Container>;
export const Icon: React.FC<IconProps> = forwardRef<HTMLSpanElement, IconProps>( export const Icon: React.FC<IconProps> = forwardStyledRef<
(props, ref) => { HTMLSpanElement,
const { name, iconElementCss, ...rest } = props; IconProps
const IconElement: IconType<typeof name> = IconLibrary[name]; >((props, ref) => {
const { name, iconElementCss, ...rest } = props;
const IconElement: IconType<typeof name> = IconLibrary[name];
return ( return (
<IconStyles.Container {...rest} ref={ref}> <IconStyles.Container {...rest} ref={ref}>
<IconElement <IconElement style={{ width: '1em', height: '1em', ...iconElementCss }} />
style={{ width: '1em', height: '1em', ...iconElementCss }} </IconStyles.Container>
/> );
</IconStyles.Container> });
);
}
);
Icon.displayName = 'Icon'; Icon.displayName = 'Icon';

View File

@ -1,4 +1,6 @@
import React, { forwardRef } from 'react'; import React from 'react';
import { forwardStyledRef } from '@/theme';
import { IconName } from '../icon'; import { IconName } from '../icon';
import { InputIconStyled, InputStyled, TextareaStyled } from './input.styles'; import { InputIconStyled, InputStyled, TextareaStyled } from './input.styles';
@ -10,24 +12,26 @@ export const LogoFileInput = StyledInputFile;
type InputProps = { type InputProps = {
leftIcon?: IconName; leftIcon?: IconName;
css?: string; //tailwind css wrapperClassName?: string; //tailwind css
} & React.ComponentPropsWithRef<typeof InputStyled>; } & React.ComponentPropsWithRef<typeof InputStyled>;
export const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => { export const Input = forwardStyledRef<HTMLInputElement, InputProps>(
const { leftIcon, css, ...ownProps } = props; (props, ref) => {
const { leftIcon, wrapperClassName: css = '', ...ownProps } = props;
return ( return (
<div className={`relative ${css ? css : ''}`}> <div className={`relative ${css}`}>
{leftIcon && ( {leftIcon && (
<InputIconStyled name={leftIcon} css={{ fontSize: '$lg' }} /> <InputIconStyled name={leftIcon} css={{ fontSize: '$lg' }} />
)} )}
<InputStyled <InputStyled
{...props} {...props}
ref={ref} ref={ref}
css={{ ...(leftIcon && { pl: '$10' }), ...(ownProps.css || {}) }} css={{ ...(leftIcon && { pl: '$10' }), ...(ownProps.css || {}) }}
/> />
</div> </div>
); );
}); }
);
Input.displayName = 'Input'; Input.displayName = 'Input';

View File

@ -4,7 +4,7 @@ import React, { forwardRef, useMemo, useState } from 'react';
import { hasValidator } from '@/utils'; import { hasValidator } from '@/utils';
import { fileToBase64 } from '@/views/mint/nfa-step/form-step/form.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 { Input, LogoFileInput, Textarea } from '../core/input';
import { import {
FormProvider, 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 { const {
id, id,
validators, validators,
@ -142,21 +145,16 @@ export abstract class Form {
validationEnabled: [validationEnabled, setValidationEnabled], validationEnabled: [validationEnabled, setValidationEnabled],
} = useFormFieldContext(); } = useFormFieldContext();
const comboboxValue = useMemo(() => { const selected = useMemo(() => {
// if it's with autocomplete maybe won't be on the items list const item = props.items.find((item) => handleValue(item) === value);
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 };
}
return item; return item;
}, [props.items, props.withAutocomplete, value]); }, [props.items, value, handleValue]);
const isValid = useFormFieldValidatorValue(id, validators, value); const isValid = useFormFieldValidatorValue(id, validators, value);
const handleComboboxChange = (option: ComboboxItem): void => { const setSelected = (option: T): void => {
if (props.onChange) props.onChange(option); if (props.onChange) props.onChange(option);
setValue(option.label); setValue(handleValue(option));
}; };
const handleComboboxBlur = (): void => { const handleComboboxBlur = (): void => {
@ -166,8 +164,7 @@ export abstract class Form {
return ( return (
<Combobox <Combobox
{...props} {...props}
onChange={handleComboboxChange} selected={[selected, setSelected]}
selectedValue={comboboxValue || ({} as ComboboxItem)}
onBlur={handleComboboxBlur} onBlur={handleComboboxBlur}
error={validationEnabled && !isValid} error={validationEnabled && !isValid}
/> />
@ -255,7 +252,7 @@ export abstract class Form {
const handleFileInputChange = async ( const handleFileInputChange = async (
e: React.ChangeEvent<HTMLInputElement> e: React.ChangeEvent<HTMLInputElement>
): void => { ): Promise<void> => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
//Convert to string base64 to send to contract //Convert to string base64 to send to contract
@ -294,12 +291,10 @@ export namespace Form {
'value' | 'error' 'value' | 'error'
>; >;
export type ComboboxProps = { export type ComboboxProps<T> = Omit<Combobox.RootProps<T>, 'selected'> & {
onChange?: (option: ComboboxItem) => void; handleValue: (item: T) => string;
} & Omit< onChange?: (item: T) => void;
React.ComponentProps<typeof Combobox>, };
'error' | 'selectedValue' | 'onChange'
>;
export type LogoFileInputProps = Omit< export type LogoFileInputProps = Omit<
React.ComponentProps<typeof LogoFileInput>, React.ComponentProps<typeof LogoFileInput>,

View File

@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { ComboboxItem } from '@/components';
const listSites = [ const listSites = [
{ {
@ -29,13 +28,13 @@ const listSites = [
}, },
]; ];
const listBranches: ComboboxItem[] = [ const listBranches = [
{ value: '4573495837458934', label: 'main' }, { value: '4573495837458934', label: 'main' },
{ value: '293857439857348', label: 'develop' }, { value: '293857439857348', label: 'develop' },
{ value: '12344', label: 'feat/nabvar' }, { value: '12344', label: 'feat/nabvar' },
]; ];
export const fetchMintedSites = async (): Promise<ComboboxItem[]> => { export const fetchMintedSites = async (): Promise<typeof listBranches> => {
return new Promise((resolved) => { return new Promise((resolved) => {
setTimeout(() => { setTimeout(() => {
resolved(listBranches); resolved(listBranches);

View File

@ -1,6 +1,5 @@
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { ComboboxItem } from '@/components';
import { githubActions, RootState } from '@/store'; import { githubActions, RootState } from '@/store';
import { AppLog } from '@/utils'; import { AppLog } from '@/utils';
@ -25,7 +24,7 @@ export const fetchBranchesThunk = createAsyncThunk<void, FetchBranches>(
const branches = await githubClient.fetchBranches(owner, repository); const branches = await githubClient.fetchBranches(owner, repository);
dispatch(githubActions.setBranches(branches as ComboboxItem[])); dispatch(githubActions.setBranches(branches));
} catch (error) { } catch (error) {
AppLog.errorToast( AppLog.errorToast(
'We have a problem trying to get your branches. Please try again later.' 'We have a problem trying to get your branches. Please try again later.'

View File

@ -3,7 +3,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import { RootState } from '@/store'; import { RootState } from '@/store';
import { AppLog } from '@/utils'; import { AppLog } from '@/utils';
import { GithubClient, UserData } from '../github-client'; import { GithubClient } from '../github-client';
import { githubActions } from '../github-slice'; import { githubActions } from '../github-slice';
export const fetchUserAndOrgsThunk = createAsyncThunk( export const fetchUserAndOrgsThunk = createAsyncThunk(
@ -19,24 +19,22 @@ export const fetchUserAndOrgsThunk = createAsyncThunk(
const githubClient = new GithubClient(token); const githubClient = new GithubClient(token);
const response = await Promise.all([ const [userResponse, orgsResponse] = await Promise.all([
githubClient.fetchUser(), githubClient.fetchUser(),
githubClient.fetchOrgs(), githubClient.fetchOrgs(),
]); ]);
const userResponse = response[0];
const orgsResponse = response[1];
let comboboxItems: UserData[] = []; const items: GithubClient.UserData[] = [];
if (userResponse) { if (userResponse) {
comboboxItems.push(userResponse); items.push(userResponse);
} }
if (orgsResponse) { if (orgsResponse) {
comboboxItems = [...comboboxItems, ...orgsResponse]; items.push(...orgsResponse);
} }
dispatch(githubActions.setUserAndOrgs(comboboxItems)); dispatch(githubActions.setUserAndOrgs(items));
} catch (error) { } catch (error) {
AppLog.errorToast('We have a problem. Please try again later.'); AppLog.errorToast('We have a problem. Please try again later.');
dispatch(githubActions.setQueryState('failed')); dispatch(githubActions.setQueryState('failed'));

View File

@ -1,15 +1,7 @@
import { Octokit } from 'octokit'; import { Octokit } from 'octokit';
import { DropdownItem } from '@/components';
import { GithubState } from './github-slice'; import { GithubState } from './github-slice';
export type UserData = {
value: string;
label: string;
avatar: string;
};
export class GithubClient { export class GithubClient {
octokit: Octokit; octokit: Octokit;
token: string; 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'); const { data: userData } = await this.octokit.request('GET /user');
return { return {
@ -31,7 +23,7 @@ export class GithubClient {
}; };
} }
async fetchOrgs(): Promise<UserData[]> { async fetchOrgs(): Promise<GithubClient.UserData[]> {
const { data: organizationsData } = await this.octokit.request( const { data: organizationsData } = await this.octokit.request(
'GET /user/orgs' '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 const branches = await this.octokit
.request('GET /repos/{owner}/{repo}/branches', { .request('GET /repos/{owner}/{repo}/branches', {
owner, owner,
@ -72,8 +67,8 @@ export class GithubClient {
.then((res) => .then((res) =>
res.data.map((branch) => { res.data.map((branch) => {
return { return {
label: branch.name, name: branch.name,
value: branch.commit.sha, commit: branch.commit.sha,
}; };
}) })
); );
@ -81,3 +76,16 @@ export class GithubClient {
return branches; return branches;
} }
} }
export namespace GithubClient {
export type UserData = {
value: string;
label: string;
avatar: string;
};
export type Branch = {
name: string;
commit: string;
};
}

View File

@ -1,11 +1,10 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { ComboboxItem } from '@/components';
import { RootState } from '@/store'; import { RootState } from '@/store';
import { useAppSelector } from '@/store/hooks'; import { useAppSelector } from '@/store/hooks';
import * as asyncThunk from './async-thunk'; import * as asyncThunk from './async-thunk';
import { UserData } from './github-client'; import { GithubClient } from './github-client';
export namespace GithubState { export namespace GithubState {
export type Token = string; export type Token = string;
@ -20,7 +19,7 @@ export namespace GithubState {
export type QueryLoading = 'idle' | 'loading' | 'failed' | 'success'; export type QueryLoading = 'idle' | 'loading' | 'failed' | 'success';
export type UserAndOrganizations = Array<UserData>; export type UserAndOrganizations = Array<GithubClient.UserData>;
export type Repository = { export type Repository = {
name: string; name: string;
@ -30,7 +29,7 @@ export namespace GithubState {
export type Repositories = Array<Repository>; export type Repositories = Array<Repository>;
export type Branches = Array<ComboboxItem>; export type Branches = Array<GithubClient.Branch>;
} }
export interface GithubState { export interface GithubState {

View File

@ -1 +1,2 @@
export * from './github-slice'; export * from './github-slice';
export * from './github-client';

View File

@ -1,17 +1,14 @@
import { Card, Grid, Icon, IconButton, Stepper } from '@/components'; import { Card, Grid, Icon, IconButton, Stepper } from '@/components';
import { CreateAccessPoint } from './create-ap.context';
import { CreateAccessPointFormBody } from './create-ap.form-body'; import { CreateAccessPointFormBody } from './create-ap.form-body';
export const CreateAccessPointForm: React.FC = () => { export const CreateAccessPointForm: React.FC = () => {
const { prevStep } = Stepper.useContext(); const { prevStep } = Stepper.useContext();
const { nfa } = CreateAccessPoint.useContext();
return ( return (
<Card.Container css={{ width: '$107h' }}> <Card.Container css={{ width: '$107h' }}>
<Card.Heading <Card.Heading
title={`Create Access Point - ${nfa.label || ''}`} title="Create Access Point"
leftIcon={ leftIcon={
<IconButton <IconButton
aria-label="Add" aria-label="Add"

View File

@ -1,14 +1,16 @@
import { useState } from 'react'; import { useState } from 'react';
import { ComboboxItem } from '@/components'; import { Token } from '@/graphclient';
import { EthereumHooks } from '@/integrations'; import { EthereumHooks } from '@/integrations';
import { useFleekERC721Billing } from '@/store'; import { useFleekERC721Billing } from '@/store';
import { AppLog, createContext, pushToast } from '@/utils'; import { AppLog, createContext, pushToast } from '@/utils';
type NFA = Pick<Token, 'id' | 'name'>;
export type AccessPointContext = { export type AccessPointContext = {
billing: string | undefined; billing: string | undefined;
nfa: ComboboxItem; nfa: NFA | undefined;
setNfa: (nfa: ComboboxItem) => void; setNfa: ReactState<NFA | undefined>[1];
}; };
const [CreateAPProvider, useContext] = createContext<AccessPointContext>({ const [CreateAPProvider, useContext] = createContext<AccessPointContext>({
@ -29,7 +31,7 @@ export abstract class CreateAccessPoint {
children, children,
}) => { }) => {
const [billing] = useFleekERC721Billing('AddAccessPoint'); const [billing] = useFleekERC721Billing('AddAccessPoint');
const [nfa, setNfa] = useState<ComboboxItem>({} as ComboboxItem); const [nfa, setNfa] = useState<NFA>();
const value = { const value = {
billing, billing,

View File

@ -1,7 +1,7 @@
import { useQuery } from '@apollo/client'; import { useQuery } from '@apollo/client';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Combobox, ComboboxItem } from '@/components'; import { Combobox } from '@/components';
import { getLatestNFAsDocument } from '@/graphclient'; import { getLatestNFAsDocument } from '@/graphclient';
import { AppLog } from '@/utils'; import { AppLog } from '@/utils';
@ -11,25 +11,26 @@ export const NfaPicker: React.FC = () => {
const { nfa, setNfa } = CreateAccessPoint.useContext(); const { nfa, setNfa } = CreateAccessPoint.useContext();
const { data, loading, error } = useQuery(getLatestNFAsDocument); const { data, loading, error } = useQuery(getLatestNFAsDocument);
const handleNfaChange = (item: ComboboxItem): void => { const items = useMemo(() => data?.tokens || [], [data]);
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>;
if (error) { if (error) {
AppLog.errorToast('Error loading NFA list'); AppLog.errorToast('Error loading NFA list', error);
} }
return ( 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>
); );
}; };

View File

@ -1,53 +1,74 @@
import { useState } from 'react'; import { useState } from 'react';
import { Combobox, ComboboxItem, Flex } from '@/components'; import { Combobox, Flex, Icon, IconName } from '@/components';
const itemsCombobox = [ type Item = { id: number; label: string; icon: IconName };
{ label: 'Item 1', value: 'item-1' },
{ label: 'Item 2', value: 'item-2' }, const Items: Item[] = [
{ label: 'Item 3', value: 'item-3' }, { 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 = () => { export const ComboboxTest: React.FC = () => {
const [selectedValue, setSelectedValue] = useState({} as ComboboxItem); const selected = useState<Item>();
const [selectedValueAutocomplete, setSelectedValueAutocomplete] = useState(
{} as ComboboxItem
);
const handleComboboxChange = (value: ComboboxItem): void => {
setSelectedValue(value);
};
const handleComboboxChangeAutocomplete = (value: ComboboxItem): void => {
setSelectedValueAutocomplete(value);
};
return ( return (
<Flex <Flex
css={{ css={{
position: 'relative',
flexDirection: 'column', flexDirection: 'column',
margin: '100px',
justifyContent: 'center', justifyContent: 'center',
gap: '10px', gap: '10px',
width: '600px',
alignSelf: 'center',
}} }}
> >
<h1>Components Test</h1> <Combobox unattached items={Items} selected={selected} queryKey="label">
<Flex css={{ width: '600px', gap: '$2' }}> {({ Field, Options }) => (
<Combobox <>
items={itemsCombobox} <Field>
selectedValue={selectedValue} {(selected) => (
onChange={handleComboboxChange} <>
leftIcon="github" <Icon name={selected?.icon || 'search'} />
/> {selected?.label || 'Select an option'}
</>
)}
</Field>
<Combobox <Options>
items={itemsCombobox} {(item) => (
selectedValue={selectedValueAutocomplete} <>
onChange={handleComboboxChangeAutocomplete} <Icon name={item.icon} /> {item.label}
withAutocomplete </>
/> )}
</Flex> </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> </Flex>
); );
}; };

View File

@ -1,7 +1,7 @@
import { useQuery } from '@apollo/client'; import { useQuery } from '@apollo/client';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Flex, NFACard, NFACardSkeleton, NoResults } from '@/components'; import { Flex, NFACard, NFACardSkeleton } from '@/components';
import { lastNFAsPaginatedDocument } from '@/graphclient'; import { lastNFAsPaginatedDocument } from '@/graphclient';
import { useWindowScrollEnd } from '@/hooks'; import { useWindowScrollEnd } from '@/hooks';
@ -75,7 +75,14 @@ export const NFAListFragment: React.FC = () => {
<NFACard data={token} key={token.id} /> <NFACard data={token} key={token.id} />
))} ))}
{isLoading && <LoadingSkeletons />} {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>
</Flex> </Flex>
); );

View File

@ -1,7 +1,12 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ComboboxItem, Flex, Form, Spinner } from '@/components'; import { Flex, Form, Icon, Spinner } from '@/components';
import { githubActions, useAppDispatch, useGithubStore } from '@/store'; import {
githubActions,
GithubClient,
useAppDispatch,
useGithubStore,
} from '@/store';
import { AppLog } from '@/utils'; import { AppLog } from '@/utils';
import { Mint } from '@/views/mint/mint.context'; import { Mint } from '@/views/mint/mint.context';
import { useMintFormContext } from '@/views/mint/nfa-step/form-step'; import { useMintFormContext } from '@/views/mint/nfa-step/form-step';
@ -24,33 +29,34 @@ export const RepoBranchCommitFields: React.FC = () => {
const { repositoryName, selectedUserOrg } = Mint.useContext(); const { repositoryName, selectedUserOrg } = Mint.useContext();
useEffect(() => { useEffect(() => {
if (queryLoading === 'idle') { if (!(queryLoading === 'idle' && selectedUserOrg && repositoryName)) return;
dispatch( dispatch(
githubActions.fetchBranchesThunk({ githubActions.fetchBranchesThunk({
owner: selectedUserOrg.label, owner: selectedUserOrg?.label,
repository: repositoryName.name, repository: repositoryName.name,
}) })
); );
} }, [queryLoading, dispatch, selectedUserOrg, repositoryName]);
}, [queryLoading, dispatch, selectedUserOrg.label, repositoryName.name]);
useEffect(() => { useEffect(() => {
try { try {
if ( if (
queryLoading === 'success' && queryLoading !== 'success' ||
branches.length > 0 && branches.length === 0 ||
repositoryName.defaultBranch !== undefined && !repositoryName ||
gitBranch === '' //we only set the default branch the first time gitBranch !== ''
) { )
const defaultBranch = branches.find( return;
(branch) =>
branch.label.toLowerCase() === const defaultBranch = branches.find(
repositoryName.defaultBranch.toLowerCase() (branch) =>
); branch.name.toLowerCase() ===
if (defaultBranch) { repositoryName.defaultBranch.toLowerCase()
setGitBranch(defaultBranch.label); );
setGitCommit(defaultBranch.value);
} if (defaultBranch) {
setGitBranch(defaultBranch.name);
setGitCommit(defaultBranch.commit);
} }
} catch (error) { } catch (error) {
AppLog.errorToast('We had a problem. Try again'); AppLog.errorToast('We had a problem. Try again');
@ -58,7 +64,7 @@ export const RepoBranchCommitFields: React.FC = () => {
}, [ }, [
queryLoading, queryLoading,
branches, branches,
repositoryName.defaultBranch, repositoryName,
gitBranch, gitBranch,
setGitBranch, setGitBranch,
setGitCommit, setGitCommit,
@ -78,9 +84,9 @@ export const RepoBranchCommitFields: React.FC = () => {
); );
} }
const handleBranchChange = (branch: ComboboxItem): void => { const handleBranchChange = (branch: GithubClient.Branch): void => {
setGitBranch(branch.label); setGitBranch(branch.name);
setGitCommit(branch.value); setGitCommit(branch.commit);
}; };
return ( return (
@ -88,10 +94,26 @@ export const RepoBranchCommitFields: React.FC = () => {
<Form.Field context={gitBranchContext}> <Form.Field context={gitBranchContext}>
<Form.Label>Git Branch</Form.Label> <Form.Label>Git Branch</Form.Label>
<Form.Combobox <Form.Combobox
leftIcon="branch"
items={branches} items={branches}
onChange={handleBranchChange} 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>
<Form.Field context={gitCommitContext}> <Form.Field context={gitCommitContext}>
<Form.Label>Git Commit</Form.Label> <Form.Label>Git Commit</Form.Label>

View File

@ -1,14 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import { Card, Flex, Grid, Icon, IconButton, Spinner } from '@/components';
Card,
ComboboxItem,
Flex,
Grid,
Icon,
IconButton,
Spinner,
} from '@/components';
import { Input } from '@/components/core/input'; import { Input } from '@/components/core/input';
import { useDebounce } from '@/hooks/use-debounce'; import { useDebounce } from '@/hooks/use-debounce';
import { useGithubStore } from '@/store'; import { useGithubStore } from '@/store';
@ -50,7 +42,7 @@ export const GithubRepositoryConnection: React.FC = () => {
const handlePrevStepClick = (): void => { const handlePrevStepClick = (): void => {
setGithubStep(1); setGithubStep(1);
setSelectedUserOrg({} as ComboboxItem); setSelectedUserOrg(undefined);
}; };
return ( return (
@ -79,13 +71,13 @@ export const GithubRepositoryConnection: React.FC = () => {
/> />
<Card.Body css={{ pt: '$4' }}> <Card.Body css={{ pt: '$4' }}>
<Grid css={{ rowGap: '$2' }}> <Grid css={{ rowGap: '$2' }}>
<Flex css={{ gap: '$4', pr: '$3h' }}> <Flex css={{ gap: '$4', pr: '$3h', position: 'relative' }}>
<UserOrgsCombobox /> <UserOrgsCombobox />
<Input <Input
leftIcon="search" leftIcon="search"
placeholder="Search repo" placeholder="Search repo"
onChange={handleSearchChange} onChange={handleSearchChange}
css="flex-1" wrapperClassName="flex-1"
/> />
</Flex> </Flex>
{queryLoading === 'loading' || {queryLoading === 'loading' ||

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { Flex, NoResults } from '@/components'; import { Flex } from '@/components';
import { githubActions, useAppDispatch, useGithubStore } from '@/store'; import { githubActions, useAppDispatch, useGithubStore } from '@/store';
import { Mint } from '@/views/mint/mint.context'; import { Mint } from '@/views/mint/mint.context';
@ -27,7 +27,7 @@ export const RepositoriesList: React.FC<RepositoriesListProps> = ({
}, [searchValue, repositories]); }, [searchValue, repositories]);
useEffect(() => { useEffect(() => {
if (queryLoading === 'idle' && selectedUserOrg.value) { if (queryLoading === 'idle' && selectedUserOrg?.value) {
dispatch(githubActions.fetchRepositoriesThunk(selectedUserOrg.value)); dispatch(githubActions.fetchRepositoriesThunk(selectedUserOrg.value));
} }
}, [queryLoading, dispatch, selectedUserOrg]); }, [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> </Flex>
); );

View File

@ -1,10 +1,37 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Avatar, Combobox, ComboboxItem } from '@/components'; import { Avatar, Combobox, Icon } from '@/components';
import { githubActions, useAppDispatch, useGithubStore } from '@/store'; import {
githubActions,
GithubClient,
useAppDispatch,
useGithubStore,
} from '@/store';
import { AppLog } from '@/utils'; import { AppLog } from '@/utils';
import { Mint } from '@/views/mint/mint.context'; 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 = () => { export const UserOrgsCombobox: React.FC = () => {
const { queryUserAndOrganizations, userAndOrganizations } = useGithubStore(); const { queryUserAndOrganizations, userAndOrganizations } = useGithubStore();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -17,7 +44,9 @@ export const UserOrgsCombobox: React.FC = () => {
} }
}, [dispatch, queryUserAndOrganizations]); }, [dispatch, queryUserAndOrganizations]);
const handleUserOrgChange = (item: ComboboxItem): void => { const handleUserOrgChange = (
item: GithubClient.UserData | undefined
): void => {
if (item) { if (item) {
dispatch(githubActions.fetchRepositoriesThunk(item.value)); dispatch(githubActions.fetchRepositoriesThunk(item.value));
setSelectedUserOrg(item); setSelectedUserOrg(item);
@ -29,10 +58,10 @@ export const UserOrgsCombobox: React.FC = () => {
useEffect(() => { useEffect(() => {
if ( if (
queryUserAndOrganizations === 'success' && queryUserAndOrganizations === 'success' &&
selectedUserOrg.value === undefined && selectedUserOrg?.value === undefined &&
userAndOrganizations.length > 0 userAndOrganizations.length > 0
) { ) {
//SET first user // sets the first user
setSelectedUserOrg(userAndOrganizations[0]); setSelectedUserOrg(userAndOrganizations[0]);
} }
}, [ }, [
@ -44,18 +73,18 @@ export const UserOrgsCombobox: React.FC = () => {
return ( return (
<Combobox <Combobox
items={userAndOrganizations.map( items={userAndOrganizations}
(item) => unattached
({ css={{ flex: 1 }}
label: item.label, selected={[selectedUserOrg, handleUserOrgChange]}
value: item.value, queryKey="label"
icon: <Avatar src={item.avatar} />, >
} as ComboboxItem) {({ Field, Options }) => (
<>
<Field>{renderSelected}</Field>
<Options>{renderItem}</Options>
</>
)} )}
selectedValue={selectedUserOrg} </Combobox>
onChange={handleUserOrgChange}
leftIcon="github"
css="flex-1"
/>
); );
}; };

View File

@ -1,21 +1,20 @@
import { useState } from 'react'; import { useState } from 'react';
import { ComboboxItem } from '@/components';
import { EthereumHooks } from '@/integrations'; import { EthereumHooks } from '@/integrations';
import { GithubState, useFleekERC721Billing } from '@/store'; import { GithubClient, GithubState, useFleekERC721Billing } from '@/store';
import { AppLog, createContext } from '@/utils'; import { AppLog, createContext } from '@/utils';
export type MintContext = { export type MintContext = {
billing: string | undefined; billing: string | undefined;
selectedUserOrg: ComboboxItem; selectedUserOrg: GithubClient.UserData | undefined;
repositoryName: GithubState.Repository; repositoryName: GithubState.Repository | undefined;
githubStep: number; githubStep: number;
nfaStep: number; nfaStep: number;
verifyNFA: boolean; verifyNFA: boolean;
setGithubStep: (step: number) => void; setGithubStep: (step: number) => void;
setNfaStep: (step: number) => void; setNfaStep: (step: number) => void;
setSelectedUserOrg: (userOrgValue: ComboboxItem) => void; setSelectedUserOrg: (userOrgValue: GithubClient.UserData | undefined) => void;
setRepositoryName: (repo: GithubState.Repository) => void; setRepositoryName: (repo: GithubState.Repository | undefined) => void;
setVerifyNFA: (verify: boolean) => void; setVerifyNFA: (verify: boolean) => void;
}; };
@ -35,9 +34,10 @@ export abstract class Mint {
static readonly Provider: React.FC<Mint.ProviderProps> = ({ children }) => { static readonly Provider: React.FC<Mint.ProviderProps> = ({ children }) => {
//Github Connection //Github Connection
const [selectedUserOrg, setSelectedUserOrg] = useState({} as ComboboxItem); const [selectedUserOrg, setSelectedUserOrg] =
useState<GithubClient.UserData>();
const [repositoryName, setRepositoryName] = const [repositoryName, setRepositoryName] =
useState<GithubState.Repository>({} as GithubState.Repository); useState<GithubState.Repository>();
const [githubStep, setGithubStepContext] = useState(1); const [githubStep, setGithubStepContext] = useState(1);
//NFA Details //NFA Details

View File

@ -4,7 +4,7 @@ import { DomainField } from './domain-field';
import { EnsField } from './ens-field'; import { EnsField } from './ens-field';
export const EnsDomainField: React.FC = () => ( export const EnsDomainField: React.FC = () => (
<Flex css={{ columnGap: '$4' }}> <Flex css={{ columnGap: '$4', position: 'relative' }}>
<EnsField /> <EnsField />
<DomainField /> <DomainField />
</Flex> </Flex>

View File

@ -3,14 +3,14 @@ import { useCallback, useEffect, useMemo } from 'react';
import { useAccount } from 'wagmi'; import { useAccount } from 'wagmi';
import { getENSNamesDocument } from '@/../.graphclient'; import { getENSNamesDocument } from '@/../.graphclient';
import { ComboboxItem, Form } from '@/components'; import { Form } from '@/components';
import { AppLog } from '@/utils'; import { AppLog } from '@/utils';
import { useMintFormContext } from '../../mint-form.context'; import { useMintFormContext } from '../../mint-form.context';
export const EnsField: React.FC = () => { export const EnsField: React.FC = () => {
const { address } = useAccount(); const { address } = useAccount();
const { data, error } = useQuery(getENSNamesDocument, { const { data, error, loading } = useQuery(getENSNamesDocument, {
variables: { variables: {
address: address?.toLowerCase() || '', //should skip if undefined address: address?.toLowerCase() || '', //should skip if undefined
}, },
@ -34,25 +34,30 @@ export const EnsField: React.FC = () => {
}, [error, showError]); }, [error, showError]);
const ensNames = useMemo(() => { const ensNames = useMemo(() => {
const ensList: ComboboxItem[] = []; if (!(data && data.account && data.account.domains)) return [];
if (data && data.account && data.account.domains) { return data.account.domains.map((ens) => ens.name as string);
data.account.domains.forEach((ens) => {
const { name } = ens;
if (name) {
ensList.push({
label: name,
value: name,
});
}
});
}
return ensList;
}, [data]); }, [data]);
return ( return (
<Form.Field context={ens} css={{ flex: 1 }}> <Form.Field context={ens} css={{ flex: 1 }}>
<Form.Label>ENS</Form.Label> <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.Overline />
</Form.Field> </Form.Field>
); );