hierarchical flag list structure
This commit is contained in:
parent
d6d7de4d93
commit
c36d84b756
@ -61,8 +61,9 @@ type FlagConsumer = 'client' | 'server' | 'api'
|
|||||||
export type Flag = {
|
export type Flag = {
|
||||||
id: string,
|
id: string,
|
||||||
name: string,
|
name: string,
|
||||||
env: FlagEnv,
|
env: string, //FlagEnv,
|
||||||
consumer: FlagConsumer,
|
consumer: string, // FlagConsumer,
|
||||||
value: FlagType,
|
value: FlagType,
|
||||||
type: string
|
type: string,
|
||||||
|
hierarchy: string
|
||||||
}
|
}
|
@ -41,8 +41,8 @@ export const FileSelector = ({ cb }: { cb: (file: File) => void }) => {
|
|||||||
export type InputElementProperties<T> = {
|
export type InputElementProperties<T> = {
|
||||||
value?: T,
|
value?: T,
|
||||||
inputRef?: React.RefObject<HTMLInputElement>,
|
inputRef?: React.RefObject<HTMLInputElement>,
|
||||||
onChange?: React.ChangeEventHandler,
|
onChange?: React.ChangeEventHandler<HTMLInputElement>,
|
||||||
children?: string,
|
children?: React.ReactNode, //JSX.Element | JSX.Element[] | string,
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,19 +78,23 @@ type StringInputProperties = {
|
|||||||
minLength?: number
|
minLength?: number
|
||||||
} & ManualInputProperties<string>
|
} & ManualInputProperties<string>
|
||||||
export const StringInput = ({ value, onChange, inputRef, children, placeholder, onBlur, onKeyUp, minLength, maxLength }: StringInputProperties) => {
|
export const StringInput = ({ value, onChange, inputRef, children, placeholder, onBlur, onKeyUp, minLength, maxLength }: StringInputProperties) => {
|
||||||
return <label>
|
|
||||||
{children && <b>{children}</b>}
|
const input = <input
|
||||||
<input
|
onBlur={onBlur}
|
||||||
onBlur={onBlur}
|
defaultValue={value}
|
||||||
value={value}
|
placeholder={placeholder}
|
||||||
placeholder={placeholder}
|
ref={inputRef}
|
||||||
ref={inputRef}
|
onChange={onChange}
|
||||||
onChange={onChange}
|
onKeyUp={onKeyUp}
|
||||||
onKeyUp={onKeyUp}
|
maxLength={maxLength}
|
||||||
maxLength={maxLength}
|
minLength={minLength}
|
||||||
minLength={minLength}
|
/>;
|
||||||
/>
|
if (children)
|
||||||
</label>;
|
return <label>
|
||||||
|
<b>{children}</b>
|
||||||
|
{input}
|
||||||
|
</label>;
|
||||||
|
return input;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NumberInputProperties = {
|
export type NumberInputProperties = {
|
||||||
@ -106,23 +110,48 @@ export const NumberInput = ({ children, placeholder, inputRef, onChange, value,
|
|||||||
else if (type === 'int') step = 1;
|
else if (type === 'int') step = 1;
|
||||||
else step = 1;
|
else step = 1;
|
||||||
}
|
}
|
||||||
return <label>
|
|
||||||
{children && <b>{children}</b>}
|
const input = <input
|
||||||
<input
|
placeholder={placeholder}
|
||||||
placeholder={placeholder}
|
ref={inputRef}
|
||||||
ref={inputRef}
|
defaultValue={value}
|
||||||
defaultValue={value}
|
onChange={onChange}
|
||||||
onChange={onChange}
|
type="number"
|
||||||
type="number"
|
min={min}
|
||||||
min={min}
|
max={max}
|
||||||
max={max}
|
step={step}
|
||||||
step={step}
|
/>;
|
||||||
/>
|
|
||||||
|
if (children) return <label>
|
||||||
|
<b>{children}</b>
|
||||||
|
{input}
|
||||||
</label>;
|
</label>;
|
||||||
|
|
||||||
|
return input;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ClickToEdit = ({ value, onUpdate, inputElement }:
|
||||||
|
{ value: string, onUpdate?: (value: string) => void, inputElement?: React.ReactElement }) => {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
const onClick = () => {
|
||||||
|
setEditing(false);
|
||||||
|
if (ref.current && onUpdate)
|
||||||
|
onUpdate(ref.current.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = inputElement ? inputElement : <input defaultValue={value} ref={ref} />;
|
||||||
|
if (editing) return <span className='input-group'>
|
||||||
|
{input}
|
||||||
|
<button className="button primary" onClick={onClick} >
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</span>;
|
||||||
|
return <span onClick={() => setEditing(true)} className='mt-0 mb-1 clickable'>{value}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DropdownHeader = ({ children, className = '' }: DropdownBaseProps) => {
|
const DropdownHeader = ({ children, className = '' }: DropdownBaseProps) => {
|
||||||
return <summary className={`card is-vertical-align header p-2 ${className}`}>
|
return <summary className={`clickable card is-vertical-align header p-2 ${className}`}>
|
||||||
{children}
|
{children}
|
||||||
</summary>;
|
</summary>;
|
||||||
};
|
};
|
||||||
@ -135,11 +164,11 @@ const DropdownItem = ({ children, type, selected, onClick }: DropdownItemProps)
|
|||||||
</ToggleSwitch>;
|
</ToggleSwitch>;
|
||||||
else InnerElement = <div onClick={(event) => {
|
else InnerElement = <div onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if(onClick) onClick(event);
|
if (onClick) onClick(event);
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</div>;
|
</div>;
|
||||||
return <div className='card item'>
|
return <div className='card item clickable'>
|
||||||
{InnerElement}
|
{InnerElement}
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
9
src/components/PageElements.tsx
Normal file
9
src/components/PageElements.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
import '../css/components/PageElements.css';
|
||||||
|
|
||||||
|
export const Popup = ({ display = false, children }: { display: boolean, children: React.ReactElement }) => {
|
||||||
|
if (!display) return null;
|
||||||
|
return <div className='popup'>
|
||||||
|
{children}
|
||||||
|
</div>;
|
||||||
|
};
|
4
src/css/components/PageElements.css
Normal file
4
src/css/components/PageElements.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.popup {
|
||||||
|
position: absolute;
|
||||||
|
margin: auto;
|
||||||
|
}
|
@ -1,4 +1,13 @@
|
|||||||
.flag-list {
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group > button {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag-list-tiles {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px
|
gap: 10px
|
||||||
@ -6,6 +15,27 @@
|
|||||||
|
|
||||||
.flag {
|
.flag {
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
/* flex: 1 0 21%; */
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
width: calc(25% - 8px);
|
width: calc(25% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag.list-element {
|
||||||
|
margin-left: 20px;
|
||||||
|
user-select: text;
|
||||||
|
padding: 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list.category {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list.category > * {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup .flag {
|
||||||
|
height: 500px;
|
||||||
|
width: 500px;
|
||||||
}
|
}
|
@ -1,37 +1,123 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Flag as APIFlag } from "../../@types/ApiStructures";
|
import { Flag as APIFlag } from "../../@types/ApiStructures";
|
||||||
import { get } from "../../util/Util";
|
import { OrganisedFlags, capitalise, get, organiseFlags } from "../../util/Util";
|
||||||
import { Dropdown, InputElementType, NumberInput, StringInput, ToggleSwitch } from "../../components/InputElements";
|
import { ClickToEdit, Dropdown, InputElementType, NumberInput, StringInput, ToggleSwitch } from "../../components/InputElements";
|
||||||
import { PageButtons } from "../../components/PageControls";
|
import { PageButtons } from "../../components/PageControls";
|
||||||
|
import { Popup } from "../../components/PageElements";
|
||||||
|
import ClickDetector from "../../util/ClickDetector";
|
||||||
|
|
||||||
const ValidFilters = ['env:prod', 'env:test', 'consumer:client', 'consumer:server', 'consumer:api'];
|
const Flag = ({ flag: incoming }: { flag: APIFlag }) => {
|
||||||
|
|
||||||
const Flag = ({ flag }: { flag: APIFlag }) => {
|
const [flag, setFlag] = useState(incoming);
|
||||||
|
const [unsaved, setUnsaved] = useState(false);
|
||||||
|
const valueRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const updateFlag = (f: APIFlag) => {
|
||||||
|
setFlag(f);
|
||||||
|
setUnsaved(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
console.log('save');
|
||||||
|
console.log(flag);
|
||||||
|
setUnsaved(false);
|
||||||
|
};
|
||||||
|
|
||||||
let Input = <p>Loading...</p>;
|
let Input = <p>Loading...</p>;
|
||||||
if (flag.type === 'string') Input = <StringInput value={flag.value as string}>
|
if (flag.type === 'string') Input = <StringInput onChange={() => setUnsaved(true)} inputRef={valueRef} value={flag.value as string} />;
|
||||||
Value:
|
else if (flag.type === 'number') Input = <NumberInput onChange={() => setUnsaved(true)} inputRef={valueRef} value={flag.value as number} type='float' />;
|
||||||
</StringInput>;
|
else if (flag.type === 'boolean') Input = <ToggleSwitch onChange={() => setUnsaved(true)} inputRef={valueRef} value={flag.value as boolean} />;
|
||||||
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'>
|
return <div className='flag card mt-0 mb-0'>
|
||||||
{/* <p className="mt-0 mb-1"><b>Name:</b> {flag.name}</p> */}
|
{unsaved && <i><small>Unsaved changes</small></i>}
|
||||||
<h3 className="mt-0 mb-1">{flag.name}</h3>
|
<h3 className="mt-0 mb-1"><ClickToEdit onUpdate={val => updateFlag({...flag, name: val})} value={flag.name} /></h3>
|
||||||
<button className="button danger">Delete</button>
|
<button className="button danger">Delete</button>
|
||||||
<button className="button primary">Save</button>
|
{unsaved && <button onClick={save} className="button primary">Save</button>}
|
||||||
<p className="mt-0 mb-1"><b>ID:</b> {flag.id}</p>
|
<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">
|
||||||
<p className="mt-0 mb-1"><b>Consumer:</b> {flag.consumer}</p>
|
<b>Environment: </b>
|
||||||
{Input}
|
<ClickToEdit onUpdate={val => updateFlag({ ...flag, env: val })} value={flag.env} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-0 mb-1">
|
||||||
|
<b>Consumer: </b>
|
||||||
|
<ClickToEdit onUpdate={val => updateFlag({ ...flag, consumer: val })} value={flag.consumer} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-0 mb-1">
|
||||||
|
<b>Hierarchy: </b>
|
||||||
|
<ClickToEdit onUpdate={val => updateFlag({ ...flag, hierarchy: val })} value={flag.hierarchy} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-0 mb-1">
|
||||||
|
<b>Value: </b>
|
||||||
|
{Input}
|
||||||
|
</p>
|
||||||
</div>;
|
</div>;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FlagTile = ({ flag }: { flag: APIFlag }) => {
|
||||||
|
return <div className="tile">
|
||||||
|
<Flag flag={flag} />
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const FlagListElement = ({ flag, onClick }: { flag: APIFlag, onClick?: React.ReactEventHandler }) => {
|
||||||
|
return <div className='card flag list-element clickable mt-2 mb-2' onClick={onClick}>
|
||||||
|
<b>{flag.name}</b> [{flag.type}] ({flag.id})
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ListCategory = ({ flags, name, setFlag }: { flags: OrganisedFlags, name: string, setFlag: (flag: APIFlag) => void }) => {
|
||||||
|
|
||||||
|
const categories = Object.keys(flags);
|
||||||
|
const elements: JSX.Element[] = [];
|
||||||
|
for (const category of categories) {
|
||||||
|
const element = flags[category];
|
||||||
|
if (element.id)
|
||||||
|
elements.push(<FlagListElement onClick={() => setFlag(element as APIFlag)} key={element.id as string} flag={element as APIFlag} />);
|
||||||
|
else
|
||||||
|
elements.push(<ListCategory setFlag={setFlag} key={category} name={category} flags={element as OrganisedFlags} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hidden, setHidden] = useState(true);
|
||||||
|
|
||||||
|
if (name === 'root') return <div>
|
||||||
|
{elements}
|
||||||
|
</div>;
|
||||||
|
|
||||||
|
return <div className="list category">
|
||||||
|
<b className="clickable" onClick={() => { setHidden(!hidden); }}>{hidden ? '>' : 'v'} {capitalise(name || 'Unordered')}</b>
|
||||||
|
<div hidden={hidden}>
|
||||||
|
{elements}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ListView = ({ flags }: { flags: APIFlag[] }) => {
|
||||||
|
const organised = organiseFlags(flags);
|
||||||
|
|
||||||
|
const [currentFlag, setCurrentFlag] = useState<APIFlag | null>(null);
|
||||||
|
|
||||||
|
return <ClickDetector callback={() => setCurrentFlag(null)}>
|
||||||
|
|
||||||
|
<Popup display={currentFlag !== null}>
|
||||||
|
<Flag flag={currentFlag as APIFlag} />
|
||||||
|
</Popup>
|
||||||
|
|
||||||
|
<ListCategory setFlag={(flag: APIFlag) => setCurrentFlag(flag)} name={'root'} flags={organised} />
|
||||||
|
|
||||||
|
</ClickDetector>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TileView = ({ flags, children }: { flags: APIFlag[], children: React.ReactNode }) => {
|
||||||
|
return <div>
|
||||||
|
<div className="flag-list-tiles">
|
||||||
|
{flags.map(flag => <FlagTile key={flag.id} flag={flag} />)}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const FlagList = () => {
|
const FlagList = () => {
|
||||||
|
|
||||||
@ -39,31 +125,40 @@ const FlagList = () => {
|
|||||||
const [flags, setFlags] = useState<APIFlag[]>([]);
|
const [flags, setFlags] = useState<APIFlag[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
|
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
|
||||||
const [availableFilters, setAvailableFilters] = useState(ValidFilters);
|
const [availableFilters, setAvailableFilters] = useState<string[] | null>(null);
|
||||||
const [nameSearch, setNameSearch] = useState<string>('');
|
const [nameSearch, setNameSearch] = useState<string>('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pages, setPages] = useState(1);
|
const [pages, setPages] = useState(1);
|
||||||
|
const [listView, toggleList] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const query: { [key: string]: string[] } = {};
|
|
||||||
|
const query: { [key: string]: string[] | boolean } = {};
|
||||||
for (const selected of selectedFilters) {
|
for (const selected of selectedFilters) {
|
||||||
const [prop, val] = selected.split(':');
|
const [prop, val] = selected.split(':');
|
||||||
if (!query[prop]) query[prop] = [val];
|
if (!query[prop]) query[prop] = [val];
|
||||||
else query[prop].push(val);
|
else (query[prop] as string[]).push(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nameSearch) query.name = [nameSearch];
|
if (nameSearch) query.name = [nameSearch];
|
||||||
|
if (listView) query.all = true;
|
||||||
|
|
||||||
const response = await get('/api/flags', query);
|
const response = await get('/api/flags', query);
|
||||||
console.log(response);
|
|
||||||
if (!response.success || !response.data) {
|
if (!response.success || !response.data) {
|
||||||
setFlags([]);
|
setFlags([]);
|
||||||
return setError(response.message as string);
|
return setError(response.message as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
setFlags(response.data.flags as APIFlag[]);
|
setFlags(response.data.flags as APIFlag[]);
|
||||||
setPages(response.data.pages);
|
setPages(response.data.pages);
|
||||||
|
if (availableFilters === null)
|
||||||
|
setAvailableFilters(response.data.tags);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
})();
|
})();
|
||||||
}, [selectedFilters, nameSearch, page]);
|
}, [selectedFilters, nameSearch, page, listView]);
|
||||||
|
|
||||||
return <div className='col-12-lg col-12'>
|
return <div className='col-12-lg col-12'>
|
||||||
|
|
||||||
@ -80,7 +175,7 @@ const FlagList = () => {
|
|||||||
}}
|
}}
|
||||||
placeholder="Flag name or ID"
|
placeholder="Flag name or ID"
|
||||||
/>
|
/>
|
||||||
<Dropdown>
|
{availableFilters && <Dropdown>
|
||||||
<Dropdown.Header>
|
<Dropdown.Header>
|
||||||
{selectedFilters.map(f => <Dropdown.Item
|
{selectedFilters.map(f => <Dropdown.Item
|
||||||
key={f}
|
key={f}
|
||||||
@ -104,16 +199,20 @@ const FlagList = () => {
|
|||||||
{f}
|
{f}
|
||||||
</Dropdown.Item>)}
|
</Dropdown.Item>)}
|
||||||
</Dropdown.ItemList>
|
</Dropdown.ItemList>
|
||||||
</Dropdown>
|
</Dropdown>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4>Flags</h4>
|
<h4>Flags</h4>
|
||||||
|
<ToggleSwitch value={listView} onChange={(event) => {
|
||||||
|
toggleList(event.target.checked);
|
||||||
|
}}>List View:</ToggleSwitch>
|
||||||
{error && <p>{error}</p>}
|
{error && <p>{error}</p>}
|
||||||
<div className="flag-list">
|
{listView ?
|
||||||
{flags.map(flag => <Flag key={flag.id} flag={flag} />)}
|
<ListView flags={flags} /> :
|
||||||
</div>
|
<TileView flags={flags}>
|
||||||
|
<PageButtons {...{ page, setPage, pages }} />
|
||||||
|
|
||||||
<PageButtons {...{ page, setPage, pages }} />
|
</TileView>}
|
||||||
|
|
||||||
</div>;
|
</div>;
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { User } from "../@types/ApiStructures";
|
import { Flag, User } from "../@types/ApiStructures";
|
||||||
import { RequestOptions, ClientSettings, Res, ReqOptions } from "../@types/Other";
|
import { RequestOptions, ClientSettings, Res, ReqOptions } from "../@types/Other";
|
||||||
|
|
||||||
type HttpOpts = {
|
type HttpOpts = {
|
||||||
@ -137,4 +137,23 @@ export const del = async (url: string, body?: object | string, opts: RequestOpti
|
|||||||
export const capitalise = (str: string) => {
|
export const capitalise = (str: string) => {
|
||||||
const first = str[0].toUpperCase();
|
const first = str[0].toUpperCase();
|
||||||
return `${first}${str.substring(1)}`;
|
return `${first}${str.substring(1)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganisedFlags = {
|
||||||
|
[key: string]: Flag | OrganisedFlags
|
||||||
|
}
|
||||||
|
export const organiseFlags = (flags: Flag[]) => {
|
||||||
|
const obj: OrganisedFlags = {};
|
||||||
|
for (const flag of flags) {
|
||||||
|
const hierarchy = flag.hierarchy.split(':');
|
||||||
|
let selected = obj;
|
||||||
|
for (let i = 0; i < hierarchy.length; i++) {
|
||||||
|
const rank = hierarchy[i];
|
||||||
|
if (!selected[rank])
|
||||||
|
selected[rank] = {} as OrganisedFlags;
|
||||||
|
selected = selected[rank] as OrganisedFlags;
|
||||||
|
}
|
||||||
|
selected[flag.name] = flag;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
};
|
};
|
Loading…
Reference in New Issue
Block a user