proje-react-panel 1.8.0 → 1.10.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/README.md +1 -0
- package/dist/__tests__/components/form/FormGroups.test.d.ts +1 -0
- package/dist/__tests__/components/form/RichTextField.test.d.ts +4 -0
- package/dist/__tests__/components/list/CellField.test.d.ts +1 -0
- package/dist/__tests__/optionalTiptap.test.d.ts +4 -0
- package/dist/components/form/RichTextField.d.ts +7 -0
- package/dist/decorators/form/Form.d.ts +8 -0
- package/dist/decorators/form/Input.d.ts +5 -1
- package/dist/decorators/form/inputs/RichTextInput.d.ts +13 -0
- package/dist/decorators/list/Cell.d.ts +9 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.esm.js +2 -2
- package/how_to.md +150 -32
- package/jest.config.js +1 -0
- package/package.json +13 -1
- package/src/__tests__/components/form/FormGroups.test.tsx +194 -0
- package/src/__tests__/components/form/RichTextField.test.tsx +181 -0
- package/src/__tests__/components/list/CellField.test.tsx +67 -0
- package/src/__tests__/optionalTiptap.test.tsx +89 -0
- package/src/components/form/FormField.tsx +8 -1
- package/src/components/form/InnerForm.tsx +101 -19
- package/src/components/form/RichTextField.tsx +218 -0
- package/src/components/list/CellField.tsx +24 -0
- package/src/components/list/Datagrid.tsx +11 -2
- package/src/components/list/cells/ImageCell.tsx +4 -1
- package/src/decorators/form/Form.ts +11 -0
- package/src/decorators/form/Input.ts +6 -1
- package/src/decorators/form/inputs/RichTextInput.ts +38 -0
- package/src/decorators/list/Cell.ts +9 -0
- package/src/index.ts +8 -1
- package/src/styles/components/rich-text.scss +115 -0
- package/src/styles/form.scss +48 -0
- package/src/styles/index.scss +1 -0
- package/src/styles/list.scss +19 -1
- package/dist/components/DashboardContainer.d.ts +0 -7
- package/dist/components/DashboardGrid.d.ts +0 -9
- package/dist/components/DashboardItem.d.ts +0 -10
- package/dist/components/ThemeSwitcher.d.ts +0 -7
- package/dist/store/themeStore.d.ts +0 -23
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import 'reflect-metadata';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { TextDecoder, TextEncoder } from 'util';
|
|
7
|
+
import { beforeAll, describe, expect, it, jest } from '@jest/globals';
|
|
8
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
9
|
+
import { FormProvider, useForm } from 'react-hook-form';
|
|
10
|
+
import { Input, getInputFields } from '../decorators/form/Input';
|
|
11
|
+
import { RichTextInput } from '../decorators/form/inputs/RichTextInput';
|
|
12
|
+
import { FormField } from '../components/form/FormField';
|
|
13
|
+
|
|
14
|
+
// @tiptap/* is declared optional in peerDependenciesMeta, so a panel that never uses a richtext
|
|
15
|
+
// field must work with the packages absent. Both are made unresolvable here; the flags record
|
|
16
|
+
// whether anything reached for them at all.
|
|
17
|
+
let reactRequested = false;
|
|
18
|
+
let starterKitRequested = false;
|
|
19
|
+
|
|
20
|
+
jest.mock('@tiptap/react', () => {
|
|
21
|
+
reactRequested = true;
|
|
22
|
+
throw new Error("Cannot find module '@tiptap/react'");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
jest.mock('@tiptap/starter-kit', () => {
|
|
26
|
+
starterKitRequested = true;
|
|
27
|
+
throw new Error("Cannot find module '@tiptap/starter-kit'");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
class PlainForm {
|
|
31
|
+
@Input({ label: 'Name' })
|
|
32
|
+
name: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class ArticleForm {
|
|
36
|
+
@RichTextInput({ label: 'Body' })
|
|
37
|
+
body: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
function Harness({ model }: { model: any }) {
|
|
42
|
+
const form = useForm({ defaultValues: { name: '', body: '' } });
|
|
43
|
+
const inputs = getInputFields(model);
|
|
44
|
+
return (
|
|
45
|
+
<FormProvider {...form}>
|
|
46
|
+
{inputs.map(input => (
|
|
47
|
+
<FormField key={input.name} input={input} register={form.register} />
|
|
48
|
+
))}
|
|
49
|
+
</FormProvider>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('@tiptap as an optional peer dependency', () => {
|
|
54
|
+
beforeAll(() => {
|
|
55
|
+
// jsdom ships neither; react-router reads them at import time. Nothing to do with tiptap.
|
|
56
|
+
Object.assign(globalThis, { TextEncoder, TextDecoder });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('imports the whole library without @tiptap installed', async () => {
|
|
60
|
+
const library = await import('../index');
|
|
61
|
+
|
|
62
|
+
expect(typeof library.FormPage).toBe('function');
|
|
63
|
+
expect(typeof library.RichTextInput).toBe('function');
|
|
64
|
+
expect(typeof library.ListPage).toBe('function');
|
|
65
|
+
expect(reactRequested).toBe(false);
|
|
66
|
+
expect(starterKitRequested).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('renders the other field types without @tiptap installed', () => {
|
|
70
|
+
render(<Harness model={PlainForm} />);
|
|
71
|
+
|
|
72
|
+
expect(screen.getByText('Name:')).toBeTruthy();
|
|
73
|
+
expect(reactRequested).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('tells the developer what to install when a richtext field is actually used', async () => {
|
|
77
|
+
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
78
|
+
|
|
79
|
+
render(<Harness model={ArticleForm} />);
|
|
80
|
+
|
|
81
|
+
const message = await screen.findByRole('alert');
|
|
82
|
+
expect(message.textContent).toContain('@tiptap/react');
|
|
83
|
+
expect(message.textContent).toContain('@tiptap/starter-kit');
|
|
84
|
+
await waitFor(() => expect(reactRequested).toBe(true));
|
|
85
|
+
expect(consoleError).toHaveBeenCalled();
|
|
86
|
+
|
|
87
|
+
consoleError.mockRestore();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -6,6 +6,9 @@ import { Checkbox } from './Checkbox';
|
|
|
6
6
|
import { Label } from './Label';
|
|
7
7
|
import { Select } from './Select';
|
|
8
8
|
import { CustomField } from './CustomField';
|
|
9
|
+
//NOTE: safe to import statically — RichTextField pulls the optional @tiptap/* peers in itself,
|
|
10
|
+
// through a dynamic import, only once a richtext field is actually rendered.
|
|
11
|
+
import { RichTextField } from './RichTextField';
|
|
9
12
|
|
|
10
13
|
interface FormFieldProps {
|
|
11
14
|
input: InputConfiguration;
|
|
@@ -80,9 +83,13 @@ export function FormField({ input, register, error, baseName }: FormFieldProps)
|
|
|
80
83
|
const renderedField = useMemo(() => {
|
|
81
84
|
switch (input.type) {
|
|
82
85
|
case 'textarea':
|
|
83
|
-
return
|
|
86
|
+
return (
|
|
87
|
+
<textarea {...register(fieldName)} placeholder={input.placeholder} rows={input.rows} />
|
|
88
|
+
);
|
|
84
89
|
case 'select':
|
|
85
90
|
return <Select input={input} fieldName={fieldName} />;
|
|
91
|
+
case 'richtext':
|
|
92
|
+
return <RichTextField input={input} fieldName={fieldName} />;
|
|
86
93
|
case 'custom':
|
|
87
94
|
return <CustomField input={input} fieldName={fieldName} error={error?.message} />;
|
|
88
95
|
case 'input': {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import React, { useRef, useState } from 'react';
|
|
1
|
+
import React, { useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { useFormContext } from 'react-hook-form';
|
|
3
3
|
import { InputConfiguration } from '../../decorators/form/Input';
|
|
4
4
|
import { FormField } from './FormField';
|
|
5
5
|
import { useNavigate } from 'react-router';
|
|
6
|
-
import { FormConfiguration } from '../../decorators/form/Form';
|
|
6
|
+
import { FormConfiguration, FormGroup } from '../../decorators/form/Form';
|
|
7
7
|
import { AnyClass } from '../../types/AnyClass';
|
|
8
8
|
import { toast } from 'react-toastify';
|
|
9
9
|
|
|
@@ -12,6 +12,63 @@ interface InnerFormProps<T extends AnyClass> {
|
|
|
12
12
|
formClass: FormConfiguration<T>;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
interface GroupSection {
|
|
16
|
+
key: string;
|
|
17
|
+
label: string;
|
|
18
|
+
collapsible: boolean;
|
|
19
|
+
defaultCollapsed: boolean;
|
|
20
|
+
inputs: InputConfiguration[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Splits the inputs into the fields rendered straight into the form and the grouped sections.
|
|
25
|
+
* With no `group` on any field every input stays ungrouped, so the markup is byte-for-byte what
|
|
26
|
+
* it was before groups existed — no stray fieldset around existing panels' CSS.
|
|
27
|
+
*/
|
|
28
|
+
function splitIntoGroups(
|
|
29
|
+
inputs: InputConfiguration[],
|
|
30
|
+
groups?: FormGroup[]
|
|
31
|
+
): { ungrouped: InputConfiguration[]; sections: GroupSection[] } {
|
|
32
|
+
const ungrouped: InputConfiguration[] = [];
|
|
33
|
+
const inputsByGroup = new Map<string, InputConfiguration[]>();
|
|
34
|
+
const appearanceOrder: string[] = [];
|
|
35
|
+
|
|
36
|
+
inputs?.forEach(input => {
|
|
37
|
+
// Hidden fields carry no label and no layout; a fieldset around them would only add noise.
|
|
38
|
+
if (!input.group || input.type === 'hidden') {
|
|
39
|
+
ungrouped.push(input);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!inputsByGroup.has(input.group)) {
|
|
43
|
+
inputsByGroup.set(input.group, []);
|
|
44
|
+
appearanceOrder.push(input.group);
|
|
45
|
+
}
|
|
46
|
+
inputsByGroup.get(input.group)!.push(input);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const declared = groups ?? [];
|
|
50
|
+
const declaredKeys = declared.map(group => group.key);
|
|
51
|
+
const orderedKeys = [
|
|
52
|
+
...declaredKeys.filter(key => inputsByGroup.has(key)),
|
|
53
|
+
// A group nobody declared in @Form({ groups }) still renders — labelled with its own key,
|
|
54
|
+
// appended after the declared ones, in the order the fields introduced it.
|
|
55
|
+
...appearanceOrder.filter(key => !declaredKeys.includes(key)),
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const sections = orderedKeys.map(key => {
|
|
59
|
+
const declaration = declared.find(group => group.key === key);
|
|
60
|
+
return {
|
|
61
|
+
key,
|
|
62
|
+
label: declaration?.label ?? key,
|
|
63
|
+
collapsible: declaration?.collapsible ?? false,
|
|
64
|
+
defaultCollapsed: declaration?.defaultCollapsed ?? false,
|
|
65
|
+
inputs: inputsByGroup.get(key)!,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return { ungrouped, sections };
|
|
70
|
+
}
|
|
71
|
+
|
|
15
72
|
export function InnerForm<T extends AnyClass>({ inputs, formClass }: InnerFormProps<T>) {
|
|
16
73
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
17
74
|
//TODO: any is not a good solution, we need to find a better way to do this
|
|
@@ -19,6 +76,29 @@ export function InnerForm<T extends AnyClass>({ inputs, formClass }: InnerFormPr
|
|
|
19
76
|
const navigate = useNavigate();
|
|
20
77
|
const form = useFormContext<T>();
|
|
21
78
|
const loadingRef = useRef(false);
|
|
79
|
+
const { ungrouped, sections } = useMemo(
|
|
80
|
+
() => splitIntoGroups(inputs, formClass.groups),
|
|
81
|
+
[inputs, formClass.groups]
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const renderField = (input: InputConfiguration) => (
|
|
85
|
+
<FormField
|
|
86
|
+
key={input.name || ''}
|
|
87
|
+
input={input}
|
|
88
|
+
register={form.register}
|
|
89
|
+
error={
|
|
90
|
+
input.name
|
|
91
|
+
? {
|
|
92
|
+
message: (
|
|
93
|
+
form.formState.errors[input.name as keyof T] as {
|
|
94
|
+
message: string;
|
|
95
|
+
}
|
|
96
|
+
)?.message,
|
|
97
|
+
}
|
|
98
|
+
: undefined
|
|
99
|
+
}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
22
102
|
|
|
23
103
|
return (
|
|
24
104
|
<form
|
|
@@ -75,23 +155,25 @@ export function InnerForm<T extends AnyClass>({ inputs, formClass }: InnerFormPr
|
|
|
75
155
|
{errorMessage}
|
|
76
156
|
</div>
|
|
77
157
|
)}
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
158
|
+
{ungrouped.map(renderField)}
|
|
159
|
+
{sections.map(section => (
|
|
160
|
+
<fieldset
|
|
161
|
+
key={section.key}
|
|
162
|
+
data-group={section.key}
|
|
163
|
+
className={`form-group-section${section.collapsible ? ' form-group-section-collapsible' : ''}`}
|
|
164
|
+
>
|
|
165
|
+
{section.collapsible ? (
|
|
166
|
+
<details open={!section.defaultCollapsed}>
|
|
167
|
+
<summary className="form-group-summary">{section.label}</summary>
|
|
168
|
+
<div className="form-group-fields">{section.inputs.map(renderField)}</div>
|
|
169
|
+
</details>
|
|
170
|
+
) : (
|
|
171
|
+
<>
|
|
172
|
+
<legend className="form-group-legend">{section.label}</legend>
|
|
173
|
+
<div className="form-group-fields">{section.inputs.map(renderField)}</div>
|
|
174
|
+
</>
|
|
175
|
+
)}
|
|
176
|
+
</fieldset>
|
|
95
177
|
))}
|
|
96
178
|
<button type="submit" className="submit-button">
|
|
97
179
|
Submit
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useController, useFormContext } from 'react-hook-form';
|
|
3
|
+
import type { Editor } from '@tiptap/react';
|
|
4
|
+
import { InputConfiguration } from '../../decorators/form/Input';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_RICH_TEXT_TOOLBAR,
|
|
7
|
+
RichTextInputConfiguration,
|
|
8
|
+
RichTextToolbarItem,
|
|
9
|
+
} from '../../decorators/form/inputs/RichTextInput';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MIN_HEIGHT = 160;
|
|
12
|
+
|
|
13
|
+
export interface RichTextFieldProps {
|
|
14
|
+
input: InputConfiguration;
|
|
15
|
+
fieldName: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Tiptap {
|
|
19
|
+
react: typeof import('@tiptap/react');
|
|
20
|
+
starterKit: (typeof import('@tiptap/starter-kit'))['default'];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
//NOTE: @tiptap/* is an optional peer dependency, so it may only be reached through a dynamic
|
|
24
|
+
// import — a static one would put ~100kb (and a hard install requirement) on every panel,
|
|
25
|
+
// including the ones that never declare a richtext field. The promise is cached at module level
|
|
26
|
+
// so a form with several richtext fields loads the editor once.
|
|
27
|
+
let tiptapPromise: Promise<Tiptap> | null = null;
|
|
28
|
+
|
|
29
|
+
function loadTiptap(): Promise<Tiptap> {
|
|
30
|
+
if (!tiptapPromise) {
|
|
31
|
+
const pending = Promise.all([import('@tiptap/react'), import('@tiptap/starter-kit')]).then(
|
|
32
|
+
([react, starterKit]) => ({ react, starterKit: starterKit.default })
|
|
33
|
+
);
|
|
34
|
+
// A failed load is not cached: a chunk that failed to arrive should be retried on the next
|
|
35
|
+
// mount instead of leaving the field broken for the rest of the session.
|
|
36
|
+
pending.catch(() => {
|
|
37
|
+
tiptapPromise = null;
|
|
38
|
+
});
|
|
39
|
+
tiptapPromise = pending;
|
|
40
|
+
}
|
|
41
|
+
return tiptapPromise;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ToolbarButton {
|
|
45
|
+
label: string;
|
|
46
|
+
title: string;
|
|
47
|
+
isActive: (editor: Editor) => boolean;
|
|
48
|
+
run: (editor: Editor) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TOOLBAR_BUTTONS: Record<RichTextToolbarItem, ToolbarButton> = {
|
|
52
|
+
bold: {
|
|
53
|
+
label: 'B',
|
|
54
|
+
title: 'Bold',
|
|
55
|
+
isActive: editor => editor.isActive('bold'),
|
|
56
|
+
run: editor => editor.chain().focus().toggleBold().run(),
|
|
57
|
+
},
|
|
58
|
+
italic: {
|
|
59
|
+
label: 'I',
|
|
60
|
+
title: 'Italic',
|
|
61
|
+
isActive: editor => editor.isActive('italic'),
|
|
62
|
+
run: editor => editor.chain().focus().toggleItalic().run(),
|
|
63
|
+
},
|
|
64
|
+
heading: {
|
|
65
|
+
label: 'H2',
|
|
66
|
+
title: 'Heading',
|
|
67
|
+
isActive: editor => editor.isActive('heading', { level: 2 }),
|
|
68
|
+
run: editor => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
69
|
+
},
|
|
70
|
+
bulletList: {
|
|
71
|
+
label: '••',
|
|
72
|
+
title: 'Bullet list',
|
|
73
|
+
isActive: editor => editor.isActive('bulletList'),
|
|
74
|
+
run: editor => editor.chain().focus().toggleBulletList().run(),
|
|
75
|
+
},
|
|
76
|
+
orderedList: {
|
|
77
|
+
label: '1.',
|
|
78
|
+
title: 'Numbered list',
|
|
79
|
+
isActive: editor => editor.isActive('orderedList'),
|
|
80
|
+
run: editor => editor.chain().focus().toggleOrderedList().run(),
|
|
81
|
+
},
|
|
82
|
+
link: {
|
|
83
|
+
label: '🔗',
|
|
84
|
+
title: 'Link',
|
|
85
|
+
isActive: editor => editor.isActive('link'),
|
|
86
|
+
run: editor => {
|
|
87
|
+
const current = editor.getAttributes('link').href as string | undefined;
|
|
88
|
+
const url = window.prompt('Link URL', current ?? 'https://');
|
|
89
|
+
//NOTE: null is "cancelled", empty string is "remove the link".
|
|
90
|
+
if (url === null) return;
|
|
91
|
+
if (url === '') {
|
|
92
|
+
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export function RichTextField({ input, fieldName }: RichTextFieldProps) {
|
|
101
|
+
const [tiptap, setTiptap] = useState<Tiptap | null>(null);
|
|
102
|
+
const [loadFailed, setLoadFailed] = useState(false);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
let active = true;
|
|
106
|
+
loadTiptap().then(
|
|
107
|
+
modules => {
|
|
108
|
+
if (active) setTiptap(modules);
|
|
109
|
+
},
|
|
110
|
+
(error: unknown) => {
|
|
111
|
+
console.error(
|
|
112
|
+
"proje-react-panel: a 'richtext' input needs @tiptap/react and @tiptap/starter-kit installed.",
|
|
113
|
+
error
|
|
114
|
+
);
|
|
115
|
+
if (active) setLoadFailed(true);
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
return () => {
|
|
119
|
+
active = false;
|
|
120
|
+
};
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
if (loadFailed) {
|
|
124
|
+
return (
|
|
125
|
+
<div className="rich-text-missing" role="alert">
|
|
126
|
+
Rich text editor unavailable — install @tiptap/react and @tiptap/starter-kit.
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!tiptap) {
|
|
132
|
+
return <div className="rich-text-loading">Loading editor…</div>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return <RichTextEditor input={input} fieldName={fieldName} tiptap={tiptap} />;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function RichTextEditor({ input, fieldName, tiptap }: RichTextFieldProps & { tiptap: Tiptap }) {
|
|
139
|
+
const richInput = input as RichTextInputConfiguration;
|
|
140
|
+
// Hooks off a dynamically loaded module: safe because the module object is cached, so the
|
|
141
|
+
// hook identity never changes between renders of this component.
|
|
142
|
+
const { EditorContent, useEditor } = tiptap.react;
|
|
143
|
+
const { control } = useFormContext();
|
|
144
|
+
//NOTE: register() would bind the value to a DOM node; TipTap owns an uncontrolled
|
|
145
|
+
// contenteditable of its own, so the value goes through the controller instead.
|
|
146
|
+
const { field } = useController({ name: fieldName, control });
|
|
147
|
+
const toolbar = richInput.toolbar ?? DEFAULT_RICH_TEXT_TOOLBAR;
|
|
148
|
+
const incomingHtml = typeof field.value === 'string' ? field.value : '';
|
|
149
|
+
|
|
150
|
+
// The HTML the editor last emitted or received. The outside -> editor sync compares against
|
|
151
|
+
// this instead of the previous prop value: while the user types, react-hook-form hands our
|
|
152
|
+
// own HTML straight back, and calling setContent for that would jump the caret to the start.
|
|
153
|
+
const editorHtmlRef = useRef<string>(incomingHtml);
|
|
154
|
+
|
|
155
|
+
// onUpdate is captured when the editor is created; the ref keeps it pointed at the live handler.
|
|
156
|
+
const onChangeRef = useRef(field.onChange);
|
|
157
|
+
onChangeRef.current = field.onChange;
|
|
158
|
+
|
|
159
|
+
const editor = useEditor({
|
|
160
|
+
extensions: [tiptap.starterKit],
|
|
161
|
+
content: editorHtmlRef.current,
|
|
162
|
+
//NOTE: toolbar highlighting reads editor state, so the field has to re-render with it.
|
|
163
|
+
shouldRerenderOnTransaction: true,
|
|
164
|
+
onUpdate: ({ editor: updatedEditor }) => {
|
|
165
|
+
// An empty document still serializes to '<p></p>'; report it as empty so required
|
|
166
|
+
// validators (@IsNotEmpty and friends) see what the user actually sees.
|
|
167
|
+
const html = updatedEditor.isEmpty ? '' : updatedEditor.getHTML();
|
|
168
|
+
editorHtmlRef.current = html;
|
|
169
|
+
onChangeRef.current(html);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!editor) return;
|
|
175
|
+
// getDetailsData resolves after the form mounts, so the value can arrive late — but only
|
|
176
|
+
// replace the document when it really differs from what the editor already holds.
|
|
177
|
+
if (incomingHtml === editorHtmlRef.current) return;
|
|
178
|
+
editorHtmlRef.current = incomingHtml;
|
|
179
|
+
editor.commands.setContent(incomingHtml, { emitUpdate: false });
|
|
180
|
+
}, [editor, incomingHtml]);
|
|
181
|
+
|
|
182
|
+
const buttons = useMemo(
|
|
183
|
+
() => toolbar.map(item => ({ item, ...TOOLBAR_BUTTONS[item] })),
|
|
184
|
+
[toolbar]
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div className="rich-text-field">
|
|
189
|
+
<div className="rich-text-toolbar" role="toolbar" aria-label={`${input.label ?? fieldName}`}>
|
|
190
|
+
{buttons.map(button => {
|
|
191
|
+
const active = editor ? button.isActive(editor) : false;
|
|
192
|
+
return (
|
|
193
|
+
<button
|
|
194
|
+
key={button.item}
|
|
195
|
+
type="button"
|
|
196
|
+
title={button.title}
|
|
197
|
+
aria-label={button.title}
|
|
198
|
+
aria-pressed={active}
|
|
199
|
+
disabled={!editor}
|
|
200
|
+
className={`rich-text-toolbar-button${active ? ' is-active' : ''}`}
|
|
201
|
+
onClick={() => editor && button.run(editor)}
|
|
202
|
+
>
|
|
203
|
+
{button.label}
|
|
204
|
+
</button>
|
|
205
|
+
);
|
|
206
|
+
})}
|
|
207
|
+
</div>
|
|
208
|
+
<EditorContent
|
|
209
|
+
editor={editor}
|
|
210
|
+
id={fieldName}
|
|
211
|
+
data-testid={`rich-text-${fieldName}`}
|
|
212
|
+
data-placeholder={input.placeholder}
|
|
213
|
+
className={`rich-text-content${!editor || editor.isEmpty ? ' is-empty' : ''}`}
|
|
214
|
+
style={{ minHeight: `${richInput.minHeight ?? DEFAULT_MIN_HEIGHT}px` }}
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -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) &&
|
|
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:
|
|
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
|
);
|
|
@@ -5,12 +5,21 @@ import { GetDetailsDataFN } from '../details/Details';
|
|
|
5
5
|
const DETAILS_METADATA_KEY = 'DetailsMetaData';
|
|
6
6
|
export type OnSubmitFN<T, L = T> = (data: T | FormData) => Promise<L>;
|
|
7
7
|
|
|
8
|
+
export interface FormGroup {
|
|
9
|
+
key: string;
|
|
10
|
+
label: string;
|
|
11
|
+
collapsible?: boolean;
|
|
12
|
+
defaultCollapsed?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
interface FormOptions<T extends AnyClass, L = T> {
|
|
9
16
|
onSubmit: OnSubmitFN<T, L>;
|
|
10
17
|
onSubmitSuccess?: (data: L) => void;
|
|
11
18
|
getDetailsData?: GetDetailsDataFN<T>;
|
|
12
19
|
type?: 'json' | 'formData';
|
|
13
20
|
redirectSuccessUrl?: string;
|
|
21
|
+
//NOTE: declares the section headings and their order; fields opt in with @Input({ group }).
|
|
22
|
+
groups?: FormGroup[];
|
|
14
23
|
}
|
|
15
24
|
|
|
16
25
|
export interface FormConfiguration<T extends AnyClass, L = T> {
|
|
@@ -19,6 +28,7 @@ export interface FormConfiguration<T extends AnyClass, L = T> {
|
|
|
19
28
|
getDetailsData?: GetDetailsDataFN<T>;
|
|
20
29
|
type: 'json' | 'formData';
|
|
21
30
|
redirectSuccessUrl?: string;
|
|
31
|
+
groups?: FormGroup[];
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
export function Form<T extends AnyClass, L = T>(options?: FormOptions<T, L>): ClassDecorator {
|
|
@@ -45,6 +55,7 @@ export function getFormConfiguration<T extends AnyClass, K extends AnyClassConst
|
|
|
45
55
|
getDetailsData: formOptions.getDetailsData,
|
|
46
56
|
type: formOptions.type ?? 'json',
|
|
47
57
|
redirectSuccessUrl: formOptions.redirectSuccessUrl,
|
|
58
|
+
groups: formOptions.groups,
|
|
48
59
|
};
|
|
49
60
|
return formConfiguration;
|
|
50
61
|
}
|