Flags page

Dropdown menu + other input elements
improved pagination elements
This commit is contained in:
Erik 2023-05-14 03:08:11 +03:00
parent 6476a205a4
commit d6d7de4d93
Signed by: Navy.gif
GPG Key ID: 2532FBBB61C65A68
12 changed files with 259 additions and 68 deletions

View File

@ -63,5 +63,6 @@ export type Flag = {
name: string,
env: FlagEnv,
consumer: FlagConsumer,
value: FlagType
value: FlagType,
type: string
}

View File

@ -38,36 +38,91 @@ export const FileSelector = ({ cb }: { cb: (file: File) => void }) => {
</label>;
};
export const ToggleSwitch = ({ value, onChange, ref, children }:
{ value: boolean, ref?: React.RefObject<HTMLInputElement>, onChange?: React.ChangeEventHandler, children: string }) => {
export type InputElementProperties<T> = {
value?: T,
inputRef?: React.RefObject<HTMLInputElement>,
onChange?: React.ChangeEventHandler,
children?: string,
placeholder?: string
}
export const ToggleSwitch = ({ value, onChange, inputRef, children }: InputElementProperties<boolean>) => {
return <p>
<label>
<span className="check-box check-box-row">
<b>{children}</b>
<input ref={ref} defaultChecked={value} onChange={onChange} type='checkbox' />
{children && <b>{children}</b>}
<input ref={inputRef} defaultChecked={value} onChange={onChange} type='checkbox' />
</span>
</label>
</p>;
};
export const VerticalToggleSwitch = ({ value, onChange, ref, children }:
{ value: boolean, ref?: React.RefObject<HTMLInputElement>, onChange?: React.ChangeEventHandler, children: string }) => {
export const VerticalToggleSwitch = ({ value, onChange, inputRef, children }: InputElementProperties<boolean>) => {
return <p>
<label>
<b>{children}</b>
{children && <b>{children}</b>}
<span className="check-box">
<input ref={ref} defaultChecked={value} onChange={onChange} type='checkbox' />
<input ref={inputRef} defaultChecked={value} onChange={onChange} type='checkbox' />
</span>
</label>
</p>;
};
export const NumberInput = () => {
//
type ManualInputProperties<T> = {
onBlur?: React.FocusEventHandler,
onKeyUp?: React.KeyboardEventHandler
} & InputElementProperties<T>;
type StringInputProperties = {
maxLength?: number,
minLength?: number
} & ManualInputProperties<string>
export const StringInput = ({ value, onChange, inputRef, children, placeholder, onBlur, onKeyUp, minLength, maxLength }: StringInputProperties) => {
return <label>
{children && <b>{children}</b>}
<input
onBlur={onBlur}
value={value}
placeholder={placeholder}
ref={inputRef}
onChange={onChange}
onKeyUp={onKeyUp}
maxLength={maxLength}
minLength={minLength}
/>
</label>;
};
export type NumberInputProperties = {
min?: number,
max?: number,
type?: 'float' | 'int',
step?: number
} & ManualInputProperties<number>
export const NumberInput = ({ children, placeholder, inputRef, onChange, value, min, max, type, step }: NumberInputProperties) => {
if (typeof step === 'undefined') {
if (type === 'float') step = 0.1;
else if (type === 'int') step = 1;
else step = 1;
}
return <label>
{children && <b>{children}</b>}
<input
placeholder={placeholder}
ref={inputRef}
defaultValue={value}
onChange={onChange}
type="number"
min={min}
max={max}
step={step}
/>
</label>;
};
const DropdownHeader = ({ children, className = '' }: DropdownBaseProps) => {
return <summary className={`card is-vertical-align dropdown-header p-2 ${className}`}>
return <summary className={`card is-vertical-align header p-2 ${className}`}>
{children}
</summary>;
};
@ -78,16 +133,19 @@ const DropdownItem = ({ children, type, selected, onClick }: DropdownItemProps)
InnerElement = <ToggleSwitch value={selected || false} onChange={onClick}>
{children as string}
</ToggleSwitch>;
else InnerElement = <div onClick={onClick}>
else InnerElement = <div onClick={(event) => {
event.preventDefault();
if(onClick) onClick(event);
}}>
{children}
</div>;
return <div className='dropdown-item'>
return <div className='card item'>
{InnerElement}
</div>;
};
const DropdownItemList = ({ children }: DropdownBaseProps) => {
return <div className='card w-100 '>
const DropdownItemList = ({ children, className = '' }: DropdownBaseProps) => {
return <div className={`card item-list ${className}`}>
{children}
</div>;
};
@ -126,4 +184,9 @@ const Dropdown = Object.assign(DropdownComp, {
Item: DropdownItem
});
export type InputElementType =
| ((props: InputElementProperties<string>) => React.ReactElement)
| ((props: InputElementProperties<boolean>) => React.ReactElement)
| ((props: NumberInputProperties) => React.ReactElement);
export { Dropdown };

View File

@ -1,14 +1,23 @@
import React from "react";
import { useNavigate } from "react-router";
export const PageButtons = ({ setPage, page, length }: { setPage: (page: number) => void, page: number, length: number}) => {
type PageButtonProps = {
setPage: (page: number) => void,
page: number,
pages: number
}
export const PageButtons = ({ setPage, page, pages }: PageButtonProps) => {
return <div className='flex is-vertical-align is-center page-controls'>
<button className="button dark" onClick={() => setPage(page - 1 || 1)}>Previous</button>
<p>Page: {page}</p>
<button className="button dark" onClick={() => {
if (length === 10) setPage(page + 1);
}}>Next</button>
</div>;
<button className="button dark" disabled={page === 1} onClick={() => {
setPage(page - 1 || 1);
}}>Previous</button>
<p>Page: {page} / {pages}</p>
<button className="button dark" disabled={page === pages} onClick={() => {
if (page < pages)
setPage(page + 1);
}}>Next</button>
</div>;
};
export const BackButton = () => {

View File

@ -1,3 +1,19 @@
.role-selector {
.dropdown .header {
min-height: 40px;
background-color: white;
}
.dropdown .item-list {
background-color: var(--bg-color);
width: calc(100% - 40px);
min-height: 26px;
max-height: 200px;
overflow: auto;
z-index: 999;
}
.dropdown .item {
margin: 2px;
padding: 0.5rem 1rem !important;
user-select: none;
}

View File

@ -82,6 +82,11 @@ table.striped tr:nth-of-type(2n) {
padding: 1rem;
}
.button.danger {
background-color: var(--color-error);
color: var(--font-color);
}
.page-controls button{
min-width: 120px;
}

View File

@ -0,0 +1,11 @@
.flag-list {
display: flex;
flex-wrap: wrap;
gap: 10px
}
.flag {
background-color: var(--bg-color);
/* flex: 1 0 21%; */
width: calc(25% - 8px);
}

View File

@ -95,5 +95,10 @@ li input {
}
.role {
margin: 5px
margin: 2px;
padding: 0.5rem 1rem !important;
}
.role-selector {
background-color: white;
}

View File

@ -1,33 +1,119 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Flag as APIFlag } from "../../@types/ApiStructures";
import { get } from "../../util/Util";
import { ToggleSwitch } from "../../components/InputElements";
import { Dropdown, InputElementType, NumberInput, StringInput, ToggleSwitch } from "../../components/InputElements";
import { PageButtons } from "../../components/PageControls";
const inputTypes = {
boolean: ToggleSwitch
};
const ValidFilters = ['env:prod', 'env:test', 'consumer:client', 'consumer:server', 'consumer:api'];
const Flag = ({ flag }: { flag: APIFlag }) => {
return <div className='flag card'>
<p><b>Name:</b> {flag.name}</p>
<p><b>ID:</b> {flag.id}</p>
<p><b>Environment:</b> {flag.env}</p>
<p><b>Consumer:</b> {flag.consumer}</p>
<label><b>Value:</b> <input /></label>
let Input = <p>Loading...</p>;
if (flag.type === 'string') Input = <StringInput value={flag.value as string}>
Value:
</StringInput>;
else if (flag.type === 'number') Input = <NumberInput value={flag.value as number} type='float'>
Value:
</NumberInput>;
else if (flag.type === 'boolean') Input = <ToggleSwitch value={flag.value as boolean}>
Value:
</ToggleSwitch>;
return <div className='flag card mt-0 mb-0'>
{/* <p className="mt-0 mb-1"><b>Name:</b> {flag.name}</p> */}
<h3 className="mt-0 mb-1">{flag.name}</h3>
<button className="button danger">Delete</button>
<button className="button primary">Save</button>
<p className="mt-0 mb-1"><b>ID:</b> {flag.id}</p>
<p className="mt-0 mb-1"><b>Environment:</b> {flag.env}</p>
<p className="mt-0 mb-1"><b>Consumer:</b> {flag.consumer}</p>
{Input}
</div>;
};
const FlagList = ({ flags, error }: { flags: APIFlag[], error: string | null }) => {
console.log(flags);
return <div className='col-6-lg col-12'>
const FlagList = () => {
const searchBarRef = useRef<HTMLInputElement>(null);
const [flags, setFlags] = useState<APIFlag[]>([]);
const [error, setError] = useState<string | null>(null);
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
const [availableFilters, setAvailableFilters] = useState(ValidFilters);
const [nameSearch, setNameSearch] = useState<string>('');
const [page, setPage] = useState(1);
const [pages, setPages] = useState(1);
useEffect(() => {
(async () => {
const query: { [key: string]: string[] } = {};
for (const selected of selectedFilters) {
const [prop, val] = selected.split(':');
if (!query[prop]) query[prop] = [val];
else query[prop].push(val);
}
if (nameSearch) query.name = [nameSearch];
const response = await get('/api/flags', query);
console.log(response);
if (!response.success || !response.data) {
setFlags([]);
return setError(response.message as string);
}
setFlags(response.data.flags as APIFlag[]);
setPages(response.data.pages);
setError(null);
})();
}, [selectedFilters, nameSearch, page]);
return <div className='col-12-lg col-12'>
<h4>Search</h4>
<div>
<StringInput
inputRef={searchBarRef}
onBlur={() => {
setNameSearch(searchBarRef.current?.value || '');
}}
onKeyUp={(event) => {
if (event.key === 'Enter')
setNameSearch(searchBarRef.current?.value || '');
}}
placeholder="Flag name or ID"
/>
<Dropdown>
<Dropdown.Header>
{selectedFilters.map(f => <Dropdown.Item
key={f}
onClick={() => {
setSelectedFilters(selectedFilters.filter(ff => f !== ff));
setAvailableFilters([...availableFilters, f]);
}}
>
{f}
</Dropdown.Item>)}
</Dropdown.Header>
<Dropdown.ItemList>
{availableFilters.map(f => <Dropdown.Item
key={f}
onClick={() => {
setAvailableFilters(availableFilters.filter(ff => f !== ff));
setSelectedFilters([...selectedFilters, f]);
}}
>
{f}
</Dropdown.Item>)}
</Dropdown.ItemList>
</Dropdown>
</div>
<h4>Flags</h4>
{error && <p>{error}</p>}
<div className="flag-list">
{flags.map(flag => <Flag key={flag.id} flag={flag} />)}
</div>
{flags.map(flag => <Flag key={flag.id} flag={flag} />)}
<PageButtons {...{ page, setPage, pages }} />
</div>;
@ -45,20 +131,9 @@ const CreateFlag = () => {
const Flags = () => {
const [flags, setFlags] = useState<APIFlag[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
const response = await get('/api/flags');
if (!response.success) return setError(response.message as string);
setFlags(response.data as APIFlag[]);
})();
}, []);
return <div className='row'>
<FlagList flags={flags} error={error} />
<FlagList />
<CreateFlag />

View File

@ -99,14 +99,16 @@ const Roles = () => {
const [error, setError] = useState<string | null>(null);
const [roles, setRoles] = useState<R[]>([]);
const [page, setPage] = useState(1);
const [pages, setPages] = useState(1);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
const result = await get('/api/roles', { page });
if (result.success) {
if (result.success && result.data) {
setError(null);
setRoles(result.data as R[]);
setRoles(result.data.roles as R[]);
setPages(result.data.pages);
} else {
setError(result.message || 'Unknown error');
}
@ -137,7 +139,7 @@ const Roles = () => {
/>)}
</Table>
<PageButtons {...{page, setPage, length: roles.length}} />
<PageButtons {...{page, setPage, pages}} />
</div>

View File

@ -78,7 +78,7 @@ const Application = ({ app }: { app: App }) => {
};
const RoleComp = ({ role, onClick }: { role: Role, onClick: React.ReactEventHandler }) => {
return <div className='role card p-3' >
return <div className='card role' >
{role.name} <span className="clickable" onClick={onClick}>X</span>
</div>;
};
@ -99,7 +99,8 @@ const Roles = ({ userRoles, roles }: { userRoles: Role[], roles: Role[] }) => {
return <Dropdown>
<Dropdown.Header className='role-selector'>
{equippedRoles.map(role => <RoleComp key={role.id} role={role} onClick={() => {
{equippedRoles.map(role => <RoleComp key={role.id} role={role} onClick={(event) => {
event.preventDefault();
roleDeselected(role);
}} />)}
</Dropdown.Header>
@ -233,20 +234,23 @@ const Users = () => {
const [users, setUsers] = useState<APIUser[]>([]);
const [roles, updateRoles] = useState<Role[]>([]);
const [page, setPage] = useState<number>(1);
const [page, setPage] = useState(1);
const [pages, setPages] = useState(1);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
const result = await get('/api/users', { page });
if (result.success) {
if (result.success && result.data) {
setError(null);
setUsers(result.data as APIUser[]);
setUsers(result.data.users as APIUser[]);
setPages(result.data.pages);
} else setError(result.message || 'Unknown error');
setLoading(false);
const rolesResponse = await get('/api/roles');
if (rolesResponse.success) updateRoles(rolesResponse.data as Role[]);
const rolesResponse = await get('/api/roles', { all: true });
if (rolesResponse.success && rolesResponse.data)
updateRoles(rolesResponse.data.roles as Role[]);
})();
}, [page]);
@ -272,7 +276,7 @@ const Users = () => {
itemKeys={['name', 'id']} />)}
</Table>
<PageButtons {...{ page, setPage, length: users.length }} />
<PageButtons {...{ page, setPage, pages }} />
</div>
<CreateUserField className="col-6-lg col-12" />

View File

@ -31,7 +31,7 @@ const ClickDetector = ({ children, callback }: {children: React.ReactNode, callb
alerter(wrapperRef, callback);
return (
<div ref={wrapperRef}>
<div className='click-detector' ref={wrapperRef}>
{children}
</div>
);

View File

@ -63,7 +63,7 @@ const parseResponse = async (response: Response): Promise<Res> => {
const data = await response.json();
return {...base, data};
}
return { ...base, message: await response.text() };
return { ...base, message: (await response.text() || response.statusText) };
};
export const post = async (url: string, body?: object | string, opts: ReqOptions = {}) => {
@ -107,9 +107,9 @@ export const patch = async (url: string, body: object | string, opts: RequestOpt
};
export const get = async (url: string, params?: {[key: string]: string | number}) => {
export const get = async (url: string, params?: {[key: string]: boolean | string | number | string[] | number[]}) => {
if (params) url += '?' + Object.entries(params)
.map(([key, val]) => `${key}=${val}`)
.map(([key, val]) => `${key}=${val instanceof Array ? val.join(',') : val}`)
.join('&');
const response = await fetch(url);
return parseResponse(response);