(feature): EditAccout form complete

This commit is contained in:
Maksym Sadovnychyy 2025-11-05 22:07:28 +01:00
parent 494fcc0f9a
commit 5c7bf224c3
12 changed files with 309 additions and 53 deletions

View File

@ -1,24 +1,64 @@
import { FC } from 'react'
import { FC, useCallback, useEffect, useState } from 'react'
import { FormContainer, FormContent, FormFooter, FormHeader } from '../components/FormLayout'
import { ButtonComponent, CheckBoxComponent, TextBoxComponent } from '../components/editors'
import { GetAccountResponse } from '../models/letsEncryptServer/account/responses/GetAccountResponse'
import { useFormState } from '../hooks/useFormState'
import { boolean, object, Schema, string } from 'zod'
import { array, boolean, object, Schema, string } from 'zod'
import { PlusIcon, TrashIcon } from 'lucide-react'
import { getData, patchData } from '../axiosConfig'
import { ApiRoutes, GetApiRoute } from '../AppMap'
import { FieldContainer } from '../components/editors/FieldContainer'
import { deepCopy, deepDelta, deltaHasOperations } from '../functions'
import { PatchAccountRequest, PatchAccountRequestSchema } from '../models/letsEncryptServer/account/requests/PatchAccountRequest'
import { addToast } from '../components/Toast/addToast'
interface EditAccountHostnameFormProps {
isDisabled: boolean
hostname: string
}
const EditAccountHostnameFormProto = (): EditAccountHostnameFormProps => ({
isDisabled: false,
hostname: ''
})
const EditAccountHostnameFormSchema: Schema<EditAccountHostnameFormProps> = object({
hostname: string(),
isDisabled: boolean()
})
interface EditAccountFormProps {
isDisabled: boolean
description: string
disabled: boolean
contact: string
contacts: string[]
hostname: string,
hostnames: EditAccountHostnameFormProps[]
}
const RegisterFormProto = (): EditAccountFormProps => ({
isDisabled: false,
description: '',
disabled: false
contact: '',
contacts: [],
hostname: '',
hostnames: [],
})
const RegisterFormSchema: Schema<EditAccountFormProps> = object({
isDisabled: boolean(),
description: string(),
disabled: boolean()
contact: string(),
contacts: array(string()),
hostname: string(),
hostnames: array(EditAccountHostnameFormSchema)
})
interface EditAccountProps {
@ -41,14 +81,88 @@ const EditAccount: FC<EditAccountProps> = (props) => {
formState,
errors,
formIsValid,
handleInputChange
handleInputChange,
setInitialState
} = useFormState<EditAccountFormProps>({
initialState: RegisterFormProto(),
validationSchema: RegisterFormSchema
})
const [backupState, setBackupState] = useState<EditAccountFormProps>(RegisterFormProto())
const handleInitialization = useCallback((response: GetAccountResponse) => {
const newState = {
...RegisterFormProto(),
isDisabled: response.isDisabled,
description: response.description,
contacts: response.contacts,
hostnames: (response.hostnames ?? []).map(h => ({
...EditAccountHostnameFormProto(),
isDisabled: h.isDisabled,
hostname: h.hostname
}))
}
setInitialState(newState)
setBackupState(deepCopy(newState))
}, [setInitialState])
useEffect(() => {
getData<GetAccountResponse>(GetApiRoute(ApiRoutes.ACCOUNT_GET).route
.replace('{accountId}', accountId)
).then((response) => {
if (!response) return
handleInitialization(response)
})
}, [accountId, handleInitialization])
const mapFormStateToPatchRequest = (formState: EditAccountFormProps) : PatchAccountRequest => {
const formStateCopy = deepCopy(formState)
const patchRequest: PatchAccountRequest = {
isDisabled: formStateCopy.isDisabled,
description: formStateCopy.description,
contacts: formStateCopy.contacts,
hostnames: formStateCopy.hostnames.map(h => ({
hostname: h.hostname
}))
}
return patchRequest
}
const handleSubmit = () => {
// onSubmitted && onSubmitted(updatedEntity)
if (!formIsValid) return
const fromFormState = mapFormStateToPatchRequest(formState)
const fromBackupState = mapFormStateToPatchRequest(backupState)
const delta = deepDelta(fromBackupState, fromFormState)
if (!deltaHasOperations(delta)) {
addToast('No changes detected', 'info')
return
}
const request = PatchAccountRequestSchema.safeParse(delta)
if (!request.success) {
request.error.issues.forEach(error => {
addToast(error.message, 'error')
})
return
}
patchData<PatchAccountRequest, GetAccountResponse>(GetApiRoute(ApiRoutes.ACCOUNT_PATCH).route
.replace('{accountId}', accountId), delta
).then((response) => {
if (!response) return
handleInitialization(response)
onSubmitted?.(response)
})
}
const handleCancel = () => {
@ -70,13 +184,100 @@ const EditAccount: FC<EditAccountProps> = (props) => {
<CheckBoxComponent
colspan={12}
label={'Disabled'}
value={formState.disabled}
onChange={(e) => handleInputChange('disabled', e.target.checked)}
errorText={errors.disabled}
value={formState.isDisabled}
onChange={(e) => handleInputChange('isDisabled', e.target.checked)}
errorText={errors.isDisabled}
/>
<h3 className={'col-span-12'}>Contacts:</h3>
<ul className={'col-span-12'}>
{formState.contacts
.map((contact) => (
<li key={contact} className={'grid grid-cols-12 gap-4 w-full pb-2'}>
<div className={'col-span-10'}>
{contact}
</div>
<ButtonComponent
colspan={2}
onClick={() => {
const updatedContacts = formState.contacts.filter(c => c !== contact)
handleInputChange('contacts', updatedContacts)
}}
>
<TrashIcon />
</ButtonComponent>
</li>
))}
</ul>
<TextBoxComponent
colspan={10}
label={'New Contact'}
value={formState.contact}
onChange={(e) => {
if (formState.contacts.includes(e.target.value))
return
handleInputChange('contact', e.target.value)
}}
placeholder={'Add contact'}
type={'text'}
errorText={errors.contact}
/>
<FieldContainer colspan={2}>
<ButtonComponent
onClick={() => {
handleInputChange('contacts', [...formState.contacts, formState.contact])
handleInputChange('contact', '')
}}
disabled={formState.contact.trim() === ''}
>
<PlusIcon />
</ButtonComponent>
</FieldContainer>
<h3 className={'col-span-12'}>Hostnames:</h3>
<ul className={'col-span-12'}>
{formState.hostnames.map((hostname) => (
<li key={hostname.hostname} className={'grid grid-cols-12 gap-4 w-full'}>
<span className={'col-span-10'}>{hostname.hostname}</span>
<ButtonComponent
colspan={2}
onClick={() => {
const updatedHostnames = formState.hostnames.filter(h => h !== hostname)
handleInputChange('hostnames', updatedHostnames)
}}
>
<TrashIcon />
</ButtonComponent>
</li>
))}
</ul>
<TextBoxComponent
colspan={10}
label={'New Hostname'}
value={formState.hostname}
onChange={(e) => {
if (formState.hostnames.find(h => h.hostname === e.target.value))
return
handleInputChange('hostname', e.target.value)
}}
placeholder={'Add hostname'}
type={'text'}
errorText={errors.hostname}
/>
<FieldContainer colspan={2}>
<ButtonComponent
onClick={() => {
handleInputChange('hostnames', [...formState.hostnames, formState.hostname])
handleInputChange('hostname', '')
}}
disabled={formState.hostname.trim() === ''}
>
<PlusIcon />
</ButtonComponent>
</FieldContainer>
</div>
</FormContent>
<FormFooter

View File

@ -137,13 +137,14 @@ const Home: FC = () => {
: 'Not Upcoming'}
</span>
</span>
<CheckBoxComponent
colspan={3}
value={hostname.isDisabled}
label={'Disabled'}
disabled={true}
/>
<span className={'col-span-3'}>
<label className={'mr-2'}>Disabled:</label>
<input
type={'checkbox'}
checked={hostname.isDisabled}
disabled={true}
/>
</span>
</li>
))}
</ul>

View File

@ -1,7 +0,0 @@
import { PatchOperation } from './PatchOperation'
export interface PatchAction<T> {
op: PatchOperation // Enum for operation type
index?: number // Index for the operation (for arrays/lists)
value?: T // Value for the operation
}

View File

@ -1,6 +1,22 @@
export enum PatchOperation {
Add,
Remove,
Replace,
None
/// <summary>
/// When you need to set or replace a normal field
/// </summary>
SetField,
/// <summary>
/// When you need to set a normal field to null
/// </summary>
RemoveField,
/// <summary>
/// When you need to add an item to a collection
/// </summary>
AddToCollection,
/// <summary>
/// When you need to remove an item from a collection
/// </summary>
RemoveFromCollection,
}

View File

@ -0,0 +1,14 @@
import z, { object, record, string } from 'zod'
import { RequestModelBase, RequestModelBaseSchema } from './RequestModelBase'
import { PatchOperation } from './PatchOperation'
export interface PatchRequestModelBase extends RequestModelBase {
operations?: { [key: string]: PatchOperation }
}
export const PatchRequestModelBaseSchema = RequestModelBaseSchema.and(
object({
operations: record(string(), z.enum(PatchOperation)).optional()
})
)

View File

@ -0,0 +1,9 @@
import { object, Schema } from 'zod'
export interface RequestModelBase {
[key: string]: unknown; // Add index signature
}
export const RequestModelBaseSchema: Schema<RequestModelBase> = object({
// Define the schema for the base request model
})

View File

@ -1,3 +0,0 @@
export interface ResponseModelBase {
[key: string]: unknown
}

View File

@ -1,9 +1,20 @@
import { PatchAction } from '@/models/PatchAction'
import { PatchHostnameRequest } from './PatchHostnameRequest'
import { PatchHostnameRequest, PatchHostnameRequestSchema } from './PatchHostnameRequest'
import { PatchRequestModelBase, PatchRequestModelBaseSchema } from '../../../PatchRequestModelBase'
import { array, boolean, object, Schema, string } from 'zod'
export interface PatchAccountRequest {
description?: PatchAction<string>
isDisabled?: PatchAction<boolean>
contacts?: PatchAction<string>[]
export interface PatchAccountRequest extends PatchRequestModelBase {
description?: string
isDisabled?: boolean
contacts?: string[]
hostnames?: PatchHostnameRequest[]
}
export const PatchAccountRequestSchema: Schema<PatchAccountRequest> = PatchRequestModelBaseSchema.and(
object({
description: string().optional(),
isDisabled: boolean().optional(),
contacts: array(string()).optional(),
hostnames: array(PatchHostnameRequestSchema).optional()
})
)

View File

@ -1,6 +1,14 @@
import { PatchAction } from '@/models/PatchAction'
import { boolean, object, Schema, string } from 'zod'
import { PatchRequestModelBase, PatchRequestModelBaseSchema } from '../../../PatchRequestModelBase'
export interface PatchHostnameRequest {
hostname?: PatchAction<string>
isDisabled?: PatchAction<boolean>
export interface PatchHostnameRequest extends PatchRequestModelBase {
hostname?: string
isDisabled?: boolean
}
export const PatchHostnameRequestSchema: Schema<PatchHostnameRequest> = PatchRequestModelBaseSchema.and(
object({
hostname: string().optional(),
isDisabled: boolean().optional()
})
)

View File

@ -1,7 +1,8 @@
import z, { array, boolean, object, Schema, string } from 'zod'
import { ChallengeType } from '../../../../entities/ChallengeType'
import { RequestModelBase, RequestModelBaseSchema } from '../../../RequestModelBase'
export interface PostAccountRequest {
export interface PostAccountRequest extends RequestModelBase {
description: string
contacts: string[]
challengeType: string
@ -9,10 +10,12 @@ export interface PostAccountRequest {
isStaging: boolean
}
export const PostAccountRequestSchema: Schema<PostAccountRequest> = object({
description: string(),
contacts: array(string()),
hostnames: array(string()),
challengeType: z.enum(ChallengeType),
isStaging: boolean()
})
export const PostAccountRequestSchema: Schema<PostAccountRequest> = RequestModelBaseSchema.and(
object({
description: string(),
contacts: array(string()),
hostnames: array(string()),
challengeType: z.enum(ChallengeType),
isStaging: boolean()
})
)

View File

@ -1,7 +1,8 @@
import { CacheAccount } from '../../../../entities/CacheAccount'
import { ResponseModelBase } from '../../../ResponseModelBase'
import { GetHostnameResponse } from './GetHostnameResponse'
export interface GetAccountResponse {
export interface GetAccountResponse extends ResponseModelBase {
accountId: string
isDisabled: boolean
description: string

View File

@ -1,4 +1,6 @@
export interface GetHostnameResponse {
import { ResponseModelBase } from '../../../ResponseModelBase'
export interface GetHostnameResponse extends ResponseModelBase {
hostname: string
expires: string
isUpcomingExpire: boolean