proje-react-panel 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proje-react-panel",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "type": "module",
5
5
  "description": "",
6
6
  "author": "SEFA DEMİR",
@@ -73,7 +73,7 @@
73
73
  "axios": ">=1.0.0",
74
74
  "react": ">=19.0.0",
75
75
  "react-hook-form": ">=7.54.2",
76
- "react-router": "7.3.0",
76
+ "react-router": "^7.3.0",
77
77
  "react-select": "^5.10.1",
78
78
  "use-sync-external-store": ">=1.4.0",
79
79
  "zustand": ">=5.0.3"
@@ -0,0 +1,193 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { TextDecoder, TextEncoder } from 'util';
5
+ // react-router reads TextEncoder at import time and jsdom has none — set it up before the
6
+ // imports below pull the router in.
7
+ Object.assign(globalThis, { TextEncoder, TextDecoder });
8
+
9
+ import 'reflect-metadata';
10
+ import React from 'react';
11
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals';
12
+ import { fireEvent, render, waitFor } from '@testing-library/react';
13
+ import { MemoryRouter } from 'react-router';
14
+ import { FormProvider, useForm, UseFormReturn } from 'react-hook-form';
15
+ import { plainToInstance, Type } from 'class-transformer';
16
+ import { getInputFields, Input } from '../../../decorators/form/Input';
17
+ import { Form, getFormConfiguration } from '../../../decorators/form/Form';
18
+ import { InnerForm } from '../../../components/form/InnerForm';
19
+ import { AnyClass } from '../../../types/AnyClass';
20
+
21
+ // react-toastify writes to a container that is not mounted here; the calls are noise for
22
+ // this test, not the subject of it.
23
+ jest.mock('react-toastify', () => ({
24
+ toast: { success: jest.fn(), error: jest.fn() },
25
+ }));
26
+
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ const submitted = jest.fn<(data: any) => Promise<any>>();
29
+ const onSubmit = async (data: unknown) => {
30
+ submitted(data);
31
+ return {};
32
+ };
33
+
34
+ @Form({ onSubmit })
35
+ class NumberForm {
36
+ @Input({ label: 'Name' })
37
+ name: string;
38
+
39
+ @Input({ inputType: 'number', label: 'Amount' })
40
+ amount: number;
41
+
42
+ @Input({ inputType: 'number', label: 'Discount' })
43
+ discount: number;
44
+
45
+ @Input({ inputType: 'date', label: 'Starts at' })
46
+ startsAt: string;
47
+
48
+ @Input({ type: 'hidden', inputType: 'number' })
49
+ id: number;
50
+ }
51
+
52
+ @Form({ onSubmit })
53
+ class DefaultedNumberForm {
54
+ @Input({ inputType: 'number', label: 'Amount', defaultValue: '3' })
55
+ amount: number;
56
+ }
57
+
58
+ // The model authors used to have to remember this; it must keep working now that the field
59
+ // already arrives as a number.
60
+ class TypedNumberModel {
61
+ @Type(() => Number)
62
+ amount: number;
63
+ }
64
+
65
+ function renderForm<T extends AnyClass>(model: new () => T) {
66
+ const inputs = getInputFields(model as never);
67
+ const formClass = getFormConfiguration(model as never);
68
+ let form: UseFormReturn | undefined;
69
+ // FormPage seeds useForm with the declared defaultValues, which are typed as strings.
70
+ const defaultValues = inputs.reduce(
71
+ (acc, input) => {
72
+ acc[input.name] = input.defaultValue;
73
+ return acc;
74
+ },
75
+ {} as Record<string, unknown>
76
+ );
77
+
78
+ function Harness() {
79
+ form = useForm({ defaultValues });
80
+ return (
81
+ <MemoryRouter>
82
+ <FormProvider {...form}>
83
+ <InnerForm inputs={inputs} formClass={formClass} />
84
+ </FormProvider>
85
+ </MemoryRouter>
86
+ );
87
+ }
88
+
89
+ const utils = render(<Harness />);
90
+ return { ...utils, getForm: () => form! };
91
+ }
92
+
93
+ const fieldOf = (container: HTMLElement, name: string) =>
94
+ container.querySelector(`[name="${name}"]`) as HTMLInputElement;
95
+
96
+ const submitForm = async (container: HTMLElement) => {
97
+ fireEvent.submit(container.querySelector('form')!);
98
+ await waitFor(() => expect(submitted).toHaveBeenCalled());
99
+ return submitted.mock.calls[submitted.mock.calls.length - 1][0];
100
+ };
101
+
102
+ describe('number and date fields in the submitted body', () => {
103
+ beforeEach(() => {
104
+ submitted.mockClear();
105
+ });
106
+
107
+ it('sends a filled number input as a number, not a string', async () => {
108
+ const { container } = renderForm(NumberForm);
109
+
110
+ fireEvent.change(fieldOf(container, 'amount'), { target: { value: '42' } });
111
+ const body = await submitForm(container);
112
+
113
+ expect(typeof body.amount).toBe('number');
114
+ expect(body.amount).toBe(42);
115
+ });
116
+
117
+ it('sends an empty number input as undefined, so it drops out of the JSON body', async () => {
118
+ const { container } = renderForm(NumberForm);
119
+
120
+ fireEvent.change(fieldOf(container, 'amount'), { target: { value: '7' } });
121
+ // 'discount' is never touched, 'amount' is filled and then cleared again.
122
+ fireEvent.change(fieldOf(container, 'amount'), { target: { value: '' } });
123
+ const body = await submitForm(container);
124
+
125
+ expect(body.amount).toBeUndefined();
126
+ expect(body.discount).toBeUndefined();
127
+ // Not 0 and not NaN: NaN would survive @IsOptional() and fail validation, and JSON.stringify
128
+ // would turn it into null.
129
+ expect(JSON.parse(JSON.stringify(body))).not.toHaveProperty('amount');
130
+ });
131
+
132
+ it('sends a hidden numeric field as a number too', async () => {
133
+ const { container, getForm } = renderForm(NumberForm);
134
+
135
+ // How a hidden id is really filled: getDetailsData loads the record and calls setValue.
136
+ // Typing into it is not a path — React fires no onChange for type="hidden".
137
+ getForm().setValue('id', '17');
138
+ const body = await submitForm(container);
139
+
140
+ expect(typeof body.id).toBe('number');
141
+ expect(body.id).toBe(17);
142
+ });
143
+
144
+ it('converts the string defaultValue of a number field', async () => {
145
+ const { container } = renderForm(DefaultedNumberForm);
146
+ const body = await submitForm(container);
147
+
148
+ expect(typeof body.amount).toBe('number');
149
+ expect(body.amount).toBe(3);
150
+ });
151
+
152
+ it('leaves text fields alone', async () => {
153
+ const { container } = renderForm(NumberForm);
154
+
155
+ fireEvent.change(fieldOf(container, 'name'), { target: { value: '5' } });
156
+ const body = await submitForm(container);
157
+
158
+ expect(typeof body.name).toBe('string');
159
+ expect(body.name).toBe('5');
160
+ });
161
+
162
+ it('keeps a date field as the ISO string the input produces', async () => {
163
+ const { container } = renderForm(NumberForm);
164
+
165
+ fireEvent.change(fieldOf(container, 'startsAt'), { target: { value: '2026-08-16' } });
166
+ const body = await submitForm(container);
167
+
168
+ expect(typeof body.startsAt).toBe('string');
169
+ expect(body.startsAt).toBe('2026-08-16');
170
+ expect(body.startsAt instanceof Date).toBe(false);
171
+ });
172
+
173
+ it('passes a value set programmatically through untouched', async () => {
174
+ const { container, getForm } = renderForm(NumberForm);
175
+
176
+ // What getDetailsData does when it loads a record: the value is already a number.
177
+ getForm().setValue('amount', 12);
178
+ const body = await submitForm(container);
179
+
180
+ expect(body.amount).toBe(12);
181
+ });
182
+
183
+ it('still works for a model that declares @Type(() => Number)', async () => {
184
+ const { container } = renderForm(NumberForm);
185
+
186
+ fireEvent.change(fieldOf(container, 'amount'), { target: { value: '42' } });
187
+ const body = await submitForm(container);
188
+ const instance = plainToInstance(TypedNumberModel, { amount: body.amount });
189
+
190
+ expect(instance.amount).toBe(42);
191
+ expect(typeof instance.amount).toBe('number');
192
+ });
193
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { TextDecoder, TextEncoder } from 'util';
5
+ // react-router reads TextEncoder at import time and jsdom has none — set it up before the
6
+ // imports below pull the router in.
7
+ Object.assign(globalThis, { TextEncoder, TextDecoder });
8
+
9
+ import 'reflect-metadata';
10
+ import React from 'react';
11
+ import { describe, expect, it } from '@jest/globals';
12
+ import { render } from '@testing-library/react';
13
+ import { MemoryRouter } from 'react-router';
14
+ import { LinkCell } from '../../../components/list/cells/LinkCell';
15
+ import { CellConfiguration } from '../../../decorators/list/Cell';
16
+
17
+ // `@LinkCell({ path: '/users/:id' })` satirin verisiyle dolmalı. Ikame bozulursa
18
+ // hucredeki her open/edit linki gorunuste calisir ama tiklaninca 404 verir —
19
+ // tarayicida fark edilmesi zor, o yuzden burada kilitli.
20
+ const hrefOf = (configuration: CellConfiguration, item: Record<string, unknown>) =>
21
+ render(
22
+ <MemoryRouter>
23
+ <table>
24
+ <tbody>
25
+ <tr>
26
+ <td>
27
+ <LinkCell configuration={configuration} item={item} />
28
+ </td>
29
+ </tr>
30
+ </tbody>
31
+ </table>
32
+ </MemoryRouter>
33
+ )
34
+ .container.querySelector('a')
35
+ ?.getAttribute('href');
36
+
37
+ const linkCell = (over: Partial<CellConfiguration> = {}): CellConfiguration =>
38
+ ({
39
+ name: 'open',
40
+ type: 'link',
41
+ placeHolder: 'open',
42
+ ...over,
43
+ }) as CellConfiguration;
44
+
45
+ describe('LinkCell path parameters', () => {
46
+ it('substitutes a single :param from the row', () => {
47
+ expect(hrefOf(linkCell({ path: '/users/:id' } as never), { id: 42 })).toBe(
48
+ '/users/42'
49
+ );
50
+ });
51
+
52
+ it('substitutes every :param in the path', () => {
53
+ expect(
54
+ hrefOf(linkCell({ path: '/workspaces/:workspaceId/users/:id' } as never), {
55
+ workspaceId: 'ws-1',
56
+ id: 7,
57
+ })
58
+ ).toBe('/workspaces/ws-1/users/7');
59
+ });
60
+
61
+ it('leaves an unmatched :param in place instead of writing undefined', () => {
62
+ expect(hrefOf(linkCell({ path: '/users/:missing' } as never), { id: 1 })).toBe(
63
+ '/users/:missing'
64
+ );
65
+ });
66
+
67
+ it('passes a path with no parameters through untouched', () => {
68
+ expect(hrefOf(linkCell({ path: '/users' } as never), { id: 1 })).toBe('/users');
69
+ });
70
+ });
@@ -64,7 +64,7 @@ export function update<T>(endpoint: string, key = 'id'): OnSubmitFN<T> {
64
64
  export function updateFormData<T>(endpoint: string, key = 'id'): OnSubmitFN<T> {
65
65
  return async (data: T | FormData): Promise<T> => {
66
66
  const axiosInstance = getAxiosInstance();
67
- const id = (data as any)[key];
67
+ const id = (data as Record<string, unknown>)[key];
68
68
  const response = await axiosInstance.put<T>(`/${endpoint}/${id}`, data, {
69
69
  headers: {
70
70
  'Content-Type': 'multipart/form-data',
@@ -89,7 +89,7 @@ export function remove<T>(endpoint: string, key = 'id'): (data: T) => Promise<vo
89
89
  const id = (data as any)[key];
90
90
  await axiosInstance
91
91
  .delete<T>(`/${endpoint}/${id}`)
92
- .then((res: any) => res.data)
92
+ .then(res => res.data)
93
93
  .catch((err: AxiosError) => {
94
94
  const messageError = err.response?.data as { message: string };
95
95
  if (messageError?.message) {
@@ -6,6 +6,7 @@ import { Checkbox } from './Checkbox';
6
6
  import { Label } from './Label';
7
7
  import { Select } from './Select';
8
8
  import { CustomField } from './CustomField';
9
+ import { getFieldRegisterOptions } from './registerOptions';
9
10
  //NOTE: safe to import statically — RichTextField pulls the optional @tiptap/* peers in itself,
10
11
  // through a dynamic import, only once a richtext field is actually rendered.
11
12
  import { RichTextField } from './RichTextField';
@@ -94,7 +95,11 @@ export function FormField({ input, register, error, baseName }: FormFieldProps)
94
95
  return <CustomField input={input} fieldName={fieldName} error={error?.message} />;
95
96
  case 'input': {
96
97
  return (
97
- <input type={input.inputType} {...register(fieldName)} placeholder={input.placeholder} />
98
+ <input
99
+ type={input.inputType}
100
+ {...register(fieldName, getFieldRegisterOptions(input.inputType))}
101
+ placeholder={input.placeholder}
102
+ />
98
103
  );
99
104
  }
100
105
  case 'file-upload':
@@ -102,7 +107,11 @@ export function FormField({ input, register, error, baseName }: FormFieldProps)
102
107
  case 'checkbox':
103
108
  return <Checkbox fieldName={fieldName} input={input} />;
104
109
  case 'hidden':
105
- return <input type="hidden" {...register(fieldName)} />;
110
+ //NOTE: hidden fields go through the same conversion — a hidden id declared with
111
+ // inputType 'number' must reach onSubmit as a number, or it ends up in the URL as a string.
112
+ return (
113
+ <input type="hidden" {...register(fieldName, getFieldRegisterOptions(input.inputType))} />
114
+ );
106
115
  case 'nested':
107
116
  return <NestedFormFields fieldName={fieldName} input={input} register={register} />;
108
117
  default:
@@ -114,6 +114,12 @@ export function InnerForm<T extends AnyClass>({ inputs, formClass }: InnerFormPr
114
114
  : (() => {
115
115
  const formData = new FormData(formRef.current!);
116
116
  for (const key in dataForm) {
117
+ //NOTE: an empty number field is `undefined` since register() converts it;
118
+ // appending that would post the literal string 'undefined'. The DOM entry
119
+ // for the field is already in formData, so skipping it loses nothing.
120
+ if (dataForm[key] === undefined || dataForm[key] === null) {
121
+ continue;
122
+ }
117
123
  if (!formData.get(key)) {
118
124
  formData.append(key, dataForm[key]);
119
125
  }
@@ -0,0 +1,50 @@
1
+ import { UseFormRegister } from 'react-hook-form';
2
+ import { InputConfiguration } from '../../decorators/form/Input';
3
+
4
+ //TODO: any is not a good solution, we need to find a better way to do this
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ type FieldRegisterOptions = Parameters<UseFormRegister<any>>[1];
7
+
8
+ /**
9
+ * The DOM hands every <input> value over as a string, number inputs included. Deciding what
10
+ * lands in the form body is this module's job — not the model author's: a forgotten
11
+ * `@Type(() => Number)` used to turn into a client-side `@IsInt()` error under the field, or a
12
+ * backend 400, with no visible cause. Checkbox already closed the same trap through
13
+ * `setValueAs`, see Checkbox.tsx.
14
+ */
15
+ export function getFieldRegisterOptions(
16
+ inputType: InputConfiguration['inputType']
17
+ ): FieldRegisterOptions {
18
+ switch (inputType) {
19
+ case 'number':
20
+ return { setValueAs: toNumber };
21
+ case 'date':
22
+ //NOTE: DELIBERATELY not converted. A date field carries the native input's 'YYYY-MM-DD'
23
+ // string all the way to onSubmit, and an empty one carries ''. Existing consumers validate
24
+ // it with @IsString()/@IsISO8601() and post it as-is, so handing them a Date object here
25
+ // would break them silently. `valueAsDate` is intentionally unused.
26
+ return undefined;
27
+ default:
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Empty means "no number given", so it becomes `undefined` rather than react-hook-form's own
34
+ * `valueAsNumber` result of NaN. NaN is neither null nor undefined, so `@IsOptional()` does not
35
+ * skip it and an untouched optional number field would start failing validation; `undefined` is
36
+ * skipped by `@IsOptional()`, dropped by JSON.stringify, and still fails a required `@IsInt()`
37
+ * with the error visible under the field. A non-empty value that is not a number is left to
38
+ * produce NaN on purpose — that is a real error and it must stay visible.
39
+ */
40
+ function toNumber(value: unknown): number | undefined {
41
+ if (value === '' || value === null || value === undefined) {
42
+ return undefined;
43
+ }
44
+ // Already numeric: a value pushed in by getDetailsData or form.setValue passes through
45
+ // untouched, so models that do declare @Type(() => Number) keep working.
46
+ if (typeof value === 'number') {
47
+ return value;
48
+ }
49
+ return Number(value);
50
+ }
@@ -8,12 +8,32 @@ interface LinkCellProps<T> {
8
8
  configuration: CellConfiguration;
9
9
  }
10
10
 
11
+ /**
12
+ * `@LinkCell({ path: '/users/:id' })` yazan model sahibi, satirin `id`'siyle dolmus
13
+ * bir adres bekliyor — router path'i zaten oyle okunuyor. Ikame edilmezse `:id`
14
+ * harfi harfine adrese giriyor ve hucredeki her open/edit linki 404'e cikiyor:
15
+ * gorunuste calisan, tiklaninca bozuk bir link. Eslesmeyen bir parametre oldugu
16
+ * gibi birakilir (silinmez) — boylece eksik alan gorunur kalir, sessizce
17
+ * `/users/undefined` uretilmez.
18
+ */
19
+ function resolvePath<T>(path: string, item: T): string {
20
+ return path.replace(/:(\w+)/g, (match, key: string) => {
21
+ const value = (item as Record<string, unknown>)[key];
22
+ return value != null ? String(value) : match;
23
+ });
24
+ }
25
+
11
26
  export function LinkCell<T>({ item, configuration }: LinkCellProps<T>) {
12
27
  const linkConfiguration = configuration as LinkCellConfiguration<T>;
13
28
  const value = item[configuration.name as keyof T] ?? 'Link';
14
29
 
15
30
  return (
16
- <Link to={linkConfiguration.path ?? linkConfiguration.url ?? ''}>
31
+ <Link
32
+ to={resolvePath(
33
+ linkConfiguration.path ?? linkConfiguration.url ?? '',
34
+ item
35
+ )}
36
+ >
17
37
  {linkConfiguration.onClick ? (
18
38
  <a
19
39
  className="util-cell-link"