Add option for the properties form to only update on submit (#23)
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: https://git.siklos-chaneru.duckdns.org/Siklos/svg-layout-designer-react/pulls/23
This commit is contained in:
Siklos 2022-08-11 08:37:10 -04:00
parent 7c16d6c97d
commit ac56f84196
12 changed files with 256 additions and 53 deletions

View file

@ -1,4 +1,4 @@
import { Dispatch, SetStateAction } from 'react';
import React, { Dispatch, SetStateAction } from 'react';
import { HistoryState } from '../../Interfaces/HistoryState';
import { Configuration } from '../../Interfaces/Configuration';
import { ContainerModel, IContainerModel } from '../../Interfaces/ContainerModel';
@ -60,7 +60,7 @@ export function DeleteContainer(
}
if (container === null || container === undefined) {
throw new Error('[OnPropertyChange] Container model was not found among children of the main container!');
throw new Error('[DeleteContainer] Container model was not found among children of the main container!');
}
if (container.parent != null) {
@ -266,6 +266,76 @@ export function OnPropertyChange(
setHistoryCurrentStep(history.length);
}
/**
* Handled the property change event in the properties form
* @param key Property name
* @param value New value of the property
* @returns void
*/
export function OnPropertiesSubmit(
event: React.SyntheticEvent<HTMLFormElement>,
refs: Array<React.RefObject<HTMLInputElement>>,
fullHistory: HistoryState[],
historyCurrentStep: number,
setHistory: Dispatch<SetStateAction<HistoryState[]>>,
setHistoryCurrentStep: Dispatch<SetStateAction<number>>
): void {
event.preventDefault();
const history = getCurrentHistory(fullHistory, historyCurrentStep);
const current = history[history.length - 1];
if (current.SelectedContainer === null ||
current.SelectedContainer === undefined) {
throw new Error('[OnPropertyChange] Property was changed before selecting a Container');
}
if (parent === null) {
const selectedContainerClone: IContainerModel = structuredClone(current.SelectedContainer);
for (const ref of refs) {
const input = ref.current;
if (input instanceof HTMLInputElement) {
(selectedContainerClone.properties as any)[input.id] = input.value;
}
}
setHistory(history.concat([{
LastAction: 'Change property of main',
MainContainer: selectedContainerClone,
SelectedContainer: selectedContainerClone,
SelectedContainerId: selectedContainerClone.properties.id,
TypeCounters: Object.assign({}, current.TypeCounters)
}]));
setHistoryCurrentStep(history.length);
return;
}
const mainContainerClone: IContainerModel = structuredClone(current.MainContainer);
const container: ContainerModel | undefined = findContainerById(mainContainerClone, current.SelectedContainer.properties.id);
if (container === null || container === undefined) {
throw new Error('[OnPropertyChange] Container model was not found among children of the main container!');
}
for (const ref of refs) {
const input = ref.current;
if (input instanceof HTMLInputElement) {
(container.properties as any)[input.id] = input.value;
}
}
if (container.properties.isRigidBody) {
RecalculatePhysics(container);
}
setHistory(history.concat([{
LastAction: `Change property of container ${container.properties.id}`,
MainContainer: mainContainerClone,
SelectedContainer: container,
SelectedContainerId: container.properties.id,
TypeCounters: Object.assign({}, current.TypeCounters)
}]));
setHistoryCurrentStep(history.length);
}
// TODO put this in a different file
export function RecalculatePhysics(container: IContainerModel): IContainerModel {

View file

@ -4,7 +4,7 @@ import { Configuration } from '../../Interfaces/Configuration';
import { SVG } from '../SVG/SVG';
import { HistoryState } from '../../Interfaces/HistoryState';
import { UI } from '../UI/UI';
import { SelectContainer, DeleteContainer, OnPropertyChange, AddContainerToSelectedContainer, AddContainer } from './ContainerOperations';
import { SelectContainer, DeleteContainer, OnPropertyChange, AddContainerToSelectedContainer, AddContainer, OnPropertiesSubmit } from './ContainerOperations';
import { SaveEditorAsJSON, SaveEditorAsSVG } from './Save';
import { onKeyDown } from './Shortcuts';
@ -72,6 +72,14 @@ const Editor: React.FunctionComponent<IEditorProps> = (props) => {
setHistory,
setHistoryCurrentStep
)}
OnPropertiesSubmit={(event, refs) => OnPropertiesSubmit(
event,
refs,
history,
historyCurrentStep,
setHistory,
setHistoryCurrentStep
)}
AddContainerToSelectedContainer={(type) => AddContainerToSelectedContainer(
type,
configuration,

View file

@ -25,6 +25,7 @@ describe.concurrent('Elements sidebar', () => {
isHistoryOpen={false}
SelectedContainer={null}
OnPropertyChange={() => {}}
OnPropertiesSubmit={() => {}}
SelectContainer={() => {}}
DeleteContainer={() => {}}
AddContainer={() => {}}
@ -57,6 +58,7 @@ describe.concurrent('Elements sidebar', () => {
isHistoryOpen={false}
SelectedContainer={MainContainer}
OnPropertyChange={() => {}}
OnPropertiesSubmit={() => {}}
SelectContainer={() => {}}
DeleteContainer={() => {}}
AddContainer={() => {}}
@ -70,12 +72,12 @@ describe.concurrent('Elements sidebar', () => {
expect(screen.queryByText('y')).toBeDefined();
expect(screen.queryByText('width')).toBeDefined();
expect(screen.queryByText('height')).toBeDefined();
const propertyId = container.querySelector('#property-id');
const propertyParentId = container.querySelector('#property-parentId');
const propertyX = container.querySelector('#property-x');
const propertyY = container.querySelector('#property-y');
const propertyWidth = container.querySelector('#property-width');
const propertyHeight = container.querySelector('#property-height');
const propertyId = container.querySelector('#id');
const propertyParentId = container.querySelector('#parentId');
const propertyX = container.querySelector('#x');
const propertyY = container.querySelector('#y');
const propertyWidth = container.querySelector('#width');
const propertyHeight = container.querySelector('#height');
expect((propertyId as HTMLInputElement).value).toBe(MainContainer.properties.id.toString());
expect(propertyParentId).toBeDefined();
expect((propertyParentId as HTMLInputElement).value).toBe('');
@ -146,6 +148,7 @@ describe.concurrent('Elements sidebar', () => {
isHistoryOpen={false}
SelectedContainer={MainContainer}
OnPropertyChange={() => {}}
OnPropertiesSubmit={() => {}}
SelectContainer={() => {}}
DeleteContainer={() => {}}
AddContainer={() => {}}
@ -202,6 +205,7 @@ describe.concurrent('Elements sidebar', () => {
isHistoryOpen={false}
SelectedContainer={SelectedContainer}
OnPropertyChange={() => {}}
OnPropertiesSubmit={() => {}}
SelectContainer={selectContainer}
DeleteContainer={() => {}}
AddContainer={() => {}}
@ -212,8 +216,8 @@ describe.concurrent('Elements sidebar', () => {
expect(screen.getByText(/main/i));
const child1 = screen.getByText(/child-1/i);
expect(child1);
const propertyId = container.querySelector('#property-id');
const propertyParentId = container.querySelector('#property-parentId');
const propertyId = container.querySelector('#id');
const propertyParentId = container.querySelector('#parentId');
expect((propertyId as HTMLInputElement).value).toBe(MainContainer.properties.id.toString());
expect((propertyParentId as HTMLInputElement).value).toBe('');
@ -225,6 +229,7 @@ describe.concurrent('Elements sidebar', () => {
isHistoryOpen={false}
SelectedContainer={SelectedContainer}
OnPropertyChange={() => {}}
OnPropertiesSubmit={() => {}}
SelectContainer={selectContainer}
DeleteContainer={() => {}}
AddContainer={() => {}}

View file

@ -14,6 +14,7 @@ interface IElementsSidebarProps {
isHistoryOpen: boolean
SelectedContainer: IContainerModel | null
OnPropertyChange: (key: string, value: string | number | boolean) => void
OnPropertiesSubmit: (event: React.FormEvent<HTMLFormElement>, refs: Array<React.RefObject<HTMLInputElement>>) => void
SelectContainer: (container: IContainerModel) => void
DeleteContainer: (containerid: string) => void
AddContainer: (index: number, type: string, parent: string) => void
@ -145,7 +146,11 @@ export const ElementsSidebar: React.FC<IElementsSidebarProps> = (props: IElement
props.DeleteContainer(onClickContainerId);
}} />
</Menu>
<Properties properties={props.SelectedContainer?.properties} onChange={props.OnPropertyChange}></Properties>
<Properties
properties={props.SelectedContainer?.properties}
onChange={props.OnPropertyChange}
onSubmit={props.OnPropertiesSubmit}
/>
</div>
);
};

View file

@ -38,13 +38,8 @@ export const MainMenu: React.FC<IMainMenuProps> = (props) => {
</form>
<button
onClick={() => setWindowState(WindowState.MAIN)}
className='block text-sm
mt-8 py-2 px-4
rounded-full border-0
font-semibold
transition-all
bg-blue-100 text-blue-700
hover:bg-blue-200'
className='normal-btn block
mt-8 '
>
Go back
</button>

View file

@ -8,6 +8,7 @@ describe.concurrent('Properties', () => {
render(<Properties
properties={undefined}
onChange={() => {}}
onSubmit={() => {}}
/>);
expect(screen.queryByText('id')).toBeNull();
@ -16,7 +17,7 @@ describe.concurrent('Properties', () => {
expect(screen.queryByText('y')).toBeNull();
});
it('Some properties', () => {
it('Some properties, change values with dynamic input', () => {
const prop = {
id: 'stuff',
parentId: 'parentId',
@ -32,6 +33,7 @@ describe.concurrent('Properties', () => {
const { container, rerender } = render(<Properties
properties={prop}
onChange={handleChange}
onSubmit={() => {}}
/>);
expect(screen.queryByText('id')).toBeDefined();
@ -39,10 +41,10 @@ describe.concurrent('Properties', () => {
expect(screen.queryByText('x')).toBeDefined();
expect(screen.queryByText('y')).toBeDefined();
let propertyId = container.querySelector('#property-id');
let propertyParentId = container.querySelector('#property-parentId');
let propertyX = container.querySelector('#property-x');
let propertyY = container.querySelector('#property-y');
let propertyId = container.querySelector('#id');
let propertyParentId = container.querySelector('#parentId');
let propertyX = container.querySelector('#x');
let propertyY = container.querySelector('#y');
expect(propertyId).toBeDefined();
expect((propertyId as HTMLInputElement).value).toBe('stuff');
expect(propertyParentId).toBeDefined();
@ -65,12 +67,13 @@ describe.concurrent('Properties', () => {
rerender(<Properties
properties={Object.assign({}, prop)}
onChange={handleChange}
onSubmit={() => {}}
/>);
propertyId = container.querySelector('#property-id');
propertyParentId = container.querySelector('#property-parentId');
propertyX = container.querySelector('#property-x');
propertyY = container.querySelector('#property-y');
propertyId = container.querySelector('#id');
propertyParentId = container.querySelector('#parentId');
propertyX = container.querySelector('#x');
propertyY = container.querySelector('#y');
expect(propertyId).toBeDefined();
expect((propertyId as HTMLInputElement).value).toBe('stuffed');
expect(propertyParentId).toBeDefined();

View file

@ -1,25 +1,50 @@
import * as React from 'react';
import React, { useState } from 'react';
import ContainerProperties from '../../Interfaces/Properties';
import { ToggleButton } from '../ToggleButton/ToggleButton';
import { INPUT_TYPES } from './PropertiesInputTypes';
interface IPropertiesProps {
properties?: ContainerProperties
onChange: (key: string, value: string | number | boolean) => void
onSubmit: (event: React.FormEvent<HTMLFormElement>, refs: Array<React.RefObject<HTMLInputElement>>) => void
}
export const Properties: React.FC<IPropertiesProps> = (props: IPropertiesProps) => {
const [isDynamicInput, setIsDynamicInput] = useState<boolean>(true);
if (props.properties === undefined) {
return <div></div>;
}
const groupInput: React.ReactNode[] = [];
const refs: Array<React.RefObject<HTMLInputElement>> = [];
Object
.entries(props.properties)
.forEach((pair) => handleProperties(pair, groupInput, props.onChange));
.forEach((pair) => handleProperties(pair, groupInput, refs, isDynamicInput, props.onChange));
const form = isDynamicInput
? <div>
{ groupInput }
</div>
: <form
key={props.properties.id}
onSubmit={(event) => props.onSubmit(event, refs)}
>
<input type='submit' className='normal-btn block mx-auto' value='Submit'/>
{ groupInput }
</form>
;
return (
<div className='p-3 bg-slate-200 h-3/5 overflow-y-auto'>
{ groupInput }
<div className='h-3/5 p-3 bg-slate-200 overflow-y-auto'>
<ToggleButton
id='isDynamic'
text='Dynamic update'
title='Enable dynamic svg update'
checked={isDynamicInput}
onChange={() => setIsDynamicInput(!isDynamicInput)}
/>
{ form }
</div>
);
};
@ -27,6 +52,8 @@ export const Properties: React.FC<IPropertiesProps> = (props: IPropertiesProps)
const handleProperties = (
[key, value]: [string, string | number],
groupInput: React.ReactNode[],
refs: Array<React.RefObject<HTMLInputElement>>,
isDynamicInput: boolean,
onChange: (key: string, value: string | number | boolean) => void
): void => {
const id = `property-${key}`;
@ -42,31 +69,49 @@ const handleProperties = (
type = INPUT_TYPES[key];
}
const isDisabled = ['id', 'parentId'].includes(key);
///
const ref: React.RefObject<HTMLInputElement> = React.useRef<HTMLInputElement>(null);
refs.push(ref);
groupInput.push(
<div key={id} className='mt-4'>
<label className='text-sm font-medium text-gray-800' htmlFor={id}>{key}</label>
<input
className='text-base font-medium transition-all text-gray-800 mt-1 block w-full px-3 py-2
const isDisabled = ['id', 'parentId'].includes(key);
const input = isDynamicInput
? <input
className='text-base font-medium transition-all text-gray-800 mt-1 block w-full px-3 py-2
bg-white border-2 border-white rounded-lg placeholder-gray-800
focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500
disabled:bg-slate-300 disabled:text-gray-500 disabled:border-slate-300 disabled:shadow-none
'
type={type}
id={id}
value={value}
checked={checked}
onChange={(event) => {
if (type === 'checkbox') {
onChange(key, event.target.checked);
return;
}
onChange(key, event.target.value);
}}
disabled={isDisabled}
/>
type={type}
id={key}
ref={ref}
value={value}
checked={checked}
onChange={(event) => {
if (type === 'checkbox') {
onChange(key, event.target.checked);
return;
}
onChange(key, event.target.value);
}}
disabled={isDisabled}
/>
: <input
className='text-base font-medium transition-all text-gray-800 mt-1 block w-full px-3 py-2
bg-white border-2 border-white rounded-lg placeholder-gray-800
focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500
disabled:bg-slate-300 disabled:text-gray-500 disabled:border-slate-300 disabled:shadow-none
'
type={type}
id={key}
ref={ref}
defaultValue={value}
defaultChecked={checked}
disabled={isDisabled}
/>;
groupInput.push(
<div key={id} className='mt-4'>
<label className='text-sm font-medium text-gray-800' htmlFor={key}>{key}</label>
{input}
</div>
);
};

View file

@ -0,0 +1,8 @@
input:checked ~ .dot {
transform: translateX(100%);
}
input:checked ~ .line {
background-color: #3B82F6;
}

View file

@ -0,0 +1,52 @@
import React, { FC } from 'react';
import './ToggleButton.scss';
interface IToggleButtonProps {
id: string
text: string
type?: TOGGLE_TYPE
title: string
checked: boolean
onChange: React.ChangeEventHandler<HTMLInputElement>
}
export enum TOGGLE_TYPE {
MATERIAL,
IOS
}
export const ToggleButton: FC<IToggleButtonProps> = (props) => {
const id = `toggle-${props.id}`;
const type = props.type ?? TOGGLE_TYPE.MATERIAL;
let classLine = 'line w-10 h-4 bg-gray-400 rounded-full shadow-inner';
let classDot = 'dot absolute w-6 h-6 bg-white rounded-full shadow -left-1 -top-1 transition';
if (type === TOGGLE_TYPE.IOS) {
classLine = 'line block bg-gray-600 w-14 h-8 rounded-full';
classDot = 'dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition';
}
return (
<div title={props.title}>
<div className="flex items-center justify-center w-full mb-12">
<label
htmlFor={id}
className="flex items-center cursor-pointer"
>
<div className="relative">
<input
id={id}
type="checkbox"
onChange={props.onChange}
checked={props.checked}
className="sr-only" />
<div className={classLine}></div>
<div className={classDot}></div>
</div>
<div className="ml-3 text-gray-700 font-medium">
{ props.text }
</div>
</label>
</div>
</div>
);
};

View file

@ -17,6 +17,7 @@ interface IUIProps {
SelectContainer: (container: ContainerModel) => void
DeleteContainer: (containerId: string) => void
OnPropertyChange: (key: string, value: string | number | boolean) => void
OnPropertiesSubmit: (event: React.FormEvent<HTMLFormElement>, refs: Array<React.RefObject<HTMLInputElement>>) => void
AddContainerToSelectedContainer: (type: string) => void
AddContainer: (index: number, type: string, parentId: string) => void
SaveEditorAsJSON: () => void
@ -59,6 +60,7 @@ export const UI: React.FunctionComponent<IUIProps> = (props: IUIProps) => {
isOpen={isElementsSidebarOpen}
isHistoryOpen={isHistoryOpen}
OnPropertyChange={props.OnPropertyChange}
OnPropertiesSubmit={props.OnPropertiesSubmit}
SelectContainer={props.SelectContainer}
DeleteContainer={props.DeleteContainer}
AddContainer={props.AddContainer}

View file

@ -23,6 +23,16 @@
@apply transition-all bg-blue-100 hover:bg-blue-200 text-blue-700 text-lg font-semibold p-8 rounded-lg
}
.normal-btn {
@apply text-sm
py-2 px-4
rounded-full border-0
font-semibold
transition-all
bg-blue-100 text-blue-700
hover:bg-blue-200
}
.floating-btn {
@apply h-full w-full text-white align-middle items-center justify-center
}

View file

@ -30,9 +30,9 @@ export const DEFAULT_MAINCONTAINER_PROPS: Properties = {
parentId: 'null',
x: 0,
y: 0,
isRigidBody: false,
width: DEFAULT_CONFIG.MainContainer.Width,
height: DEFAULT_CONFIG.MainContainer.Height,
isRigidBody: false,
fillOpacity: 0,
stroke: 'black'
};