ui(admin): add role select for user management

This commit is contained in:
Matthew Penner 2021-07-25 15:51:39 -06:00
parent 58f0bbbb9b
commit 25feeaa9f5
16 changed files with 202 additions and 52 deletions

View file

@ -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 = () => (

View file

@ -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>
))}

View file

@ -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>
))}

View file

@ -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>
))}

View file

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

View 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>
);
};

View file

@ -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);

View file

@ -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>

View file

@ -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,