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 = {
|
||||
id: string,
|
||||
name: string,
|
||||
env: FlagEnv,
|
||||
consumer: FlagConsumer,
|
||||
env: string, //FlagEnv,
|
||||
consumer: string, // FlagConsumer,
|
||||
value: FlagType,
|
||||
type: string
|
||||
type: string,
|
||||
hierarchy: string
|
||||
}
|
@ -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>;
|
||||
};
|
||||
@ -139,7 +168,7 @@ const DropdownItem = ({ children, type, selected, onClick }: DropdownItemProps)
|
||||
}}>
|
||||
{children}
|
||||
</div>;
|
||||
return <div className='card item'>
|
||||
return <div className='card item clickable'>
|
||||
{InnerElement}
|
||||
</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;
|
||||
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;
|
||||
}
|
@ -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>;
|
||||
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
Loading…
Reference in New Issue
Block a user