proje-react-panel 1.9.0 → 1.11.0-beta.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.9.0",
3
+ "version": "1.11.0-beta.0",
4
4
  "type": "module",
5
5
  "description": "",
6
6
  "author": "SEFA DEMİR",
@@ -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,67 @@
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 { CellField } from '../../../components/list/CellField';
15
+ import { CellConfiguration } from '../../../decorators/list/Cell';
16
+
17
+ // Tablo `table-layout: fixed` kullaniyor: kolon genisligi sabit ve tasan metin
18
+ // ellipsis'e dusuyor. Kesilen degeri okumanin tek yolu hucrenin tooltip'i.
19
+ const cell = (over: Partial<CellConfiguration> = {}): CellConfiguration => ({
20
+ name: 'description',
21
+ type: 'string',
22
+ ...over,
23
+ });
24
+
25
+ const titleOf = (configuration: CellConfiguration, item: Record<string, unknown>) =>
26
+ render(
27
+ // Link hucresi react-router'in <Link>'ini basiyor, o da router context'i istiyor.
28
+ <MemoryRouter>
29
+ <table>
30
+ <tbody>
31
+ <tr>
32
+ <CellField configuration={configuration} item={item} />
33
+ </tr>
34
+ </tbody>
35
+ </table>
36
+ </MemoryRouter>
37
+ )
38
+ .container.querySelector('td')
39
+ ?.getAttribute('title');
40
+
41
+ describe("CellField — hucre tooltip'u", () => {
42
+ it('metin hucresinde tam degeri title olarak verir', () => {
43
+ const uzun = 'a'.repeat(400);
44
+
45
+ expect(titleOf(cell(), { description: uzun })).toBe(uzun);
46
+ });
47
+
48
+ it('metin olmayan degerleri yaziya cevirir', () => {
49
+ expect(titleOf(cell({ type: 'number' }), { description: 42 })).toBe('42');
50
+ });
51
+
52
+ // Bu hucreler metin degil element basiyor; ham url'i tooltip yapmak
53
+ // kullaniciya bir sey anlatmiyor, yalnizca gurultu olurdu.
54
+ it.each(['image', 'download', 'link'] as const)('%s hucresinde title vermez', type => {
55
+ expect(titleOf(cell({ type, name: 'thumbnailUrl' }), { thumbnailUrl: '/uploads/x.png' })).toBe(
56
+ null
57
+ );
58
+ });
59
+
60
+ it.each([
61
+ ['null', null],
62
+ ['undefined', undefined],
63
+ ['nesne', { a: 1 }],
64
+ ])('%s degerde title vermez', (_ad, value) => {
65
+ expect(titleOf(cell(), { description: value })).toBe(null);
66
+ });
67
+ });
@@ -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
+ }
@@ -48,6 +48,7 @@ export function CellField<T extends AnyClass>({
48
48
  return (
49
49
  <td
50
50
  key={configuration.name}
51
+ title={cellTitle(configuration, item)}
51
52
  style={{
52
53
  minWidth,
53
54
  width,
@@ -57,3 +58,26 @@ export function CellField<T extends AnyClass>({
57
58
  </td>
58
59
  );
59
60
  }
61
+
62
+ /**
63
+ * Sabit kolon genisliginde uzun metin ellipsis'e dusuyor ve tam degeri okumanin
64
+ * tek yolu hover kaliyor. Gorsel/indirme/link hucrelerinde metin degil element
65
+ * basildigi icin ham degeri tooltip yapmak yalnizca gurultu olurdu.
66
+ */
67
+ function cellTitle<T extends AnyClass>(
68
+ configuration: CellConfiguration,
69
+ item: T
70
+ ): string | undefined {
71
+ if (
72
+ configuration.type === 'image' ||
73
+ configuration.type === 'download' ||
74
+ configuration.type === 'link'
75
+ ) {
76
+ return undefined;
77
+ }
78
+ const value = item[configuration.name];
79
+ if (value === null || value === undefined || typeof value === 'object') {
80
+ return undefined;
81
+ }
82
+ return String(value);
83
+ }
@@ -11,6 +11,13 @@ import { CellField } from './CellField';
11
11
  import { CellConfiguration } from '../../decorators/list/Cell';
12
12
  import { useAppStore } from '../../store/store';
13
13
 
14
+ /**
15
+ * Otomatik duzende bu deger yalnizca bir ipucuydu; tarayici "Actions" etiketi
16
+ * sigsin diye kolonu kendisi buyutuyordu. `table-layout: fixed` ile genislik
17
+ * birebir uygulaniyor, dolayisiyla eski 30px etiketi kirpiyor.
18
+ */
19
+ const ACTIONS_COLUMN_WIDTH = '120px';
20
+
14
21
  interface DatagridProps<T extends AnyClass> {
15
22
  data: T[];
16
23
  listPageMeta: ListPageMeta<T>;
@@ -49,7 +56,9 @@ export function Datagrid<T extends AnyClass>({
49
56
  {(listActions?.details ||
50
57
  listActions?.edit ||
51
58
  listActions?.delete ||
52
- listActions?.customActions?.length) && <th style={{ width: '30px' }}>Actions</th>}
59
+ listActions?.customActions?.length) && (
60
+ <th style={{ width: ACTIONS_COLUMN_WIDTH }}>Actions</th>
61
+ )}
53
62
  </tr>
54
63
  </thead>
55
64
  <tbody>
@@ -80,7 +89,7 @@ export function Datagrid<T extends AnyClass>({
80
89
  );
81
90
  })}
82
91
  {(listCells?.details || listCells?.edit || listCells?.delete) && (
83
- <td style={{ width: '30px' }}>
92
+ <td className="util-cell-actions-cell" style={{ width: ACTIONS_COLUMN_WIDTH }}>
84
93
  <div className="util-cell-actions">
85
94
  <p className="util-cell-actions-label">
86
95
  Actions <DownArrowIcon className="icon icon-down" />
@@ -14,11 +14,14 @@ export function ImageCell({ item, configuration }: ImageCellProps) {
14
14
  if (!value) return <>-</>;
15
15
 
16
16
  return (
17
+ // maxWidth sart: sabit tablo duzeninde kolon 100px'ten dar kalabiliyor ve
18
+ // hucre tasmayi kirptigi icin gorsel sessizce yarim gorunurdu. Kucultmek
19
+ // kirpmaktan iyi; objectFit: contain oranı koruyor.
17
20
  <img
18
21
  width={100}
19
22
  height={100}
20
23
  src={imageConfiguration.baseUrl + value}
21
- style={{ objectFit: 'contain' }}
24
+ style={{ objectFit: 'contain', maxWidth: '100%' }}
22
25
  alt=""
23
26
  />
24
27
  );
@@ -23,7 +23,16 @@ export interface CellOptions {
23
23
  placeHolder?: string;
24
24
  filter?: Filter | StaticSelectFilter;
25
25
  style?: {
26
+ /**
27
+ * @deprecated Tablo `table-layout: fixed` kullaniyor; sabit duzende hucre
28
+ * `min-width`'i kolon genisligini etkilemiyor (CSS 2.1 17.5.2.1). Bunun
29
+ * yerine `width` verin.
30
+ */
26
31
  minWidth?: string;
32
+ /**
33
+ * Kolon genisligi. Sabit duzende birebir uygulanir; yuzde vermek tabloyu
34
+ * konteynere sigdirir, px vermek toplam konteyneri asarsa yatay scroll acar.
35
+ */
27
36
  width?: string;
28
37
  };
29
38
  }
@@ -86,8 +86,17 @@ $datagrid-height: calc(100vh - #{$header-height} - #{$footer-height});
86
86
  @include custom-scrollbar;
87
87
  }
88
88
 
89
+ // `width` sart: `table-layout: fixed`, genislik `auto` kaldigi surece devreye
90
+ // girmiyor (CSS 2.1 17.5.2.1) ve tarayici otomatik duzene doner. Burada eskiden
91
+ // sadece `min-width: 100%` vardi, yani sabit duzen hic calismiyordu: nowrap
92
+ // hucreler kolonu icerik kadar sisiriyor, tablo konteyneri asiyor ve uzun
93
+ // metin tasiyan her liste yatay scroll aciyordu.
94
+ //
95
+ // Kolonlarina px genislik veren tuketiciler genis tabloyu kaybetmiyor: sabit
96
+ // duzende kullanilan tablo genisligi, belirtilen kolon genisliklerinin toplami
97
+ // ile bu %100 degerinin buyugudur.
89
98
  .datagrid-table {
90
- min-width: 100%;
99
+ width: 100%;
91
100
  table-layout: fixed;
92
101
  border-collapse: collapse;
93
102
  position: relative;
@@ -97,10 +106,19 @@ $datagrid-height: calc(100vh - #{$header-height} - #{$footer-height});
97
106
  padding: 12px 16px;
98
107
  text-align: left;
99
108
  border-bottom: 1px solid var(--prp-border-primary);
109
+ // `text-overflow` yalnizca tasma kirpildiginda is goruyor; `overflow`
110
+ // olmadan ellipsis hicbir zaman cizilmiyordu.
111
+ overflow: hidden;
100
112
  text-overflow: ellipsis;
101
113
  white-space: nowrap;
102
114
  }
103
115
 
116
+ // Actions hucresindeki acilir liste `position: absolute`; kirpilirsa menu
117
+ // gorunmez oluyor. Kirpma sadece metin kolonlari icin gerekli.
118
+ .util-cell-actions-cell {
119
+ overflow: visible;
120
+ }
121
+
104
122
  th {
105
123
  color: var(--prp-text-primary);
106
124
  background-color: var(--prp-bg-tertiary);