diff --git a/client/src/api/core/OpenAPI.ts b/client/src/api/core/OpenAPI.ts index 77fa96a..03da729 100644 --- a/client/src/api/core/OpenAPI.ts +++ b/client/src/api/core/OpenAPI.ts @@ -29,4 +29,4 @@ export const OpenAPI: OpenAPIConfig = { PASSWORD: undefined, HEADERS: undefined, ENCODE_PATH: undefined, -}; +}; diff --git a/client/src/api/core/request.ts b/client/src/api/core/request.ts index 319b00a..6cf8280 100644 --- a/client/src/api/core/request.ts +++ b/client/src/api/core/request.ts @@ -2,285 +2,315 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import axios from 'axios'; -import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; -import FormData from 'form-data'; - -import { ApiError } from './ApiError'; -import type { ApiRequestOptions } from './ApiRequestOptions'; -import type { ApiResult } from './ApiResult'; -import { CancelablePromise } from './CancelablePromise'; -import type { OnCancel } from './CancelablePromise'; -import type { OpenAPIConfig } from './OpenAPI'; - -export const isDefined = (value: T | null | undefined): value is Exclude => { - return value !== undefined && value !== null; +import axios from "axios"; +import type { + AxiosError, + AxiosRequestConfig, + AxiosResponse, + AxiosInstance, +} from "axios"; +import FormData from "form-data"; + +import { ApiError } from "./ApiError"; +import type { ApiRequestOptions } from "./ApiRequestOptions"; +import type { ApiResult } from "./ApiResult"; +import { CancelablePromise } from "./CancelablePromise"; +import type { OnCancel } from "./CancelablePromise"; +import type { OpenAPIConfig } from "./OpenAPI"; + +export const isDefined = ( + value: T | null | undefined +): value is Exclude => { + return value !== undefined && value !== null; }; export const isString = (value: any): value is string => { - return typeof value === 'string'; + return typeof value === "string"; }; export const isStringWithValue = (value: any): value is string => { - return isString(value) && value !== ''; + return isString(value) && value !== ""; }; export const isBlob = (value: any): value is Blob => { - return ( - typeof value === 'object' && - typeof value.type === 'string' && - typeof value.stream === 'function' && - typeof value.arrayBuffer === 'function' && - typeof value.constructor === 'function' && - typeof value.constructor.name === 'string' && - /^(Blob|File)$/.test(value.constructor.name) && - /^(Blob|File)$/.test(value[Symbol.toStringTag]) - ); + return ( + typeof value === "object" && + typeof value.type === "string" && + typeof value.stream === "function" && + typeof value.arrayBuffer === "function" && + typeof value.constructor === "function" && + typeof value.constructor.name === "string" && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); }; export const isFormData = (value: any): value is FormData => { - return value instanceof FormData; + return value instanceof FormData; }; export const isSuccess = (status: number): boolean => { - return status >= 200 && status < 300; + return status >= 200 && status < 300; }; export const base64 = (str: string): string => { - try { - return btoa(str); - } catch (err) { - // @ts-ignore - return Buffer.from(str).toString('base64'); - } + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString("base64"); + } }; export const getQueryString = (params: Record): string => { - const qs: string[] = []; + const qs: string[] = []; - const append = (key: string, value: any) => { - qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); - }; + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; - const process = (key: string, value: any) => { - if (isDefined(value)) { - if (Array.isArray(value)) { - value.forEach(v => { - process(key, v); - }); - } else if (typeof value === 'object') { - Object.entries(value).forEach(([k, v]) => { - process(`${key}[${k}]`, v); - }); - } else { - append(key, value); - } - } - }; + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach((v) => { + process(key, v); + }); + } else if (typeof value === "object") { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; - Object.entries(params).forEach(([key, value]) => { - process(key, value); - }); + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); - if (qs.length > 0) { - return `?${qs.join('&')}`; - } + if (qs.length > 0) { + return `?${qs.join("&")}`; + } - return ''; + return ""; }; const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { - const encoder = config.ENCODE_PATH || encodeURI; - - const path = options.url - .replace('{api-version}', config.VERSION) - .replace(/{(.*?)}/g, (substring: string, group: string) => { - if (options.path?.hasOwnProperty(group)) { - return encoder(String(options.path[group])); - } - return substring; - }); + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace("{api-version}", config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); - const url = `${config.BASE}${path}`; - if (options.query) { - return `${url}${getQueryString(options.query)}`; - } - return url; + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; }; -export const getFormData = (options: ApiRequestOptions): FormData | undefined => { - if (options.formData) { - const formData = new FormData(); +export const getFormData = ( + options: ApiRequestOptions +): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); - const process = (key: string, value: any) => { - if (isString(value) || isBlob(value)) { - formData.append(key, value); - } else { - formData.append(key, JSON.stringify(value)); - } - }; + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; - Object.entries(options.formData) - .filter(([_, value]) => isDefined(value)) - .forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach(v => process(key, v)); - } else { - process(key, value); - } - }); - - return formData; - } - return undefined; + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((v) => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; }; type Resolver = (options: ApiRequestOptions) => Promise; -export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { - if (typeof resolver === 'function') { - return (resolver as Resolver)(options); - } - return resolver; +export const resolve = async ( + options: ApiRequestOptions, + resolver?: T | Resolver +): Promise => { + if (typeof resolver === "function") { + return (resolver as Resolver)(options); + } + return resolver; }; -export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise> => { - const [token, username, password, additionalHeaders] = await Promise.all([ - resolve(options, config.TOKEN), - resolve(options, config.USERNAME), - resolve(options, config.PASSWORD), - resolve(options, config.HEADERS), - ]); - - const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} - - const headers = Object.entries({ - Accept: 'application/json', - ...additionalHeaders, - ...options.headers, - ...formHeaders, - }) +export const getHeaders = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + formData?: FormData +): Promise> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const formHeaders = + (typeof formData?.getHeaders === "function" && formData?.getHeaders()) || + {}; + + const headers = Object.entries({ + Accept: "application/json", + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) .filter(([_, value]) => isDefined(value)) - .reduce((headers, [key, value]) => ({ + .reduce( + (headers, [key, value]) => ({ ...headers, [key]: String(value), - }), {} as Record); - - if (isStringWithValue(token)) { - headers['Authorization'] = `Bearer ${token}`; - } - - if (isStringWithValue(username) && isStringWithValue(password)) { - const credentials = base64(`${username}:${password}`); - headers['Authorization'] = `Basic ${credentials}`; - } + }), + {} as Record + ); - if (options.body !== undefined) { - if (options.mediaType) { - headers['Content-Type'] = options.mediaType; - } else if (isBlob(options.body)) { - headers['Content-Type'] = options.body.type || 'application/octet-stream'; - } else if (isString(options.body)) { - headers['Content-Type'] = 'text/plain'; - } else if (!isFormData(options.body)) { - headers['Content-Type'] = 'application/json'; - } + if (isStringWithValue(token)) { + headers["Authorization"] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers["Authorization"] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers["Content-Type"] = options.mediaType; + } else if (isBlob(options.body)) { + headers["Content-Type"] = options.body.type || "application/octet-stream"; + } else if (isString(options.body)) { + headers["Content-Type"] = "text/plain"; + } else if (!isFormData(options.body)) { + headers["Content-Type"] = "application/json"; } + } - return headers; + return headers; }; export const getRequestBody = (options: ApiRequestOptions): any => { - if (options.body) { - return options.body; - } - return undefined; + if (options.body) { + return options.body; + } + return undefined; }; export const sendRequest = async ( - config: OpenAPIConfig, - options: ApiRequestOptions, - url: string, - body: any, - formData: FormData | undefined, - headers: Record, - onCancel: OnCancel, - axiosClient: AxiosInstance + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Record, + onCancel: OnCancel, + axiosClient: AxiosInstance ): Promise> => { - const source = axios.CancelToken.source(); - - const requestConfig: AxiosRequestConfig = { - url, - headers, - data: body ?? formData, - method: options.method, - withCredentials: config.WITH_CREDENTIALS, - withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, - cancelToken: source.token, - }; - - onCancel(() => source.cancel('The user aborted a request.')); - - try { - return await axiosClient.request(requestConfig); - } catch (error) { - const axiosError = error as AxiosError; - if (axiosError.response) { - return axiosError.response; - } - throw error; + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, + headers, + data: body ?? formData, + method: options.method, + withCredentials: config.WITH_CREDENTIALS, + withXSRFToken: + config.CREDENTIALS === "include" ? config.WITH_CREDENTIALS : false, + cancelToken: source.token, + }; + + onCancel(() => source.cancel("The user aborted a request.")); + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError; + if (axiosError.response) { + return axiosError.response; } + throw error; + } }; -export const getResponseHeader = (response: AxiosResponse, responseHeader?: string): string | undefined => { - if (responseHeader) { - const content = response.headers[responseHeader]; - if (isString(content)) { - return content; - } +export const getResponseHeader = ( + response: AxiosResponse, + responseHeader?: string +): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; } - return undefined; + } + return undefined; }; export const getResponseBody = (response: AxiosResponse): any => { - if (response.status !== 204) { - return response.data; - } - return undefined; + if (response.status !== 204) { + return response.data; + } + return undefined; }; -export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { - const errors: Record = { - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 500: 'Internal Server Error', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - ...options.errors, - } - - const error = errors[result.status]; - if (error) { - throw new ApiError(options, result, error); - } - - if (!result.ok) { - const errorStatus = result.status ?? 'unknown'; - const errorStatusText = result.statusText ?? 'unknown'; - const errorBody = (() => { - try { - return JSON.stringify(result.body, null, 2); - } catch (e) { - return undefined; - } - })(); - - throw new ApiError(options, result, - `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` - ); - } +export const catchErrorCodes = ( + options: ApiRequestOptions, + result: ApiResult +): void => { + const errors: Record = { + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 500: "Internal Server Error", + 502: "Bad Gateway", + 503: "Service Unavailable", + ...options.errors, + }; + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? "unknown"; + const errorStatusText = result.statusText ?? "unknown"; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError( + options, + result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } }; /** @@ -291,33 +321,49 @@ export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): * @returns CancelablePromise * @throws ApiError */ -export const request = (config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise => { - return new CancelablePromise(async (resolve, reject, onCancel) => { - try { - const url = getUrl(config, options); - const formData = getFormData(options); - const body = getRequestBody(options); - const headers = await getHeaders(config, options, formData); - - if (!onCancel.isCancelled) { - const response = await sendRequest(config, options, url, body, formData, headers, onCancel, axiosClient); - const responseBody = getResponseBody(response); - const responseHeader = getResponseHeader(response, options.responseHeader); - - const result: ApiResult = { - url, - ok: isSuccess(response.status), - status: response.status, - statusText: response.statusText, - body: responseHeader ?? responseBody, - }; - - catchErrorCodes(options, result); - - resolve(result.body); - } - } catch (error) { - reject(error); - } - }); -}; +export const request = ( + config: OpenAPIConfig, + options: ApiRequestOptions, + axiosClient: AxiosInstance = axios +): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + const axiosOptions = { + url, + method: options.method, + data: body, + headers, + params: url.includes("?") ? undefined : options.query, + withCredentials: true, + }; + + if (!onCancel.isCancelled) { + const response = await axiosClient.request(axiosOptions); + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader( + response, + options.responseHeader + ); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/client/src/api/services/DefaultService.ts b/client/src/api/services/DefaultService.ts index 4ceef98..93bed92 100644 --- a/client/src/api/services/DefaultService.ts +++ b/client/src/api/services/DefaultService.ts @@ -137,6 +137,7 @@ export class DefaultService { /** * Delete an expense * @param id + * @param id * @returns void * @throws ApiError */ @@ -149,86 +150,6 @@ export class DefaultService { }, }); } - /** - * Get system income categories - * @returns any List of system income categories" - * @throws ApiError - */ - public static getIncomesCategories(): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/incomes/categories", - }); - } - /** - * Get user's custom income categories - * @returns any List of user's custom income categories - * @throws ApiError - */ - public static getIncomesCustomCategories(): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/incomes/custom-categories", - }); - } - /** - * Create a new custom income category - * @param requestBody - * @returns any Income category created - * @throws ApiError - */ - public static postIncomesCustomCategories(requestBody: { - category_name: string; - icon_url?: string; - }): CancelablePromise { - return __request(OpenAPI, { - method: "POST", - url: "/incomes/custom-categories", - body: requestBody, - mediaType: "application/json", - }); - } - /** - * Update an income category - * @param id - * @param requestBody - * @returns any Income category updated - * @throws ApiError - */ - public static putIncomesCustomCategories( - id: string, - requestBody: { - category_name: string; - icon_url?: string; - } - ): CancelablePromise { - return __request(OpenAPI, { - method: "PUT", - url: "/incomes/custom-categories/{id}", - path: { - id: id, - }, - body: requestBody, - mediaType: "application/json", - }); - } - /** - * Delete an income category - * @param id - * @returns void - * @throws ApiError - */ - public static deleteIncomesCustomCategories( - id: string - ): CancelablePromise { - return __request(OpenAPI, { - method: "DELETE", - url: "/incomes/custom-categories/{id}", - path: { - id: id, - }, - }); - } /** * List all incomes * @param start @@ -257,10 +178,9 @@ export class DefaultService { */ public static postIncomes(requestBody?: { amount: number; - date?: string; + date: string; source?: string; description?: string; - category_id?: number; }): CancelablePromise { return __request(OpenAPI, { method: "POST", @@ -313,6 +233,7 @@ export class DefaultService { /** * Delete an income entry * @param id + * @param id * @returns void * @throws ApiError */ diff --git a/client/src/components/IncomeForm.tsx b/client/src/components/IncomeForm.tsx index eac4750..8dff09c 100644 --- a/client/src/components/IncomeForm.tsx +++ b/client/src/components/IncomeForm.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from "react"; import type { Income, IncomeFormData } from "../types/Income"; -import { useIncomeCategories } from "../hooks/useIncomeCategories"; -import { Button, TextField, Select, Dialog, Skeleton } from "../ui"; +import { Button, TextField, Dialog, DatePicker } from "../ui"; import { useMascot } from "../hooks/useMascot"; +import { validateIncomeData } from "../utils/validators"; interface IncomeFormProps { income?: Income; @@ -17,7 +17,6 @@ export const IncomeForm: React.FC = ({ onCancel, open, }) => { - const { categories, loading: categoriesLoading } = useIncomeCategories(); const [saving, setSaving] = useState(false); const { showSuccess, showError } = useMascot(); @@ -28,9 +27,10 @@ export const IncomeForm: React.FC = ({ : new Date().toISOString().split("T")[0], source: income?.source || "", description: income?.description || "", - category_id: income?.category_id || 1, }); + const [errors, setErrors] = useState>({}); + useEffect(() => { if (income) { setFormData({ @@ -38,7 +38,6 @@ export const IncomeForm: React.FC = ({ date: new Date(income.date).toISOString().split("T")[0], source: income.source, description: income.description || "", - category_id: income.category_id, }); } else { setFormData({ @@ -46,15 +45,20 @@ export const IncomeForm: React.FC = ({ date: new Date().toISOString().split("T")[0], source: "", description: "", - category_id: 1, }); } + setErrors({}); }, [income, open]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - setSaving(true); + const validationErrors = validateIncomeData(formData); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + setSaving(true); try { await onSave(formData); showSuccess(); @@ -72,9 +76,16 @@ export const IncomeForm: React.FC = ({ ) => { setFormData((prev) => ({ ...prev, - [field]: - field === "amount" || field === "category_id" ? Number(value) : value, + [field]: field === "amount" ? Number(value) : value, })); + setErrors((prev) => ({ ...prev, [field]: "" })); + }; + + const handleDateChange = (date: Date | null) => { + if (date) { + const formattedDate = date.toISOString().split("T")[0]; + handleChange("date", formattedDate); + } }; return ( @@ -91,14 +102,17 @@ export const IncomeForm: React.FC = ({ onChange={(e) => handleChange("amount", e.target.value)} required fullWidth + min={0} + error={!!errors.amount} + helperText={errors.amount} /> - handleChange("date", e.target.value)} - fullWidth + size="medium" + required /> = ({ value={formData.source ?? ""} onChange={(e) => handleChange("source", e.target.value)} fullWidth + error={!!errors.source} + helperText={errors.source} /> - {categoriesLoading ? ( - - ) : ( -
- -