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 $password
|
||||||
* @property string|null $remember_token
|
* @property string|null $remember_token
|
||||||
* @property string $language
|
* @property string $language
|
||||||
|
* @property int $admin_role_id
|
||||||
* @property bool $root_admin
|
* @property bool $root_admin
|
||||||
* @property bool $use_totp
|
* @property bool $use_totp
|
||||||
* @property string|null $totp_secret
|
* @property string|null $totp_secret
|
||||||
|
|
|
@ -12,7 +12,7 @@ class UserTransformer extends BaseTransformer
|
||||||
*
|
*
|
||||||
* @var array
|
* @var array
|
||||||
*/
|
*/
|
||||||
protected $availableIncludes = ['servers'];
|
protected $availableIncludes = ['role', 'servers'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the resource name for the JSONAPI output.
|
* Return the resource name for the JSONAPI output.
|
||||||
|
@ -39,12 +39,32 @@ class UserTransformer extends BaseTransformer
|
||||||
'root_admin' => (bool) $model->root_admin,
|
'root_admin' => (bool) $model->root_admin,
|
||||||
'2fa' => (bool) $model->use_totp,
|
'2fa' => (bool) $model->use_totp,
|
||||||
'avatar_url' => $model->avatarURL(),
|
'avatar_url' => $model->avatarURL(),
|
||||||
|
'admin_role_id' => $model->admin_role_id,
|
||||||
'role_name' => $model->adminRoleName(),
|
'role_name' => $model->adminRoleName(),
|
||||||
'created_at' => $this->formatTimestamp($model->created_at),
|
'created_at' => $this->formatTimestamp($model->created_at),
|
||||||
'updated_at' => $this->formatTimestamp($model->updated_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.
|
* Return the servers associated with this user.
|
||||||
*
|
*
|
||||||
|
|
|
@ -16,7 +16,7 @@ export default (filters?: Filters): Promise<Database[]> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
http.get('/api/application/databases', { params: { ...params } })
|
http.get('/api/application/databases', { params })
|
||||||
.then(response => resolve(
|
.then(response => resolve(
|
||||||
(response.data.data || []).map(rawDataToDatabase)
|
(response.data.data || []).map(rawDataToDatabase)
|
||||||
))
|
))
|
||||||
|
|
|
@ -16,7 +16,7 @@ export default (filters?: Filters): Promise<Location[]> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
http.get('/api/application/locations', { params: { ...params } })
|
http.get('/api/application/locations', { params })
|
||||||
.then(response => resolve(
|
.then(response => resolve(
|
||||||
(response.data.data || []).map(rawDataToLocation)
|
(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 { useContext } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { createContext } from '@/api/admin';
|
import { createContext } from '@/api/admin';
|
||||||
|
import { rawDataToDatabase } from '@/api/admin/databases/getDatabases';
|
||||||
|
import { Role } from '@/api/admin/roles/getRoles';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -12,13 +14,17 @@ export interface User {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
language: string;
|
language: string;
|
||||||
|
adminRoleId: number | null;
|
||||||
rootAdmin: boolean;
|
rootAdmin: boolean;
|
||||||
tfa: boolean;
|
tfa: boolean;
|
||||||
avatarURL: string;
|
avatarURL: string;
|
||||||
roleId: number | null;
|
|
||||||
roleName: string | null;
|
roleName: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
|
relationships: {
|
||||||
|
role: Role | undefined;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const rawDataToUser = ({ attributes }: FractalResponseData): User => ({
|
export const rawDataToUser = ({ attributes }: FractalResponseData): User => ({
|
||||||
|
@ -30,13 +36,17 @@ export const rawDataToUser = ({ attributes }: FractalResponseData): User => ({
|
||||||
firstName: attributes.first_name,
|
firstName: attributes.first_name,
|
||||||
lastName: attributes.last_name,
|
lastName: attributes.last_name,
|
||||||
language: attributes.language,
|
language: attributes.language,
|
||||||
|
adminRoleId: attributes.admin_role_id,
|
||||||
rootAdmin: attributes.root_admin,
|
rootAdmin: attributes.root_admin,
|
||||||
tfa: attributes['2fa'],
|
tfa: attributes['2fa'],
|
||||||
avatarURL: attributes.avatar_url,
|
avatarURL: attributes.avatar_url,
|
||||||
roleId: attributes.role_id,
|
|
||||||
roleName: attributes.role_name,
|
roleName: attributes.role_name,
|
||||||
createdAt: new Date(attributes.created_at),
|
createdAt: new Date(attributes.created_at),
|
||||||
updatedAt: new Date(attributes.updated_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 {
|
export interface Filters {
|
||||||
|
|
|
@ -7,7 +7,7 @@ export interface Values {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
password: string;
|
password: string;
|
||||||
roleId: number | null;
|
adminRoleId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (id: number, values: Partial<Values>, include: string[] = []): Promise<User> => {
|
export default (id: number, values: Partial<Values>, include: string[] = []): Promise<User> => {
|
||||||
|
|
|
@ -71,6 +71,13 @@ const CustomStyles = createGlobalStyle`
|
||||||
::-webkit-scrollbar-corner {
|
::-webkit-scrollbar-corner {
|
||||||
background: transparent;
|
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 = () => (
|
const GlobalStyles = () => (
|
||||||
|
|
|
@ -12,10 +12,12 @@ export default ({ selected }: { selected: Database | null }) => {
|
||||||
|
|
||||||
const onSearch = (query: string): Promise<void> => {
|
const onSearch = (query: string): Promise<void> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
searchDatabases({ name: query }).then((databases) => {
|
searchDatabases({ name: query })
|
||||||
setDatabases(databases);
|
.then(databases => {
|
||||||
return resolve();
|
setDatabases(databases);
|
||||||
}).catch(reject);
|
return resolve();
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -24,14 +26,16 @@ export default ({ selected }: { selected: Database | null }) => {
|
||||||
context.setFieldValue('databaseHostId', database?.id || null);
|
context.setFieldValue('databaseHostId', database?.id || null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSelectedText = (database: Database | null): string => {
|
const getSelectedText = (database: Database | null): string | undefined => {
|
||||||
return database?.name || '';
|
return database?.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
id="database"
|
id={'databaseId'}
|
||||||
name="Database"
|
name={'databaseId'}
|
||||||
|
label={'Database'}
|
||||||
|
placeholder={'Select a database...'}
|
||||||
items={databases}
|
items={databases}
|
||||||
selected={database}
|
selected={database}
|
||||||
setSelected={setDatabase}
|
setSelected={setDatabase}
|
||||||
|
@ -42,7 +46,7 @@ export default ({ selected }: { selected: Database | null }) => {
|
||||||
nullable
|
nullable
|
||||||
>
|
>
|
||||||
{databases?.map(d => (
|
{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}
|
{d.name}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -12,10 +12,12 @@ export default ({ selected }: { selected: Location | null }) => {
|
||||||
|
|
||||||
const onSearch = (query: string): Promise<void> => {
|
const onSearch = (query: string): Promise<void> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
searchLocations({ short: query }).then((locations) => {
|
searchLocations({ short: query })
|
||||||
setLocations(locations);
|
.then(locations => {
|
||||||
return resolve();
|
setLocations(locations);
|
||||||
}).catch(reject);
|
return resolve();
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -24,14 +26,16 @@ export default ({ selected }: { selected: Location | null }) => {
|
||||||
context.setFieldValue('locationId', location?.id || null);
|
context.setFieldValue('locationId', location?.id || null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSelectedText = (location: Location | null): string => {
|
const getSelectedText = (location: Location | null): string | undefined => {
|
||||||
return location?.short || '';
|
return location?.short;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
id="location"
|
id={'locationId'}
|
||||||
name="Location"
|
name={'locationId'}
|
||||||
|
label={'Location'}
|
||||||
|
placeholder={'Select a location...'}
|
||||||
items={locations}
|
items={locations}
|
||||||
selected={location}
|
selected={location}
|
||||||
setSelected={setLocation}
|
setSelected={setLocation}
|
||||||
|
@ -42,7 +46,7 @@ export default ({ selected }: { selected: Location | null }) => {
|
||||||
nullable
|
nullable
|
||||||
>
|
>
|
||||||
{locations?.map(d => (
|
{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}
|
{d.short}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -30,8 +30,10 @@ export default ({ selected }: { selected: User | null }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
id="user"
|
id={'ownerId'}
|
||||||
name="Owner"
|
name={'ownerId'}
|
||||||
|
label={'Owner'}
|
||||||
|
placeholder={'Select a user...'}
|
||||||
items={users}
|
items={users}
|
||||||
selected={user}
|
selected={user}
|
||||||
setSelected={setUser}
|
setSelected={setUser}
|
||||||
|
@ -42,7 +44,7 @@ export default ({ selected }: { selected: User | null }) => {
|
||||||
nullable
|
nullable
|
||||||
>
|
>
|
||||||
{users?.map(d => (
|
{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}
|
{d.username}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -37,7 +37,7 @@ export default () => {
|
||||||
|
|
||||||
<FlashMessageRender byKey={'user:create'} css={tw`mb-4`}/>
|
<FlashMessageRender byKey={'user:create'} css={tw`mb-4`}/>
|
||||||
|
|
||||||
<InformationContainer title={'Create User'} onSubmit={submit}/>
|
<InformationContainer title={'Create User'} onSubmit={submit} role={null}/>
|
||||||
</AdminContentBlock>
|
</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 SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
import { Form, Formik, FormikHelpers } from 'formik';
|
import { Form, Formik, FormikHelpers } from 'formik';
|
||||||
import { object, string } from 'yup';
|
import { object, string } from 'yup';
|
||||||
import Field from '@/components/elements/Field';
|
import { Role } from '@/api/admin/roles/getRoles';
|
||||||
import Button from '@/components/elements/Button';
|
|
||||||
import updateUser, { Values } from '@/api/admin/users/updateUser';
|
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';
|
import UserDeleteButton from '@/components/admin/users/UserDeleteButton';
|
||||||
|
|
||||||
interface ctx {
|
interface ctx {
|
||||||
|
@ -37,9 +39,11 @@ export interface Params {
|
||||||
|
|
||||||
onSubmit: (values: Values, helpers: FormikHelpers<Values>) => void;
|
onSubmit: (values: Values, helpers: FormikHelpers<Values>) => void;
|
||||||
exists?: boolean;
|
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>) => {
|
const submit = (values: Values, helpers: FormikHelpers<Values>) => {
|
||||||
onSubmit(values, helpers);
|
onSubmit(values, helpers);
|
||||||
};
|
};
|
||||||
|
@ -51,7 +55,7 @@ export function InformationContainer ({ title, initialValues, children, onSubmit
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
password: '',
|
password: '',
|
||||||
roleId: 0,
|
adminRoleId: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -126,7 +130,9 @@ export function InformationContainer ({ title, initialValues, children, onSubmit
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div css={tw`w-full flex flex-row items-center mt-6`}>
|
<div css={tw`w-full flex flex-row items-center mt-6`}>
|
||||||
|
@ -180,10 +186,11 @@ function EditInformationContainer () {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
roleId: user.roleId,
|
adminRoleId: user.adminRoleId,
|
||||||
password: '',
|
password: '',
|
||||||
}}
|
}}
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
|
role={user?.relationships.role || null}
|
||||||
exists
|
exists
|
||||||
>
|
>
|
||||||
<div css={tw`flex`}>
|
<div css={tw`flex`}>
|
||||||
|
@ -208,7 +215,7 @@ function UserEditContainer () {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearFlashes('user');
|
clearFlashes('user');
|
||||||
|
|
||||||
getUser(Number(match.params?.id))
|
getUser(Number(match.params?.id), [ 'role' ])
|
||||||
.then(user => setUser(user))
|
.then(user => setUser(user))
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import React, { createRef, ReactElement, useEffect, useState } from 'react';
|
|
||||||
import { debounce } from 'debounce';
|
import { debounce } from 'debounce';
|
||||||
|
import React, { createRef, ReactElement, useEffect, useState } from 'react';
|
||||||
import tw, { styled } from 'twin.macro';
|
import tw, { styled } from 'twin.macro';
|
||||||
import Input from '@/components/elements/Input';
|
import Input from '@/components/elements/Input';
|
||||||
import Label from '@/components/elements/Label';
|
|
||||||
import InputSpinner from '@/components/elements/InputSpinner';
|
import InputSpinner from '@/components/elements/InputSpinner';
|
||||||
|
import Label from '@/components/elements/Label';
|
||||||
|
|
||||||
const Dropdown = styled.div<{ expanded: boolean }>`
|
const Dropdown = styled.div<{ expanded: boolean }>`
|
||||||
${tw`absolute z-10 w-full mt-1 rounded-md shadow-lg bg-neutral-900`};
|
${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> {
|
interface SearchableSelectProps<T> {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
nullable: boolean;
|
label: string;
|
||||||
|
placeholder?: string;
|
||||||
|
nullable?: boolean;
|
||||||
|
|
||||||
selected: T | null;
|
selected: T | null;
|
||||||
setSelected: (item: T | null) => void;
|
setSelected: (item: T | null) => void;
|
||||||
|
@ -80,12 +82,13 @@ interface SearchableSelectProps<T> {
|
||||||
onSearch: (query: string) => Promise<void>;
|
onSearch: (query: string) => Promise<void>;
|
||||||
onSelect: (item: T | null) => void;
|
onSelect: (item: T | null) => void;
|
||||||
|
|
||||||
getSelectedText: (item: T | null) => string;
|
getSelectedText: (item: T | null) => string | undefined;
|
||||||
|
|
||||||
children: React.ReactNode;
|
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 [ loading, setLoading ] = useState(false);
|
||||||
const [ expanded, setExpanded ] = useState(false);
|
const [ expanded, setExpanded ] = useState(false);
|
||||||
|
|
||||||
|
@ -144,7 +147,7 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = items.find((item) => item.id === highlighted);
|
const item = items.find(i => i.id === highlighted);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -169,7 +172,7 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const item = items.find((item) => item.id === highlighted);
|
const item = items.find(i => i.id === highlighted);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -210,10 +213,10 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
|
||||||
onBlur();
|
onBlur();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('click', clickHandler);
|
window.addEventListener('mousedown', clickHandler);
|
||||||
window.addEventListener('contextmenu', contextmenuHandler);
|
window.addEventListener('contextmenu', contextmenuHandler);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('click', clickHandler);
|
window.removeEventListener('mousedown', clickHandler);
|
||||||
window.removeEventListener('contextmenu', contextmenuHandler);
|
window.removeEventListener('contextmenu', contextmenuHandler);
|
||||||
};
|
};
|
||||||
}, [ expanded ]);
|
}, [ expanded ]);
|
||||||
|
@ -240,17 +243,16 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={className}>
|
||||||
<Label htmlFor={id + '-select-label'}>{name}</Label>
|
<Label htmlFor={id + '-select-label'}>{label}</Label>
|
||||||
|
|
||||||
<div css={tw`relative mt-1`}>
|
<div css={tw`relative mt-1`}>
|
||||||
<InputSpinner visible={loading}>
|
<InputSpinner visible={loading}>
|
||||||
<Input
|
<Input
|
||||||
ref={searchInput}
|
ref={searchInput}
|
||||||
type="text"
|
type={'search'}
|
||||||
className="ignoreReadOnly"
|
|
||||||
id={id}
|
id={id}
|
||||||
name={id}
|
name={name}
|
||||||
value={inputText}
|
value={inputText}
|
||||||
readOnly={!expanded}
|
readOnly={!expanded}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
|
@ -259,17 +261,29 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
|
||||||
search(e.currentTarget.value);
|
search(e.currentTarget.value);
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleInputKeydown}
|
onKeyDown={handleInputKeydown}
|
||||||
|
className={'ignoreReadOnly'}
|
||||||
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
</InputSpinner>
|
</InputSpinner>
|
||||||
|
|
||||||
<div css={tw`absolute inset-y-0 right-0 flex items-center pr-2 ml-3 pointer-events-none`}>
|
<div css={[ tw`absolute inset-y-0 right-0 flex items-center pr-2 ml-3`, !expanded && tw`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">
|
{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"/>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dropdown ref={itemsList} expanded={expanded}>
|
<Dropdown ref={itemsList} expanded={expanded}>
|
||||||
{ items === null || items.length < 1 ?
|
{items === null || items.length < 1 ?
|
||||||
items === null || inputText.length < 2 ?
|
items === null || inputText.length < 2 ?
|
||||||
<div css={tw`flex flex-row items-center h-10 px-3`}>
|
<div css={tw`flex flex-row items-center h-10 px-3`}>
|
||||||
<p css={tw`text-sm`}>Please type 2 or more characters.</p>
|
<p css={tw`text-sm`}>Please type 2 or more characters.</p>
|
||||||
|
|
|
@ -224,12 +224,14 @@ interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
placeholder?: string;
|
||||||
validate?: (value: any) => undefined | string | Promise<any>;
|
validate?: (value: any) => undefined | string | Promise<any>;
|
||||||
|
|
||||||
options: Array<Option>;
|
options: Array<Option>;
|
||||||
|
|
||||||
isMulti?: boolean;
|
isMulti?: boolean;
|
||||||
isSearchable?: boolean;
|
isSearchable?: boolean;
|
||||||
|
|
||||||
isCreatable?: boolean;
|
isCreatable?: boolean;
|
||||||
isValidNewOption?: ((
|
isValidNewOption?: ((
|
||||||
inputValue: string,
|
inputValue: string,
|
||||||
|
|
Loading…
Reference in a new issue