ui(admin): add role select for user management
This commit is contained in:
parent
58f0bbbb9b
commit
25feeaa9f5
16 changed files with 202 additions and 52 deletions
|
@ -28,6 +28,7 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification;
|
|||
* @property string $password
|
||||
* @property string|null $remember_token
|
||||
* @property string $language
|
||||
* @property int $admin_role_id
|
||||
* @property bool $root_admin
|
||||
* @property bool $use_totp
|
||||
* @property string|null $totp_secret
|
||||
|
|
|
@ -12,7 +12,7 @@ class UserTransformer extends BaseTransformer
|
|||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $availableIncludes = ['servers'];
|
||||
protected $availableIncludes = ['role', 'servers'];
|
||||
|
||||
/**
|
||||
* Return the resource name for the JSONAPI output.
|
||||
|
@ -39,12 +39,32 @@ class UserTransformer extends BaseTransformer
|
|||
'root_admin' => (bool) $model->root_admin,
|
||||
'2fa' => (bool) $model->use_totp,
|
||||
'avatar_url' => $model->avatarURL(),
|
||||
'admin_role_id' => $model->admin_role_id,
|
||||
'role_name' => $model->adminRoleName(),
|
||||
'created_at' => $this->formatTimestamp($model->created_at),
|
||||
'updated_at' => $this->formatTimestamp($model->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the role associated with this user.
|
||||
*
|
||||
* @return \League\Fractal\Resource\Item|\League\Fractal\Resource\NullResource
|
||||
*
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
|
||||
*/
|
||||
public function includeRole(User $user)
|
||||
{
|
||||
if (!$this->authorize(AdminAcl::RESOURCE_ROLES)) {
|
||||
return $this->null();
|
||||
}
|
||||
|
||||
$user->loadMissing('adminRole');
|
||||
|
||||
return $this->item($user->getRelation('adminRole'), $this->makeTransformer(AdminRoleTransformer::class), 'admin_role');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the servers associated with this user.
|
||||
*
|
||||
|
|
|
@ -16,7 +16,7 @@ export default (filters?: Filters): Promise<Database[]> => {
|
|||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get('/api/application/databases', { params: { ...params } })
|
||||
http.get('/api/application/databases', { params })
|
||||
.then(response => resolve(
|
||||
(response.data.data || []).map(rawDataToDatabase)
|
||||
))
|
||||
|
|
|
@ -16,7 +16,7 @@ export default (filters?: Filters): Promise<Location[]> => {
|
|||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get('/api/application/locations', { params: { ...params } })
|
||||
http.get('/api/application/locations', { params })
|
||||
.then(response => resolve(
|
||||
(response.data.data || []).map(rawDataToLocation)
|
||||
))
|
||||
|
|
24
resources/scripts/api/admin/roles/searchRoles.ts
Normal file
24
resources/scripts/api/admin/roles/searchRoles.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import http from '@/api/http';
|
||||
import { Role, rawDataToRole } from '@/api/admin/roles/getRoles';
|
||||
|
||||
interface Filters {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export default (filters?: Filters): Promise<Role[]> => {
|
||||
const params = {};
|
||||
if (filters !== undefined) {
|
||||
Object.keys(filters).forEach(key => {
|
||||
// @ts-ignore
|
||||
params['filter[' + key + ']'] = filters[key];
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get('/api/application/roles', { params })
|
||||
.then(response => resolve(
|
||||
(response.data.data || []).map(rawDataToRole)
|
||||
))
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
|
@ -2,6 +2,8 @@ import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/
|
|||
import { useContext } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { createContext } from '@/api/admin';
|
||||
import { rawDataToDatabase } from '@/api/admin/databases/getDatabases';
|
||||
import { Role } from '@/api/admin/roles/getRoles';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
|
@ -12,13 +14,17 @@ export interface User {
|
|||
firstName: string;
|
||||
lastName: string;
|
||||
language: string;
|
||||
adminRoleId: number | null;
|
||||
rootAdmin: boolean;
|
||||
tfa: boolean;
|
||||
avatarURL: string;
|
||||
roleId: number | null;
|
||||
roleName: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
relationships: {
|
||||
role: Role | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export const rawDataToUser = ({ attributes }: FractalResponseData): User => ({
|
||||
|
@ -30,13 +36,17 @@ export const rawDataToUser = ({ attributes }: FractalResponseData): User => ({
|
|||
firstName: attributes.first_name,
|
||||
lastName: attributes.last_name,
|
||||
language: attributes.language,
|
||||
adminRoleId: attributes.admin_role_id,
|
||||
rootAdmin: attributes.root_admin,
|
||||
tfa: attributes['2fa'],
|
||||
avatarURL: attributes.avatar_url,
|
||||
roleId: attributes.role_id,
|
||||
roleName: attributes.role_name,
|
||||
createdAt: new Date(attributes.created_at),
|
||||
updatedAt: new Date(attributes.updated_at),
|
||||
|
||||
relationships: {
|
||||
role: attributes.relationships?.role !== undefined && attributes.relationships?.role.object !== 'null_resource' ? rawDataToDatabase(attributes.relationships.role as FractalResponseData) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
export interface Filters {
|
||||
|
|
|
@ -7,7 +7,7 @@ export interface Values {
|
|||
firstName: string;
|
||||
lastName: string;
|
||||
password: string;
|
||||
roleId: number | null;
|
||||
adminRoleId: number | null;
|
||||
}
|
||||
|
||||
export default (id: number, values: Partial<Values>, include: string[] = []): Promise<User> => {
|
||||
|
|
|
@ -71,6 +71,13 @@ const CustomStyles = createGlobalStyle`
|
|||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
input[type="search"]::-webkit-search-decoration,
|
||||
input[type="search"]::-webkit-search-cancel-button,
|
||||
input[type="search"]::-webkit-search-results-button,
|
||||
input[type="search"]::-webkit-search-results-decoration {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const GlobalStyles = () => (
|
||||
|
|
|
@ -12,10 +12,12 @@ export default ({ selected }: { selected: Database | null }) => {
|
|||
|
||||
const onSearch = (query: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
searchDatabases({ name: query }).then((databases) => {
|
||||
setDatabases(databases);
|
||||
return resolve();
|
||||
}).catch(reject);
|
||||
searchDatabases({ name: query })
|
||||
.then(databases => {
|
||||
setDatabases(databases);
|
||||
return resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -24,14 +26,16 @@ export default ({ selected }: { selected: Database | null }) => {
|
|||
context.setFieldValue('databaseHostId', database?.id || null);
|
||||
};
|
||||
|
||||
const getSelectedText = (database: Database | null): string => {
|
||||
return database?.name || '';
|
||||
const getSelectedText = (database: Database | null): string | undefined => {
|
||||
return database?.name;
|
||||
};
|
||||
|
||||
return (
|
||||
<SearchableSelect
|
||||
id="database"
|
||||
name="Database"
|
||||
id={'databaseId'}
|
||||
name={'databaseId'}
|
||||
label={'Database'}
|
||||
placeholder={'Select a database...'}
|
||||
items={databases}
|
||||
selected={database}
|
||||
setSelected={setDatabase}
|
||||
|
@ -42,7 +46,7 @@ export default ({ selected }: { selected: Database | null }) => {
|
|||
nullable
|
||||
>
|
||||
{databases?.map(d => (
|
||||
<Option key={d.id} selectId="database" id={d.id} item={d} active={d.id === database?.id}>
|
||||
<Option key={d.id} selectId={'databaseId'} id={d.id} item={d} active={d.id === database?.id}>
|
||||
{d.name}
|
||||
</Option>
|
||||
))}
|
||||
|
|
|
@ -12,10 +12,12 @@ export default ({ selected }: { selected: Location | null }) => {
|
|||
|
||||
const onSearch = (query: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
searchLocations({ short: query }).then((locations) => {
|
||||
setLocations(locations);
|
||||
return resolve();
|
||||
}).catch(reject);
|
||||
searchLocations({ short: query })
|
||||
.then(locations => {
|
||||
setLocations(locations);
|
||||
return resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -24,14 +26,16 @@ export default ({ selected }: { selected: Location | null }) => {
|
|||
context.setFieldValue('locationId', location?.id || null);
|
||||
};
|
||||
|
||||
const getSelectedText = (location: Location | null): string => {
|
||||
return location?.short || '';
|
||||
const getSelectedText = (location: Location | null): string | undefined => {
|
||||
return location?.short;
|
||||
};
|
||||
|
||||
return (
|
||||
<SearchableSelect
|
||||
id="location"
|
||||
name="Location"
|
||||
id={'locationId'}
|
||||
name={'locationId'}
|
||||
label={'Location'}
|
||||
placeholder={'Select a location...'}
|
||||
items={locations}
|
||||
selected={location}
|
||||
setSelected={setLocation}
|
||||
|
@ -42,7 +46,7 @@ export default ({ selected }: { selected: Location | null }) => {
|
|||
nullable
|
||||
>
|
||||
{locations?.map(d => (
|
||||
<Option key={d.id} selectId="location" id={d.id} item={d} active={d.id === location?.id}>
|
||||
<Option key={d.id} selectId={'locationId'} id={d.id} item={d} active={d.id === location?.id}>
|
||||
{d.short}
|
||||
</Option>
|
||||
))}
|
||||
|
|
|
@ -30,8 +30,10 @@ export default ({ selected }: { selected: User | null }) => {
|
|||
|
||||
return (
|
||||
<SearchableSelect
|
||||
id="user"
|
||||
name="Owner"
|
||||
id={'ownerId'}
|
||||
name={'ownerId'}
|
||||
label={'Owner'}
|
||||
placeholder={'Select a user...'}
|
||||
items={users}
|
||||
selected={user}
|
||||
setSelected={setUser}
|
||||
|
@ -42,7 +44,7 @@ export default ({ selected }: { selected: User | null }) => {
|
|||
nullable
|
||||
>
|
||||
{users?.map(d => (
|
||||
<Option key={d.id} selectId="user" id={d.id} item={d} active={d.id === user?.id}>
|
||||
<Option key={d.id} selectId={'ownerId'} id={d.id} item={d} active={d.id === user?.id}>
|
||||
{d.username}
|
||||
</Option>
|
||||
))}
|
||||
|
|
|
@ -37,7 +37,7 @@ export default () => {
|
|||
|
||||
<FlashMessageRender byKey={'user:create'} css={tw`mb-4`}/>
|
||||
|
||||
<InformationContainer title={'Create User'} onSubmit={submit}/>
|
||||
<InformationContainer title={'Create User'} onSubmit={submit} role={null}/>
|
||||
</AdminContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
55
resources/scripts/components/admin/users/RoleSelect.tsx
Normal file
55
resources/scripts/components/admin/users/RoleSelect.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { Role } from '@/api/admin/roles/getRoles';
|
||||
import searchRoles from '@/api/admin/roles/searchRoles';
|
||||
import SearchableSelect, { Option } from '@/components/elements/SearchableSelect';
|
||||
|
||||
export default ({ selected }: { selected: Role | null }) => {
|
||||
const context = useFormikContext();
|
||||
|
||||
const [ role, setRole ] = useState<Role | null>(selected);
|
||||
const [ roles, setRoles ] = useState<Role[] | null>(null);
|
||||
|
||||
const onSearch = (query: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
searchRoles({ name: query })
|
||||
.then(roles => {
|
||||
setRoles(roles);
|
||||
return resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = (role: Role | null) => {
|
||||
setRole(role);
|
||||
context.setFieldValue('adminRoleId', role?.id || null);
|
||||
};
|
||||
|
||||
const getSelectedText = (role: Role | null): string | undefined => {
|
||||
return role?.name;
|
||||
};
|
||||
|
||||
return (
|
||||
<SearchableSelect
|
||||
id={'adminRoleId'}
|
||||
name={'adminRoleId'}
|
||||
label={'Role'}
|
||||
placeholder={'Select a role...'}
|
||||
items={roles}
|
||||
selected={role}
|
||||
setSelected={setRole}
|
||||
setItems={setRoles}
|
||||
onSearch={onSearch}
|
||||
onSelect={onSelect}
|
||||
getSelectedText={getSelectedText}
|
||||
nullable
|
||||
>
|
||||
{roles?.map(d => (
|
||||
<Option key={d.id} selectId={'adminRoleId'} id={d.id} item={d} active={d.id === role?.id}>
|
||||
{d.name}
|
||||
</Option>
|
||||
))}
|
||||
</SearchableSelect>
|
||||
);
|
||||
};
|
|
@ -12,9 +12,11 @@ import AdminBox from '@/components/admin/AdminBox';
|
|||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import { object, string } from 'yup';
|
||||
import Field from '@/components/elements/Field';
|
||||
import Button from '@/components/elements/Button';
|
||||
import { Role } from '@/api/admin/roles/getRoles';
|
||||
import updateUser, { Values } from '@/api/admin/users/updateUser';
|
||||
import Button from '@/components/elements/Button';
|
||||
import Field from '@/components/elements/Field';
|
||||
import RoleSelect from '@/components/admin/users/RoleSelect';
|
||||
import UserDeleteButton from '@/components/admin/users/UserDeleteButton';
|
||||
|
||||
interface ctx {
|
||||
|
@ -37,9 +39,11 @@ export interface Params {
|
|||
|
||||
onSubmit: (values: Values, helpers: FormikHelpers<Values>) => void;
|
||||
exists?: boolean;
|
||||
|
||||
role: Role | null;
|
||||
}
|
||||
|
||||
export function InformationContainer ({ title, initialValues, children, onSubmit, exists }: Params) {
|
||||
export function InformationContainer ({ title, initialValues, children, onSubmit, exists, role }: Params) {
|
||||
const submit = (values: Values, helpers: FormikHelpers<Values>) => {
|
||||
onSubmit(values, helpers);
|
||||
};
|
||||
|
@ -51,7 +55,7 @@ export function InformationContainer ({ title, initialValues, children, onSubmit
|
|||
firstName: '',
|
||||
lastName: '',
|
||||
password: '',
|
||||
roleId: 0,
|
||||
adminRoleId: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -126,7 +130,9 @@ export function InformationContainer ({ title, initialValues, children, onSubmit
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div css={tw`md:w-full md:flex md:flex-col md:ml-4 mt-6 md:mt-0`}/>
|
||||
<div css={tw`md:w-full md:flex md:flex-col md:ml-4 mt-6 md:mt-0`}>
|
||||
<RoleSelect selected={role}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div css={tw`w-full flex flex-row items-center mt-6`}>
|
||||
|
@ -180,10 +186,11 @@ function EditInformationContainer () {
|
|||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
roleId: user.roleId,
|
||||
adminRoleId: user.adminRoleId,
|
||||
password: '',
|
||||
}}
|
||||
onSubmit={submit}
|
||||
role={user?.relationships.role || null}
|
||||
exists
|
||||
>
|
||||
<div css={tw`flex`}>
|
||||
|
@ -208,7 +215,7 @@ function UserEditContainer () {
|
|||
useEffect(() => {
|
||||
clearFlashes('user');
|
||||
|
||||
getUser(Number(match.params?.id))
|
||||
getUser(Number(match.params?.id), [ 'role' ])
|
||||
.then(user => setUser(user))
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import React, { createRef, ReactElement, useEffect, useState } from 'react';
|
||||
import { debounce } from 'debounce';
|
||||
import React, { createRef, ReactElement, useEffect, useState } from 'react';
|
||||
import tw, { styled } from 'twin.macro';
|
||||
import Input from '@/components/elements/Input';
|
||||
import Label from '@/components/elements/Label';
|
||||
import InputSpinner from '@/components/elements/InputSpinner';
|
||||
import Label from '@/components/elements/Label';
|
||||
|
||||
const Dropdown = styled.div<{ expanded: boolean }>`
|
||||
${tw`absolute z-10 w-full mt-1 rounded-md shadow-lg bg-neutral-900`};
|
||||
|
@ -69,7 +69,9 @@ export const Option = <T extends IdObj>({ selectId, id, item, active, isHighligh
|
|||
interface SearchableSelectProps<T> {
|
||||
id: string;
|
||||
name: string;
|
||||
nullable: boolean;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
nullable?: boolean;
|
||||
|
||||
selected: T | null;
|
||||
setSelected: (item: T | null) => void;
|
||||
|
@ -80,12 +82,13 @@ interface SearchableSelectProps<T> {
|
|||
onSearch: (query: string) => Promise<void>;
|
||||
onSelect: (item: T | null) => void;
|
||||
|
||||
getSelectedText: (item: T | null) => string;
|
||||
getSelectedText: (item: T | null) => string | undefined;
|
||||
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelected, items, setItems, onSearch, onSelect, getSelectedText, children }: SearchableSelectProps<T>) => {
|
||||
export const SearchableSelect = <T extends IdObj>({ id, name, label, placeholder, selected, setSelected, items, setItems, onSearch, onSelect, getSelectedText, children, className }: SearchableSelectProps<T>) => {
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [ expanded, setExpanded ] = useState(false);
|
||||
|
||||
|
@ -144,7 +147,7 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
|
|||
return;
|
||||
}
|
||||
|
||||
const item = items.find((item) => item.id === highlighted);
|
||||
const item = items.find(i => i.id === highlighted);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
@ -169,7 +172,7 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
|
|||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
const item = items.find((item) => item.id === highlighted);
|
||||
const item = items.find(i => i.id === highlighted);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
@ -210,10 +213,10 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
|
|||
onBlur();
|
||||
};
|
||||
|
||||
window.addEventListener('click', clickHandler);
|
||||
window.addEventListener('mousedown', clickHandler);
|
||||
window.addEventListener('contextmenu', contextmenuHandler);
|
||||
return () => {
|
||||
window.removeEventListener('click', clickHandler);
|
||||
window.removeEventListener('mousedown', clickHandler);
|
||||
window.removeEventListener('contextmenu', contextmenuHandler);
|
||||
};
|
||||
}, [ expanded ]);
|
||||
|
@ -240,17 +243,16 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
|
|||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id + '-select-label'}>{name}</Label>
|
||||
<div className={className}>
|
||||
<Label htmlFor={id + '-select-label'}>{label}</Label>
|
||||
|
||||
<div css={tw`relative mt-1`}>
|
||||
<InputSpinner visible={loading}>
|
||||
<Input
|
||||
ref={searchInput}
|
||||
type="text"
|
||||
className="ignoreReadOnly"
|
||||
type={'search'}
|
||||
id={id}
|
||||
name={id}
|
||||
name={name}
|
||||
value={inputText}
|
||||
readOnly={!expanded}
|
||||
onFocus={onFocus}
|
||||
|
@ -259,17 +261,29 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
|
|||
search(e.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={handleInputKeydown}
|
||||
className={'ignoreReadOnly'}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</InputSpinner>
|
||||
|
||||
<div css={tw`absolute inset-y-0 right-0 flex items-center pr-2 ml-3 pointer-events-none`}>
|
||||
<svg css={tw`w-5 h-5 text-neutral-400`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<div css={[ tw`absolute inset-y-0 right-0 flex items-center pr-2 ml-3`, !expanded && tw`pointer-events-none` ]}>
|
||||
{inputText !== '' && expanded &&
|
||||
<svg css={tw`w-5 h-5 text-neutral-400 cursor-pointer`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
setInputText('');
|
||||
}}
|
||||
>
|
||||
<path clipRule="evenodd" fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"/>
|
||||
</svg>
|
||||
}
|
||||
<svg css={tw`w-5 h-5 text-neutral-400 pointer-events-none`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path clipRule="evenodd" fillRule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<Dropdown ref={itemsList} expanded={expanded}>
|
||||
{ items === null || items.length < 1 ?
|
||||
{items === null || items.length < 1 ?
|
||||
items === null || inputText.length < 2 ?
|
||||
<div css={tw`flex flex-row items-center h-10 px-3`}>
|
||||
<p css={tw`text-sm`}>Please type 2 or more characters.</p>
|
||||
|
|
|
@ -224,12 +224,14 @@ interface Props {
|
|||
name: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
validate?: (value: any) => undefined | string | Promise<any>;
|
||||
|
||||
options: Array<Option>;
|
||||
|
||||
isMulti?: boolean;
|
||||
isSearchable?: boolean;
|
||||
|
||||
isCreatable?: boolean;
|
||||
isValidNewOption?: ((
|
||||
inputValue: string,
|
||||
|
|
Loading…
Reference in a new issue