Skip to content

Commit

Permalink
[227] Introduce a dedicated hook to manage forms
Browse files Browse the repository at this point in the history
Bug: #227
Signed-off-by: Stéphane Bégaudeau <stephane.begaudeau@gmail.com>
  • Loading branch information
sbegaudeau committed Aug 19, 2023
1 parent f9e0c1a commit b92343b
Show file tree
Hide file tree
Showing 4 changed files with 166 additions and 71 deletions.
69 changes: 69 additions & 0 deletions frontend/svalyn-studio-app/src/forms/useForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2023 Stéphane Bégaudeau.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import { TextFieldProps } from '@mui/material/TextField';
import { useState } from 'react';
import { UseFormProps, UseFormState, UseFormValue, ValidationRule } from './useForm.types';

export function useForm<FormDataType extends Object>({
initialValue,
validationRules,
}: UseFormProps<FormDataType>): UseFormValue<FormDataType> {
const [state, setState] = useState<UseFormState<FormDataType>>({
data: initialValue,
isFormValid: false,
});

const getTextFieldProps = (name: keyof FormDataType): TextFieldProps => {
return {
value: state.data[name],
onChange: (event) => {
const {
target: { value },
} = event;
setState((prevState) => {
const newData: FormDataType = { ...prevState.data, [name]: value };
const isFormValid = validate(newData, validationRules);
return { ...prevState, data: newData, isFormValid };
});
},
};
};

return {
data: state.data,
isFormValid: state.isFormValid,
getTextFieldProps,
};
}

function validate<FormDataType extends Object>(
data: FormDataType,
validationRules: { [FormDataProperty in keyof FormDataType]: ValidationRule<FormDataType, FormDataProperty> }
): boolean {
let isFormValid: boolean = true;
for (const property in data) {
const validationRule = validationRules[property];
if (validationRule) {
const isValidProperty = validationRule(data, property);
isFormValid = isFormValid && isValidProperty;
}
}
return isFormValid;
}
41 changes: 41 additions & 0 deletions frontend/svalyn-studio-app/src/forms/useForm.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 Stéphane Bégaudeau.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import { TextFieldProps } from '@mui/material/TextField';

export interface UseFormProps<FormDataType extends Object> {
initialValue: FormDataType;
validationRules: { [FormDataProperty in keyof FormDataType]: ValidationRule<FormDataType, FormDataProperty> };
}

export interface UseFormValue<FormDataType extends Object> {
data: FormDataType;
isFormValid: boolean;
getTextFieldProps: (name: keyof FormDataType) => TextFieldProps;
}

export type ValidationRule<FormDataType extends Object, FormDataProperty> = (
data: FormDataType,
property: FormDataProperty
) => boolean;

export interface UseFormState<FormDataType extends Object> {
data: FormDataType;
isFormValid: boolean;
}
119 changes: 51 additions & 68 deletions frontend/svalyn-studio-app/src/new/NewOrganizationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,42 +28,28 @@ import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { Navigate, Link as RouterLink } from 'react-router-dom';
import { useForm } from '../forms/useForm';
import { Navbar } from '../navbars/Navbar';
import { ErrorSnackbar } from '../snackbar/ErrorSnackbar';
import { NewOrganizationViewState } from './NewOrganizationView.types';
import { NewOrganizationViewFormData, NewOrganizationViewState } from './NewOrganizationView.types';
import { useCreateOrganization } from './useCreateOrganization';
import { CreateOrganizationInput } from './useCreateOrganization.types';

export const NewOrganizationView = () => {
const [state, setState] = useState<NewOrganizationViewState>({
name: '',
organizationId: '',
isFormValid: false,
message: null,
});

const handleNameChanged: React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> = (event) => {
const {
target: { value },
} = event;

setState((prevState) => ({
...prevState,
name: value,
isFormValid: value.length > 0 && prevState.organizationId.length > 0,
}));
};

const handleOrganizationIdChanged: React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> = (event) => {
const {
target: { value },
} = event;
setState((prevState) => ({
...prevState,
organizationId: value,
isFormValid: value.length > 0 && prevState.name.length > 0,
}));
};
const { data, isFormValid, getTextFieldProps } = useForm<NewOrganizationViewFormData>({
initialValue: {
name: '',
organizationId: '',
},
validationRules: {
name: (data, property) => data[property].length > 0,
organizationId: (data, property) => data[property].length > 0,
},
});

const [createOrganization, { organization, message }] = useCreateOrganization();
useEffect(() => {
Expand All @@ -72,11 +58,13 @@ export const NewOrganizationView = () => {
}
}, [message]);

const handleCreateOrganization: React.MouseEventHandler<HTMLButtonElement> = () => {
const handleCreateOrganization: React.FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();

const input: CreateOrganizationInput = {
id: crypto.randomUUID(),
identifier: state.organizationId,
name: state.name,
identifier: data.organizationId,
name: data.name,
};
createOrganization(input);
};
Expand All @@ -95,45 +83,40 @@ export const NewOrganizationView = () => {
<Container maxWidth="sm">
<Toolbar />
<Paper variant="outlined" sx={{ padding: (theme) => theme.spacing(2) }}>
<Stack spacing={4}>
<Typography variant="h4" align="center">
Let's create your organization
</Typography>
<TextField
label="Organization Name"
helperText="This is where you'll manage your domains"
value={state.name}
onChange={handleNameChanged}
variant="outlined"
autoFocus
required
inputProps={{
'aria-label': 'Organization Name',
}}
/>
<TextField
label="Organization Identifier"
helperText="A unique identifier composed of letters, numbers and dashes"
value={state.organizationId}
onChange={handleOrganizationIdChanged}
variant="outlined"
required
inputProps={{
'aria-label': 'Organization Identifier',
}}
/>
<Button
variant="contained"
startIcon={<CorporateFareIcon />}
onClick={handleCreateOrganization}
disabled={!state.isFormValid}
>
Create organization
</Button>
<Link component={RouterLink} to="/" variant="body2" underline="hover" align="center">
Back to the homepage
</Link>
</Stack>
<form onSubmit={handleCreateOrganization}>
<Stack spacing={4}>
<Typography variant="h4" align="center">
Let's create your organization
</Typography>
<TextField
{...getTextFieldProps('name')}
label="Organization Name"
helperText="This is where you'll manage your domains"
variant="outlined"
autoFocus
required
inputProps={{
'aria-label': 'Organization Name',
}}
/>
<TextField
{...getTextFieldProps('organizationId')}
label="Organization Identifier"
helperText="A unique identifier composed of letters, numbers and dashes"
variant="outlined"
required
inputProps={{
'aria-label': 'Organization Identifier',
}}
/>
<Button type="submit" variant="contained" startIcon={<CorporateFareIcon />} disabled={!isFormValid}>
Create organization
</Button>
<Link component={RouterLink} to="/" variant="body2" underline="hover" align="center">
Back to the homepage
</Link>
</Stack>
</form>
</Paper>
</Container>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 Stéphane Bégaudeau.
* Copyright (c) 2022, 2023 Stéphane Bégaudeau.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
Expand All @@ -18,8 +18,10 @@
*/

export interface NewOrganizationViewState {
message: string | null;
}

export interface NewOrganizationViewFormData {
name: string;
organizationId: string;
isFormValid: boolean;
message: string | null;
}

0 comments on commit b92343b

Please sign in to comment.