diff --git a/src/@types/ApiStructures.ts b/src/@types/ApiStructures.ts index 65d9ff6..384f0a7 100644 --- a/src/@types/ApiStructures.ts +++ b/src/@types/ApiStructures.ts @@ -63,5 +63,6 @@ export type Flag = { name: string, env: FlagEnv, consumer: FlagConsumer, - value: FlagType + value: FlagType, + type: string } \ No newline at end of file diff --git a/src/components/InputElements.tsx b/src/components/InputElements.tsx index 066337e..4171b37 100644 --- a/src/components/InputElements.tsx +++ b/src/components/InputElements.tsx @@ -38,36 +38,91 @@ export const FileSelector = ({ cb }: { cb: (file: File) => void }) => { ; }; -export const ToggleSwitch = ({ value, onChange, ref, children }: - { value: boolean, ref?: React.RefObject, onChange?: React.ChangeEventHandler, children: string }) => { +export type InputElementProperties = { + value?: T, + inputRef?: React.RefObject, + onChange?: React.ChangeEventHandler, + children?: string, + placeholder?: string +} + +export const ToggleSwitch = ({ value, onChange, inputRef, children }: InputElementProperties) => { return

; }; -export const VerticalToggleSwitch = ({ value, onChange, ref, children }: - { value: boolean, ref?: React.RefObject, onChange?: React.ChangeEventHandler, children: string }) => { +export const VerticalToggleSwitch = ({ value, onChange, inputRef, children }: InputElementProperties) => { return

; }; -export const NumberInput = () => { - // +type ManualInputProperties = { + onBlur?: React.FocusEventHandler, + onKeyUp?: React.KeyboardEventHandler +} & InputElementProperties; + +type StringInputProperties = { + maxLength?: number, + minLength?: number +} & ManualInputProperties +export const StringInput = ({ value, onChange, inputRef, children, placeholder, onBlur, onKeyUp, minLength, maxLength }: StringInputProperties) => { + return ; +}; + +export type NumberInputProperties = { + min?: number, + max?: number, + type?: 'float' | 'int', + step?: number +} & ManualInputProperties + +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 ; }; const DropdownHeader = ({ children, className = '' }: DropdownBaseProps) => { - return + return {children} ; }; @@ -78,16 +133,19 @@ const DropdownItem = ({ children, type, selected, onClick }: DropdownItemProps) InnerElement = {children as string} ; - else InnerElement =
+ else InnerElement =
{ + event.preventDefault(); + if(onClick) onClick(event); + }}> {children}
; - return
+ return
{InnerElement}
; }; -const DropdownItemList = ({ children }: DropdownBaseProps) => { - return
+const DropdownItemList = ({ children, className = '' }: DropdownBaseProps) => { + return
{children}
; }; @@ -126,4 +184,9 @@ const Dropdown = Object.assign(DropdownComp, { Item: DropdownItem }); +export type InputElementType = + | ((props: InputElementProperties) => React.ReactElement) + | ((props: InputElementProperties) => React.ReactElement) + | ((props: NumberInputProperties) => React.ReactElement); + export { Dropdown }; \ No newline at end of file diff --git a/src/components/PageControls.tsx b/src/components/PageControls.tsx index 216df8f..64cbbe9 100644 --- a/src/components/PageControls.tsx +++ b/src/components/PageControls.tsx @@ -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
- -

Page: {page}

- -
; + +

Page: {page} / {pages}

+ +
; }; export const BackButton = () => { diff --git a/src/css/components/InputElements.css b/src/css/components/InputElements.css index 30844bb..6f45c24 100644 --- a/src/css/components/InputElements.css +++ b/src/css/components/InputElements.css @@ -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; } \ No newline at end of file diff --git a/src/css/index.css b/src/css/index.css index f7523f0..55cae8e 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -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; } diff --git a/src/css/pages/Admin.css b/src/css/pages/Admin.css index e69de29..c1b73c3 100644 --- a/src/css/pages/Admin.css +++ b/src/css/pages/Admin.css @@ -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); +} \ No newline at end of file diff --git a/src/css/pages/Users.css b/src/css/pages/Users.css index 08fda31..93bd4b4 100644 --- a/src/css/pages/Users.css +++ b/src/css/pages/Users.css @@ -95,5 +95,10 @@ li input { } .role { - margin: 5px + margin: 2px; + padding: 0.5rem 1rem !important; +} + +.role-selector { + background-color: white; } \ No newline at end of file diff --git a/src/pages/admin/Flags.tsx b/src/pages/admin/Flags.tsx index f33dc8d..8413b91 100644 --- a/src/pages/admin/Flags.tsx +++ b/src/pages/admin/Flags.tsx @@ -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
-

Name: {flag.name}

-

ID: {flag.id}

-

Environment: {flag.env}

-

Consumer: {flag.consumer}

- + let Input =

Loading...

; + if (flag.type === 'string') Input = + Value: + ; + else if (flag.type === 'number') Input = + Value: + ; + else if (flag.type === 'boolean') Input = + Value: + ; + + return
+ {/*

Name: {flag.name}

*/} +

{flag.name}

+ + +

ID: {flag.id}

+

Environment: {flag.env}

+

Consumer: {flag.consumer}

+ {Input}
; }; -const FlagList = ({ flags, error }: { flags: APIFlag[], error: string | null }) => { - console.log(flags); - return
+const FlagList = () => { + + const searchBarRef = useRef(null); + const [flags, setFlags] = useState([]); + const [error, setError] = useState(null); + const [selectedFilters, setSelectedFilters] = useState([]); + const [availableFilters, setAvailableFilters] = useState(ValidFilters); + const [nameSearch, setNameSearch] = useState(''); + 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
+ +

Search

+
+ { + setNameSearch(searchBarRef.current?.value || ''); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') + setNameSearch(searchBarRef.current?.value || ''); + }} + placeholder="Flag name or ID" + /> + + + {selectedFilters.map(f => { + setSelectedFilters(selectedFilters.filter(ff => f !== ff)); + setAvailableFilters([...availableFilters, f]); + }} + > + {f} + )} + + + + {availableFilters.map(f => { + setAvailableFilters(availableFilters.filter(ff => f !== ff)); + setSelectedFilters([...selectedFilters, f]); + }} + > + {f} + )} + + +
+

Flags

{error &&

{error}

} +
+ {flags.map(flag => )} +
- {flags.map(flag => )} +
; @@ -45,20 +131,9 @@ const CreateFlag = () => { const Flags = () => { - const [flags, setFlags] = useState([]); - const [error, setError] = useState(null); - - useEffect(() => { - (async () => { - const response = await get('/api/flags'); - if (!response.success) return setError(response.message as string); - setFlags(response.data as APIFlag[]); - })(); - }, []); - return
- + diff --git a/src/pages/admin/Roles.tsx b/src/pages/admin/Roles.tsx index 9405e61..c04f2e8 100644 --- a/src/pages/admin/Roles.tsx +++ b/src/pages/admin/Roles.tsx @@ -99,14 +99,16 @@ const Roles = () => { const [error, setError] = useState(null); const [roles, setRoles] = useState([]); 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 = () => { />)} - +
diff --git a/src/pages/admin/Users.tsx b/src/pages/admin/Users.tsx index 5abf561..02358e9 100644 --- a/src/pages/admin/Users.tsx +++ b/src/pages/admin/Users.tsx @@ -78,7 +78,7 @@ const Application = ({ app }: { app: App }) => { }; const RoleComp = ({ role, onClick }: { role: Role, onClick: React.ReactEventHandler }) => { - return
+ return
{role.name} X
; }; @@ -99,7 +99,8 @@ const Roles = ({ userRoles, roles }: { userRoles: Role[], roles: Role[] }) => { return - {equippedRoles.map(role => { + {equippedRoles.map(role => { + event.preventDefault(); roleDeselected(role); }} />)} @@ -233,20 +234,23 @@ const Users = () => { const [users, setUsers] = useState([]); const [roles, updateRoles] = useState([]); - const [page, setPage] = useState(1); + const [page, setPage] = useState(1); + const [pages, setPages] = useState(1); const [error, setError] = useState(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']} />)} - +
diff --git a/src/util/ClickDetector.tsx b/src/util/ClickDetector.tsx index 23548fb..2dc088b 100644 --- a/src/util/ClickDetector.tsx +++ b/src/util/ClickDetector.tsx @@ -31,7 +31,7 @@ const ClickDetector = ({ children, callback }: {children: React.ReactNode, callb alerter(wrapperRef, callback); return ( -
+
{children}
); diff --git a/src/util/Util.ts b/src/util/Util.ts index 8d2a9ff..65246a1 100644 --- a/src/util/Util.ts +++ b/src/util/Util.ts @@ -63,7 +63,7 @@ const parseResponse = async (response: Response): Promise => { 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);