From 5c7bf224c3cb1b9a7d5570acc8cf52f22cfbcb18 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Wed, 5 Nov 2025 22:07:28 +0100 Subject: [PATCH] (feature): EditAccout form complete --- src/MaksIT.WebUI/src/forms/EditAccount.tsx | 223 +++++++++++++++++- src/MaksIT.WebUI/src/forms/Home.tsx | 15 +- src/MaksIT.WebUI/src/models/PatchAction.ts | 7 - src/MaksIT.WebUI/src/models/PatchOperation.ts | 26 +- .../src/models/PatchRequestModelBase.ts | 14 ++ .../src/models/RequestModelBase.ts | 9 + .../src/models/ResponseModelBase copy.ts | 3 - .../account/requests/PatchAccountRequest.ts | 23 +- .../account/requests/PatchHostnameRequest.ts | 16 +- .../account/requests/PostAccountRequest.ts | 19 +- .../account/responses/GetAccountResponse.ts | 3 +- .../account/responses/GetHostnameResponse.ts | 4 +- 12 files changed, 309 insertions(+), 53 deletions(-) delete mode 100644 src/MaksIT.WebUI/src/models/PatchAction.ts create mode 100644 src/MaksIT.WebUI/src/models/PatchRequestModelBase.ts create mode 100644 src/MaksIT.WebUI/src/models/RequestModelBase.ts delete mode 100644 src/MaksIT.WebUI/src/models/ResponseModelBase copy.ts diff --git a/src/MaksIT.WebUI/src/forms/EditAccount.tsx b/src/MaksIT.WebUI/src/forms/EditAccount.tsx index d8394c7..6f3046e 100644 --- a/src/MaksIT.WebUI/src/forms/EditAccount.tsx +++ b/src/MaksIT.WebUI/src/forms/EditAccount.tsx @@ -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 = 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 = 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 = (props) => { formState, errors, formIsValid, - handleInputChange + handleInputChange, + setInitialState } = useFormState({ initialState: RegisterFormProto(), validationSchema: RegisterFormSchema }) + const [backupState, setBackupState] = useState(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(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(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 = (props) => { handleInputChange('disabled', e.target.checked)} - errorText={errors.disabled} + value={formState.isDisabled} + onChange={(e) => handleInputChange('isDisabled', e.target.checked)} + errorText={errors.isDisabled} />

Contacts:

- +
    + {formState.contacts + .map((contact) => ( +
  • +
    + {contact} +
    + { + const updatedContacts = formState.contacts.filter(c => c !== contact) + handleInputChange('contacts', updatedContacts) + }} + + > + + +
  • + ))} +
+ { + if (formState.contacts.includes(e.target.value)) + return + + handleInputChange('contact', e.target.value) + }} + placeholder={'Add contact'} + type={'text'} + errorText={errors.contact} + /> + + { + handleInputChange('contacts', [...formState.contacts, formState.contact]) + handleInputChange('contact', '') + }} + disabled={formState.contact.trim() === ''} + > + + + +

Hostnames:

+
    + {formState.hostnames.map((hostname) => ( +
  • + {hostname.hostname} + { + const updatedHostnames = formState.hostnames.filter(h => h !== hostname) + handleInputChange('hostnames', updatedHostnames) + }} + > + + + +
  • + ))} +
+ { + if (formState.hostnames.find(h => h.hostname === e.target.value)) + return + + handleInputChange('hostname', e.target.value) + }} + placeholder={'Add hostname'} + type={'text'} + errorText={errors.hostname} + /> + + { + handleInputChange('hostnames', [...formState.hostnames, formState.hostname]) + handleInputChange('hostname', '') + }} + disabled={formState.hostname.trim() === ''} + > + + + { : 'Not Upcoming'} - - + + + + ))} diff --git a/src/MaksIT.WebUI/src/models/PatchAction.ts b/src/MaksIT.WebUI/src/models/PatchAction.ts deleted file mode 100644 index 545e2e8..0000000 --- a/src/MaksIT.WebUI/src/models/PatchAction.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { PatchOperation } from './PatchOperation' - -export interface PatchAction { - op: PatchOperation // Enum for operation type - index?: number // Index for the operation (for arrays/lists) - value?: T // Value for the operation -} diff --git a/src/MaksIT.WebUI/src/models/PatchOperation.ts b/src/MaksIT.WebUI/src/models/PatchOperation.ts index f8a0c1a..f7e3725 100644 --- a/src/MaksIT.WebUI/src/models/PatchOperation.ts +++ b/src/MaksIT.WebUI/src/models/PatchOperation.ts @@ -1,6 +1,22 @@ export enum PatchOperation { - Add, - Remove, - Replace, - None -} + + /// + /// When you need to set or replace a normal field + /// + SetField, + + /// + /// When you need to set a normal field to null + /// + RemoveField, + + /// + /// When you need to add an item to a collection + /// + AddToCollection, + + /// + /// When you need to remove an item from a collection + /// + RemoveFromCollection, +} \ No newline at end of file diff --git a/src/MaksIT.WebUI/src/models/PatchRequestModelBase.ts b/src/MaksIT.WebUI/src/models/PatchRequestModelBase.ts new file mode 100644 index 0000000..7fe0587 --- /dev/null +++ b/src/MaksIT.WebUI/src/models/PatchRequestModelBase.ts @@ -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() + }) +) \ No newline at end of file diff --git a/src/MaksIT.WebUI/src/models/RequestModelBase.ts b/src/MaksIT.WebUI/src/models/RequestModelBase.ts new file mode 100644 index 0000000..a214614 --- /dev/null +++ b/src/MaksIT.WebUI/src/models/RequestModelBase.ts @@ -0,0 +1,9 @@ +import { object, Schema } from 'zod' + +export interface RequestModelBase { + [key: string]: unknown; // Add index signature +} + +export const RequestModelBaseSchema: Schema = object({ + // Define the schema for the base request model +}) \ No newline at end of file diff --git a/src/MaksIT.WebUI/src/models/ResponseModelBase copy.ts b/src/MaksIT.WebUI/src/models/ResponseModelBase copy.ts deleted file mode 100644 index c24ddc8..0000000 --- a/src/MaksIT.WebUI/src/models/ResponseModelBase copy.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface ResponseModelBase { - [key: string]: unknown -} diff --git a/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PatchAccountRequest.ts b/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PatchAccountRequest.ts index de65dc3..12dd395 100644 --- a/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PatchAccountRequest.ts +++ b/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PatchAccountRequest.ts @@ -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 - isDisabled?: PatchAction - contacts?: PatchAction[] +export interface PatchAccountRequest extends PatchRequestModelBase { + description?: string + isDisabled?: boolean + contacts?: string[] hostnames?: PatchHostnameRequest[] } + +export const PatchAccountRequestSchema: Schema = PatchRequestModelBaseSchema.and( + object({ + description: string().optional(), + isDisabled: boolean().optional(), + contacts: array(string()).optional(), + hostnames: array(PatchHostnameRequestSchema).optional() + }) +) + diff --git a/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PatchHostnameRequest.ts b/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PatchHostnameRequest.ts index 8096fb6..79f1ff7 100644 --- a/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PatchHostnameRequest.ts +++ b/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PatchHostnameRequest.ts @@ -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 - isDisabled?: PatchAction +export interface PatchHostnameRequest extends PatchRequestModelBase { + hostname?: string + isDisabled?: boolean } + +export const PatchHostnameRequestSchema: Schema = PatchRequestModelBaseSchema.and( + object({ + hostname: string().optional(), + isDisabled: boolean().optional() + }) +) diff --git a/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PostAccountRequest.ts b/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PostAccountRequest.ts index a467653..7a137f6 100644 --- a/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PostAccountRequest.ts +++ b/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PostAccountRequest.ts @@ -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 = object({ - description: string(), - contacts: array(string()), - hostnames: array(string()), - challengeType: z.enum(ChallengeType), - isStaging: boolean() -}) +export const PostAccountRequestSchema: Schema = RequestModelBaseSchema.and( + object({ + description: string(), + contacts: array(string()), + hostnames: array(string()), + challengeType: z.enum(ChallengeType), + isStaging: boolean() + }) +) \ No newline at end of file diff --git a/src/MaksIT.WebUI/src/models/letsEncryptServer/account/responses/GetAccountResponse.ts b/src/MaksIT.WebUI/src/models/letsEncryptServer/account/responses/GetAccountResponse.ts index ae52943..eec0354 100644 --- a/src/MaksIT.WebUI/src/models/letsEncryptServer/account/responses/GetAccountResponse.ts +++ b/src/MaksIT.WebUI/src/models/letsEncryptServer/account/responses/GetAccountResponse.ts @@ -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 diff --git a/src/MaksIT.WebUI/src/models/letsEncryptServer/account/responses/GetHostnameResponse.ts b/src/MaksIT.WebUI/src/models/letsEncryptServer/account/responses/GetHostnameResponse.ts index 967295e..2d6a829 100644 --- a/src/MaksIT.WebUI/src/models/letsEncryptServer/account/responses/GetHostnameResponse.ts +++ b/src/MaksIT.WebUI/src/models/letsEncryptServer/account/responses/GetHostnameResponse.ts @@ -1,4 +1,6 @@ -export interface GetHostnameResponse { +import { ResponseModelBase } from '../../../ResponseModelBase' + +export interface GetHostnameResponse extends ResponseModelBase { hostname: string expires: string isUpcomingExpire: boolean