hierarchical flag list structure

This commit is contained in:
Erik 2023-05-15 00:55:27 +03:00
parent d6d7de4d93
commit c36d84b756
Signed by: Navy.gif
GPG Key ID: 2532FBBB61C65A68
7 changed files with 257 additions and 66 deletions

View File

@ -61,8 +61,9 @@ type FlagConsumer = 'client' | 'server' | 'api'
export type Flag = {
id: string,
name: string,
env: FlagEnv,
consumer: FlagConsumer,
env: string, //FlagEnv,
consumer: string, // FlagConsumer,
value: FlagType,
type: string
type: string,
hierarchy: string
}

View File

@ -41,8 +41,8 @@ export const FileSelector = ({ cb }: { cb: (file: File) => void }) => {
export type InputElementProperties<T> = {
value?: T,
inputRef?: React.RefObject<HTMLInputElement>,
onChange?: React.ChangeEventHandler,
children?: string,
onChange?: React.ChangeEventHandler<HTMLInputElement>,
children?: React.ReactNode, //JSX.Element | JSX.Element[] | string,
placeholder?: string
}
@ -78,19 +78,23 @@ type StringInputProperties = {
minLength?: number
} & ManualInputProperties<string>
export const StringInput = ({ value, onChange, inputRef, children, placeholder, onBlur, onKeyUp, minLength, maxLength }: StringInputProperties) => {
return <label>
{children && <b>{children}</b>}
<input
const input = <input
onBlur={onBlur}
value={value}
defaultValue={value}
placeholder={placeholder}
ref={inputRef}
onChange={onChange}
onKeyUp={onKeyUp}
maxLength={maxLength}
minLength={minLength}
/>
/>;
if (children)
return <label>
<b>{children}</b>
{input}
</label>;
return input;
};
export type NumberInputProperties = {
@ -106,9 +110,8 @@ export const NumberInput = ({ children, placeholder, inputRef, onChange, value,
else if (type === 'int') step = 1;
else step = 1;
}
return <label>
{children && <b>{children}</b>}
<input
const input = <input
placeholder={placeholder}
ref={inputRef}
defaultValue={value}
@ -117,12 +120,38 @@ export const NumberInput = ({ children, placeholder, inputRef, onChange, value,
min={min}
max={max}
step={step}
/>
/>;
if (children) return <label>
<b>{children}</b>
{input}
</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) => {
return <summary className={`card is-vertical-align header p-2 ${className}`}>
return <summary className={`clickable card is-vertical-align header p-2 ${className}`}>
{children}
</summary>;
};
@ -135,11 +164,11 @@ const DropdownItem = ({ children, type, selected, onClick }: DropdownItemProps)
</ToggleSwitch>;
else InnerElement = <div onClick={(event) => {
event.preventDefault();
if(onClick) onClick(event);
if (onClick) onClick(event);
}}>
{children}
</div>;
return <div className='card item'>
return <div className='card item clickable'>
{InnerElement}
</div>;
};

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

View File

@ -0,0 +1,4 @@
.popup {
position: absolute;
margin: auto;
}

View File

@ -1,4 +1,13 @@
.flag-list {
.input-group {
display: flex;
flex-direction: row;
}
.input-group > button {
margin-bottom: 1rem;
}
.flag-list-tiles {
display: flex;
flex-wrap: wrap;
gap: 10px
@ -6,6 +15,27 @@
.flag {
background-color: var(--bg-color);
/* flex: 1 0 21%; */
}
.tile {
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;
}

View File

@ -1,37 +1,123 @@
import React, { useEffect, useRef, useState } from "react";
import { Flag as APIFlag } from "../../@types/ApiStructures";
import { get } from "../../util/Util";
import { Dropdown, InputElementType, NumberInput, StringInput, ToggleSwitch } from "../../components/InputElements";
import { OrganisedFlags, capitalise, get, organiseFlags } from "../../util/Util";
import { ClickToEdit, Dropdown, InputElementType, NumberInput, StringInput, ToggleSwitch } from "../../components/InputElements";
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>;
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>;
if (flag.type === 'string') Input = <StringInput onChange={() => setUnsaved(true)} inputRef={valueRef} value={flag.value as string} />;
else if (flag.type === 'number') Input = <NumberInput onChange={() => setUnsaved(true)} inputRef={valueRef} value={flag.value as number} type='float' />;
else if (flag.type === 'boolean') Input = <ToggleSwitch onChange={() => setUnsaved(true)} inputRef={valueRef} value={flag.value as boolean} />;
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>
{unsaved && <i><small>Unsaved changes</small></i>}
<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 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>Environment:</b> {flag.env}</p>
<p className="mt-0 mb-1"><b>Consumer:</b> {flag.consumer}</p>
<p className="mt-0 mb-1">
<b>Environment: </b>
<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>;
};
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 = () => {
@ -39,31 +125,40 @@ const FlagList = () => {
const [flags, setFlags] = useState<APIFlag[]>([]);
const [error, setError] = useState<string | null>(null);
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
const [availableFilters, setAvailableFilters] = useState(ValidFilters);
const [availableFilters, setAvailableFilters] = useState<string[] | null>(null);
const [nameSearch, setNameSearch] = useState<string>('');
const [page, setPage] = useState(1);
const [pages, setPages] = useState(1);
const [listView, toggleList] = useState(false);
useEffect(() => {
(async () => {
const query: { [key: string]: string[] } = {};
const query: { [key: string]: string[] | boolean } = {};
for (const selected of selectedFilters) {
const [prop, val] = selected.split(':');
if (!query[prop]) query[prop] = [val];
else query[prop].push(val);
else (query[prop] as string[]).push(val);
}
if (nameSearch) query.name = [nameSearch];
if (listView) query.all = true;
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);
if (availableFilters === null)
setAvailableFilters(response.data.tags);
setError(null);
})();
}, [selectedFilters, nameSearch, page]);
}, [selectedFilters, nameSearch, page, listView]);
return <div className='col-12-lg col-12'>
@ -80,7 +175,7 @@ const FlagList = () => {
}}
placeholder="Flag name or ID"
/>
<Dropdown>
{availableFilters && <Dropdown>
<Dropdown.Header>
{selectedFilters.map(f => <Dropdown.Item
key={f}
@ -104,17 +199,21 @@ const FlagList = () => {
{f}
</Dropdown.Item>)}
</Dropdown.ItemList>
</Dropdown>
</Dropdown>}
</div>
<h4>Flags</h4>
<ToggleSwitch value={listView} onChange={(event) => {
toggleList(event.target.checked);
}}>List View:</ToggleSwitch>
{error && <p>{error}</p>}
<div className="flag-list">
{flags.map(flag => <Flag key={flag.id} flag={flag} />)}
</div>
{listView ?
<ListView flags={flags} /> :
<TileView flags={flags}>
<PageButtons {...{ page, setPage, pages }} />
</TileView>}
</div>;
};

View File

@ -1,4 +1,4 @@
import { User } from "../@types/ApiStructures";
import { Flag, User } from "../@types/ApiStructures";
import { RequestOptions, ClientSettings, Res, ReqOptions } from "../@types/Other";
type HttpOpts = {
@ -138,3 +138,22 @@ export const capitalise = (str: string) => {
const first = str[0].toUpperCase();
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;
};