@qoretechnologies/reqraft 0.10.62-pr.142.g62c577f → 0.10.62
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/dist/components/form/engine/CompactRow.js +1 -1
- package/dist/components/form/engine/CompactRow.js.map +1 -1
- package/dist/components/form/fields/rich-text/RichText.d.ts +22 -1
- package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
- package/dist/components/form/fields/rich-text/RichText.js +135 -50
- package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
- package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts +3 -1
- package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
- package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +2 -5
- package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
- package/dist/components/form/fields/template/TemplateField.js +1 -1
- package/dist/components/form/fields/template/TemplateField.js.map +1 -1
- package/dist/helpers/choiceChip.d.ts +98 -0
- package/dist/helpers/choiceChip.d.ts.map +1 -0
- package/dist/helpers/choiceChip.js +181 -0
- package/dist/helpers/choiceChip.js.map +1 -0
- package/package.json +1 -1
- package/src/components/form/engine/CompactRow.tsx +1 -1
- package/src/components/form/engine/FormEngine.stories.tsx +195 -0
- package/src/components/form/fields/rich-text/RichText.stories.tsx +178 -1
- package/src/components/form/fields/rich-text/RichText.tsx +180 -45
- package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +4 -5
- package/src/components/form/fields/template/TemplateField.tsx +1 -1
- package/src/helpers/choiceChip.ts +227 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { StoryObj } from '@storybook/react-vite';
|
|
2
|
-
import { expect, fn, within } from 'storybook/test';
|
|
2
|
+
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { StoryMeta } from '../../../../types';
|
|
@@ -49,6 +49,183 @@ export const WithValue: Story = {
|
|
|
49
49
|
},
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
export const WithChoiceValue: Story = {
|
|
53
|
+
args: {
|
|
54
|
+
value: 'C07ERBFS0P6',
|
|
55
|
+
allowed_values: [
|
|
56
|
+
{
|
|
57
|
+
display_name: 'general',
|
|
58
|
+
short_desc: 'Company-wide announcements',
|
|
59
|
+
value: { type: 'richtext', value: 'C07ERBFS0P6' },
|
|
60
|
+
},
|
|
61
|
+
{ display_name: 'random', value: { type: 'richtext', value: 'C07RANDOM01' } },
|
|
62
|
+
] as never,
|
|
63
|
+
},
|
|
64
|
+
parameters: {
|
|
65
|
+
docs: {
|
|
66
|
+
description: {
|
|
67
|
+
story:
|
|
68
|
+
'Renders the RichText field holding the value of one of its own choices — the Slack channel id C07ERBFS0P6 — drawn as one chip named "general"; the id itself is not shown, and stays the stored value.',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
async play({ canvasElement }) {
|
|
73
|
+
const canvas = within(canvasElement);
|
|
74
|
+
await expect(canvas.getByText('general')).toBeInTheDocument();
|
|
75
|
+
await expect(canvas.queryByText('C07ERBFS0P6')).not.toBeInTheDocument();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const WithChoiceValueAsDocument: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
...WithChoiceValue.args,
|
|
82
|
+
value: [{ type: 'paragraph', children: [{ text: 'C07ERBFS0P6' }] }] as never,
|
|
83
|
+
},
|
|
84
|
+
parameters: {
|
|
85
|
+
// Interaction-only: the play assertions are the guard, and the end state
|
|
86
|
+
// is already pictured by `WithChoiceValue`.
|
|
87
|
+
qlip: { skip: true },
|
|
88
|
+
docs: {
|
|
89
|
+
description: {
|
|
90
|
+
story:
|
|
91
|
+
'Renders the RichText field holding a document that is nothing but the id of one of its choices — what an edit of the chip stores — drawn as the chip "general"; the id is not shown, and nothing is emitted for it, since the document already stands for the choice.',
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
async play({ args, canvasElement }) {
|
|
96
|
+
const canvas = within(canvasElement);
|
|
97
|
+
await expect(canvas.getByText('general')).toBeInTheDocument();
|
|
98
|
+
await expect(canvas.queryByText('C07ERBFS0P6')).not.toBeInTheDocument();
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
100
|
+
await expect(args.onChange).not.toHaveBeenCalled();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const RetypingTheChoiceReturnsItsChip: Story = {
|
|
105
|
+
args: WithChoiceValue.args,
|
|
106
|
+
parameters: {
|
|
107
|
+
// Interaction-only: the play assertions are the guard, and the end state
|
|
108
|
+
// is already pictured by `WithChoiceValue`.
|
|
109
|
+
qlip: { skip: true },
|
|
110
|
+
docs: {
|
|
111
|
+
description: {
|
|
112
|
+
story:
|
|
113
|
+
'Renders the RichText field holding a choice drawn as the chip "general". A click opens it into C07ERBFS0P6 as text; Backspace emits C07ERBFS0P, and a typed 6 emits C07ERBFS0P6 again, each as a document and still shown as text while the author types; leaving the field with Tab draws it as the chip "general" once more and emits nothing further, since that document already stands for the choice.',
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
async play({ args, canvasElement }) {
|
|
118
|
+
const canvas = within(canvasElement);
|
|
119
|
+
const editor = canvas.getByRole('textbox');
|
|
120
|
+
|
|
121
|
+
await userEvent.click(canvas.getByText('general'));
|
|
122
|
+
await waitFor(() => {
|
|
123
|
+
expect(editor.querySelector('.reqore-tag')).toBeNull();
|
|
124
|
+
expect(editor.contains(document.activeElement)).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// One key at a time, each emitted before the next: typed within the
|
|
128
|
+
// field's debounce, the two would settle as the value the field started
|
|
129
|
+
// with, and nothing would be emitted at all.
|
|
130
|
+
await userEvent.keyboard('{Backspace}');
|
|
131
|
+
await waitFor(() =>
|
|
132
|
+
expect(args.onChange).toHaveBeenLastCalledWith([
|
|
133
|
+
{ type: 'paragraph', children: [{ text: 'C07ERBFS0P' }] },
|
|
134
|
+
])
|
|
135
|
+
);
|
|
136
|
+
await userEvent.keyboard('6');
|
|
137
|
+
await waitFor(() =>
|
|
138
|
+
expect(args.onChange).toHaveBeenLastCalledWith([
|
|
139
|
+
{ type: 'paragraph', children: [{ text: 'C07ERBFS0P6' }] },
|
|
140
|
+
])
|
|
141
|
+
);
|
|
142
|
+
await expect(editor.querySelector('.reqore-tag')).toBeNull();
|
|
143
|
+
|
|
144
|
+
await userEvent.tab();
|
|
145
|
+
await waitFor(() => {
|
|
146
|
+
expect(editor.querySelector('.reqore-tag')?.textContent).toContain('general');
|
|
147
|
+
expect(editor.textContent).not.toContain('C07ERBFS0P6');
|
|
148
|
+
});
|
|
149
|
+
// Leaving the field changed nothing: the chip stands for what was emitted.
|
|
150
|
+
await expect(args.onChange).toHaveBeenLastCalledWith([
|
|
151
|
+
{ type: 'paragraph', children: [{ text: 'C07ERBFS0P6' }] },
|
|
152
|
+
]);
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const ClickOpensTheChoiceChip: Story = {
|
|
157
|
+
args: WithChoiceValue.args,
|
|
158
|
+
parameters: {
|
|
159
|
+
// Interaction-only: the play assertions are the guard, and the end state
|
|
160
|
+
// is already pictured by a plain editor holding text.
|
|
161
|
+
qlip: { skip: true },
|
|
162
|
+
docs: {
|
|
163
|
+
description: {
|
|
164
|
+
story:
|
|
165
|
+
'Renders the RichText field holding a choice drawn as the chip "general", with the default chip behaviour. A click on the chip opens it into its value as editable text: the editor shows C07ERBFS0P6 and no chip, the cursor at its end, so Backspace and a typed 7 make it C07ERBFS0P7, which is what the field emits.',
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
async play({ args, canvasElement }) {
|
|
170
|
+
const canvas = within(canvasElement);
|
|
171
|
+
const editor = canvas.getByRole('textbox');
|
|
172
|
+
|
|
173
|
+
await userEvent.click(canvas.getByText('general'));
|
|
174
|
+
await waitFor(() => {
|
|
175
|
+
expect(editor.querySelector('.reqore-tag')).toBeNull();
|
|
176
|
+
expect(editor.textContent).toBe('C07ERBFS0P6');
|
|
177
|
+
expect(editor.contains(document.activeElement)).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await userEvent.keyboard('{Backspace}7');
|
|
181
|
+
await waitFor(() => expect(editor.textContent).toBe('C07ERBFS0P7'));
|
|
182
|
+
await waitFor(() =>
|
|
183
|
+
expect(args.onChange).toHaveBeenLastCalledWith([
|
|
184
|
+
{ type: 'paragraph', children: [{ text: 'C07ERBFS0P7' }] },
|
|
185
|
+
])
|
|
186
|
+
);
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export const BackspaceOpensTheChoiceChip: Story = {
|
|
191
|
+
args: {
|
|
192
|
+
...WithChoiceValue.args,
|
|
193
|
+
choiceChip: { click: 'cursor' },
|
|
194
|
+
},
|
|
195
|
+
parameters: {
|
|
196
|
+
// Interaction-only: the play assertions are the guard, and the end state
|
|
197
|
+
// is already pictured by a plain editor holding text.
|
|
198
|
+
qlip: { skip: true },
|
|
199
|
+
docs: {
|
|
200
|
+
description: {
|
|
201
|
+
story:
|
|
202
|
+
'Renders the RichText field holding a choice drawn as the chip "general", configured so that a click on the chip only places the cursor after it. Backspace then opens the chip into its value as editable text: the editor shows C07ERBFS0P6 and no chip, the cursor at its end, so one more Backspace and a typed 7 make it C07ERBFS0P7, which is what the field emits.',
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
async play({ args, canvasElement }) {
|
|
207
|
+
const canvas = within(canvasElement);
|
|
208
|
+
const editor = canvas.getByRole('textbox');
|
|
209
|
+
|
|
210
|
+
await userEvent.click(canvas.getByText('general'));
|
|
211
|
+
await waitFor(() => expect(editor.contains(document.activeElement)).toBe(true));
|
|
212
|
+
|
|
213
|
+
await userEvent.keyboard('{Backspace}');
|
|
214
|
+
await waitFor(() => {
|
|
215
|
+
expect(editor.querySelector('.reqore-tag')).toBeNull();
|
|
216
|
+
expect(editor.textContent).toBe('C07ERBFS0P6');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await userEvent.keyboard('{Backspace}7');
|
|
220
|
+
await waitFor(() => expect(editor.textContent).toBe('C07ERBFS0P7'));
|
|
221
|
+
await waitFor(() =>
|
|
222
|
+
expect(args.onChange).toHaveBeenLastCalledWith([
|
|
223
|
+
{ type: 'paragraph', children: [{ text: 'C07ERBFS0P7' }] },
|
|
224
|
+
])
|
|
225
|
+
);
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
52
229
|
export const Empty: Story = {
|
|
53
230
|
parameters: {
|
|
54
231
|
docs: {
|
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
import {
|
|
2
2
|
IReqoreRichTextEditorProps,
|
|
3
3
|
ReqoreRichTextEditor,
|
|
4
|
+
TReqoreRichTextEditorRef,
|
|
4
5
|
} from '@qoretechnologies/reqore/dist/components/RichTextEditor';
|
|
5
6
|
import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea';
|
|
6
7
|
import { IReqoreTagProps } from '@qoretechnologies/reqore/dist/components/Tag';
|
|
7
8
|
import { IReqoreTooltip } from '@qoretechnologies/reqore/dist/types/global';
|
|
9
|
+
import { IQorusAllowedValue } from '@qoretechnologies/ts-toolkit';
|
|
8
10
|
import { isEqual, size } from 'lodash';
|
|
9
|
-
import { KeyboardEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
11
|
+
import { FocusEvent, KeyboardEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
10
12
|
import { useDebounce } from 'react-use';
|
|
13
|
+
import { Path } from 'slate';
|
|
14
|
+
import { ReactEditor } from 'slate-react';
|
|
11
15
|
import { useEmittedValues } from '../emittedValues';
|
|
16
|
+
import {
|
|
17
|
+
choiceChipAtCursor,
|
|
18
|
+
choiceForValue,
|
|
19
|
+
choiceToNodes,
|
|
20
|
+
choiceValue,
|
|
21
|
+
choiceValueOfNodes,
|
|
22
|
+
DEFAULT_CHOICE_CHIP_BEHAVIOR,
|
|
23
|
+
IChoiceChipBehavior,
|
|
24
|
+
pathOfNode,
|
|
25
|
+
unwrapChoiceChip,
|
|
26
|
+
} from '../../../../helpers/choiceChip';
|
|
12
27
|
import { flattenToSingleLine, hasLineBreak } from '../../../../helpers/singleLineString';
|
|
13
28
|
import {
|
|
14
29
|
renderTemplateItemDescriptions,
|
|
@@ -57,6 +72,25 @@ export interface IRichTextFormFieldProps extends Omit<
|
|
|
57
72
|
valueFormat?: 'richtext' | 'text';
|
|
58
73
|
/** With `valueFormat: 'text'`: the value holds one line, so Enter adds none. */
|
|
59
74
|
singleLine?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* The choices the field offers, when it offers any. A value that IS one of
|
|
77
|
+
* them — the whole string, as picked from the field's suggestions, or a
|
|
78
|
+
* document of nothing but that string, as an edit of it stores — is drawn as
|
|
79
|
+
* one chip named from the choice (`general` rather than `C07ERBFS0P6`), and
|
|
80
|
+
* is still emitted as that value: the chip's own value is the choice's, and a
|
|
81
|
+
* document that is nothing but the chip reads back as it. A field spreads
|
|
82
|
+
* its schema here, so this is the schema's own key.
|
|
83
|
+
*/
|
|
84
|
+
allowed_values?: IQorusAllowedValue[];
|
|
85
|
+
/**
|
|
86
|
+
* What a choice chip does when the author acts on it — Backspace right after
|
|
87
|
+
* it, a click on it. By default each opens the chip into its value as plain
|
|
88
|
+
* text, the cursor at the end, so one letter of what was chosen can be
|
|
89
|
+
* changed; see `IChoiceChipBehavior` for the alternatives. Text that is again
|
|
90
|
+
* exactly one of the choices becomes its chip when the author leaves the
|
|
91
|
+
* field — not while typing, where a choice can be the start of a longer value.
|
|
92
|
+
*/
|
|
93
|
+
choiceChip?: IChoiceChipBehavior;
|
|
60
94
|
/**
|
|
61
95
|
* With `valueFormat: 'text'`: the token grammar the value's references are
|
|
62
96
|
* WRITTEN in, when the host speaks one of its own.
|
|
@@ -111,18 +145,6 @@ export const getChipTooltipDescriptions = (
|
|
|
111
145
|
return out;
|
|
112
146
|
};
|
|
113
147
|
|
|
114
|
-
/* The chip references a document holds, in order, as one comparable string. Two documents that stand for
|
|
115
|
-
the same text but chip different references differ here; two that chip the same references do not — so a
|
|
116
|
-
re-parse that recognises nothing new is not taken for a change (which would move the cursor). */
|
|
117
|
-
const chipValuesOf = (nodes?: ReadonlyArray<{ children?: ReadonlyArray<Record<string, any>> }>): string =>
|
|
118
|
-
JSON.stringify(
|
|
119
|
-
(nodes ?? []).flatMap((paragraph) =>
|
|
120
|
-
(paragraph?.children ?? [])
|
|
121
|
-
.filter((node) => node?.type === 'tag')
|
|
122
|
-
.map((node) => String(node?.value ?? ''))
|
|
123
|
-
)
|
|
124
|
-
);
|
|
125
|
-
|
|
126
148
|
export const RichTextFormField = memo(({
|
|
127
149
|
value,
|
|
128
150
|
onChange,
|
|
@@ -131,12 +153,39 @@ export const RichTextFormField = memo(({
|
|
|
131
153
|
valueFormat = 'richtext',
|
|
132
154
|
singleLine,
|
|
133
155
|
templateToken,
|
|
156
|
+
allowed_values,
|
|
157
|
+
choiceChip,
|
|
134
158
|
...rest
|
|
135
159
|
}: IRichTextFormFieldProps) => {
|
|
136
160
|
const isText = valueFormat === 'text';
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
);
|
|
161
|
+
/* The Slate editor behind Reqore's, for the two chip actions below: both
|
|
162
|
+
rewrite the document in place, which the editor's props cannot. */
|
|
163
|
+
const editorRef = useRef<TReqoreRichTextEditorRef>(null);
|
|
164
|
+
const chipBehavior = { ...DEFAULT_CHOICE_CHIP_BEHAVIOR, ...choiceChip };
|
|
165
|
+
/* The document the editor shows for a value: a choice by its chip, a text
|
|
166
|
+
with its references as chips, a document as it is. */
|
|
167
|
+
const toLocal = (next: unknown): any => {
|
|
168
|
+
if (isText) {
|
|
169
|
+
const text = String(next ?? '');
|
|
170
|
+
const choice = choiceForValue(text, allowed_values);
|
|
171
|
+
return choice ? choiceToNodes(choice) : templateTextToNodes(text, templates, templateToken);
|
|
172
|
+
}
|
|
173
|
+
const choice = choiceForValue(next, allowed_values);
|
|
174
|
+
return choice ? choiceToNodes(choice) : next;
|
|
175
|
+
};
|
|
176
|
+
const [localValue, setLocalValue] = useState<any>(() => toLocal(value));
|
|
177
|
+
/* What a value stands for: the choice's value when the value is that choice
|
|
178
|
+
by any spelling — the plain string, the chip, a document of just the text —
|
|
179
|
+
else the value itself. Values are compared by this, so opening a chip, or
|
|
180
|
+
typing its id back, is not a change to the value. */
|
|
181
|
+
const standsFor = (next: unknown): unknown => {
|
|
182
|
+
const chip = choiceValueOfNodes(next);
|
|
183
|
+
if (chip !== undefined) {
|
|
184
|
+
return chip;
|
|
185
|
+
}
|
|
186
|
+
const choice = choiceForValue(next, allowed_values);
|
|
187
|
+
return choice ? choiceValue(choice) : next;
|
|
188
|
+
};
|
|
140
189
|
/* In text mode, the string the editor's document stands for. The value that
|
|
141
190
|
comes back from the parent is usually that same string, and rebuilding the
|
|
142
191
|
document from it would hand the editor a new tree on every keystroke —
|
|
@@ -155,31 +204,34 @@ export const RichTextFormField = memo(({
|
|
|
155
204
|
const text = String(value ?? '');
|
|
156
205
|
if (text !== lastTextRef.current) {
|
|
157
206
|
lastTextRef.current = text;
|
|
158
|
-
setLocalValue(
|
|
207
|
+
setLocalValue(toLocal(text));
|
|
159
208
|
}
|
|
160
209
|
return;
|
|
161
210
|
}
|
|
162
|
-
|
|
163
|
-
|
|
211
|
+
// Compared as what each stands for: a lone choice chip IS the value it
|
|
212
|
+
// draws, so the value coming back is not a change to it.
|
|
213
|
+
if (!isEqual(standsFor(value), standsFor(localValue))) {
|
|
214
|
+
setLocalValue(toLocal(value));
|
|
164
215
|
}
|
|
165
216
|
}, [JSON.stringify(value)]);
|
|
166
217
|
|
|
167
|
-
/*
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
218
|
+
/* The choices usually arrive after the value — they are fetched — so a
|
|
219
|
+
value already shown as text becomes its chip once they do. Only while the
|
|
220
|
+
editor still shows the stored value as it is: an edit in progress is
|
|
221
|
+
never redrawn from under the author. */
|
|
222
|
+
const choiceKey = JSON.stringify(
|
|
223
|
+
allowed_values?.map((choice) => [choice.value, choice.display_name]) ?? null
|
|
224
|
+
);
|
|
174
225
|
useEffect(() => {
|
|
175
|
-
if (!
|
|
226
|
+
if (!allowed_values?.length) {
|
|
176
227
|
return;
|
|
177
228
|
}
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
229
|
+
const shown = isText ? templateNodesToText(localValue) : (choiceValueOfNodes(localValue) ?? localValue);
|
|
230
|
+
const stored = isText ? String(value ?? '') : value;
|
|
231
|
+
if (isEqual(shown, stored) && choiceForValue(stored, allowed_values)) {
|
|
232
|
+
setLocalValue(toLocal(stored));
|
|
233
|
+
}
|
|
234
|
+
}, [choiceKey]);
|
|
183
235
|
|
|
184
236
|
useDebounce(
|
|
185
237
|
() => {
|
|
@@ -190,9 +242,12 @@ export const RichTextFormField = memo(({
|
|
|
190
242
|
}
|
|
191
243
|
return;
|
|
192
244
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
245
|
+
// A document that is nothing but one choice chip is emitted as the
|
|
246
|
+
// choice's value — what was stored and what the server resolves.
|
|
247
|
+
const next = choiceValueOfNodes(localValue) ?? localValue;
|
|
248
|
+
if (!isEqual(standsFor(next), standsFor(value))) {
|
|
249
|
+
emitted.record(next);
|
|
250
|
+
onChange?.(next);
|
|
196
251
|
}
|
|
197
252
|
},
|
|
198
253
|
100,
|
|
@@ -211,7 +266,7 @@ export const RichTextFormField = memo(({
|
|
|
211
266
|
// stopped below. Flattened text is shown as the one line it now is.
|
|
212
267
|
if (singleLine && hasLineBreak(text)) {
|
|
213
268
|
lastTextRef.current = flattenToSingleLine(text);
|
|
214
|
-
setLocalValue(
|
|
269
|
+
setLocalValue(toLocal(lastTextRef.current));
|
|
215
270
|
return;
|
|
216
271
|
}
|
|
217
272
|
lastTextRef.current = text;
|
|
@@ -233,6 +288,43 @@ export const RichTextFormField = memo(({
|
|
|
233
288
|
JSON.stringify(templates),
|
|
234
289
|
]);
|
|
235
290
|
|
|
291
|
+
/* Opens a choice chip into its value as text and puts the cursor there.
|
|
292
|
+
A click on the chip — a non-editable node — need not have given the
|
|
293
|
+
editor focus, so focus is asked of Slate, which places the DOM caret where
|
|
294
|
+
the selection is and focuses after; but only once the text the chip became
|
|
295
|
+
is rendered (the effect below), since Slate resolves the caret through the
|
|
296
|
+
rendered DOM. Asked any earlier it lost: a DOM `focus()` before the
|
|
297
|
+
transform let the browser put the caret at the editor's start and the
|
|
298
|
+
`selectionchange` it raised beat the render, so the next key typed landed
|
|
299
|
+
there; asked on the next frame, the frame came before React's commit
|
|
300
|
+
often enough. An editor that already has focus (Backspace came from it)
|
|
301
|
+
is returned at once, and its own render puts the caret where the
|
|
302
|
+
selection is. */
|
|
303
|
+
const focusOnceRendered = useRef(false);
|
|
304
|
+
const openChoiceChip = useCallback((path: Path | undefined): void => {
|
|
305
|
+
const editor = editorRef.current;
|
|
306
|
+
|
|
307
|
+
if (editor && path && unwrapChoiceChip(editor, path)) {
|
|
308
|
+
focusOnceRendered.current = true;
|
|
309
|
+
}
|
|
310
|
+
}, []);
|
|
311
|
+
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
const editor = editorRef.current;
|
|
314
|
+
|
|
315
|
+
if (!focusOnceRendered.current || !editor) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
focusOnceRendered.current = false;
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
ReactEditor.toDOMNode(editor, editor);
|
|
322
|
+
ReactEditor.focus(editor);
|
|
323
|
+
} catch {
|
|
324
|
+
// Not mounted in a document — nothing to focus.
|
|
325
|
+
}
|
|
326
|
+
}, [localValue]);
|
|
327
|
+
|
|
236
328
|
const handleGetTagProps = useCallback(
|
|
237
329
|
(tag: any): IReqoreTagProps => {
|
|
238
330
|
const tagValue = tag.value?.toString();
|
|
@@ -241,6 +333,21 @@ export const RichTextFormField = memo(({
|
|
|
241
333
|
return {};
|
|
242
334
|
}
|
|
243
335
|
|
|
336
|
+
/* One of the field's own choices, drawn by the name it was picked by;
|
|
337
|
+
what it stands for on hover. */
|
|
338
|
+
if (tag.metadata?.allowedValue) {
|
|
339
|
+
return {
|
|
340
|
+
icon: 'ListCheck',
|
|
341
|
+
label: tag.label ?? tagValue,
|
|
342
|
+
tooltip: tag.metadata?.description,
|
|
343
|
+
/* The editor hands the document's own node here, so the chip is
|
|
344
|
+
found by identity; a chip already gone is a click on nothing. */
|
|
345
|
+
...(chipBehavior.click === 'unwrap' ?
|
|
346
|
+
{ onClick: () => openChoiceChip(editorRef.current ? pathOfNode(editorRef.current, tag) : undefined) }
|
|
347
|
+
: {}),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
244
351
|
/* A reference in a string is drawn exactly as the collapsed row draws the
|
|
245
352
|
same value (`ReadOnlyTemplateTag`): named from the catalogue, its
|
|
246
353
|
description on hover. Read at render, so a catalogue that arrives
|
|
@@ -253,10 +360,6 @@ export const RichTextFormField = memo(({
|
|
|
253
360
|
leftIconProps: { image: metadata?.image },
|
|
254
361
|
label: templateChipLabel(templates, tagValue),
|
|
255
362
|
tooltip: templateTooltip(templates, tagValue),
|
|
256
|
-
// The same chip the read summary (`TemplateText`) and the read-only tag draw: a `tiny` box whose
|
|
257
|
-
// label takes the font of the text it sits in, so a reference is the same size in every view.
|
|
258
|
-
size: 'tiny',
|
|
259
|
-
style: { fontSize: 'inherit' },
|
|
260
363
|
...getTemplateTagStyle(metadata),
|
|
261
364
|
};
|
|
262
365
|
}
|
|
@@ -296,16 +399,29 @@ export const RichTextFormField = memo(({
|
|
|
296
399
|
}
|
|
297
400
|
: descriptionByValue[tagValue]
|
|
298
401
|
: undefined,
|
|
299
|
-
// The same chip the read summary and the read-only tag draw — see the `isText` branch above.
|
|
300
|
-
size: 'tiny',
|
|
301
|
-
style: { fontSize: 'inherit' },
|
|
302
402
|
...getTemplateTagStyle(tag.metadata),
|
|
303
403
|
};
|
|
304
404
|
}
|
|
305
405
|
|
|
306
406
|
return {};
|
|
307
407
|
},
|
|
308
|
-
[descriptionByValue, renderMarkdown, isText, templates]
|
|
408
|
+
[descriptionByValue, renderMarkdown, isText, templates, chipBehavior.click, openChoiceChip]
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
/* Leaving the field: text that is exactly one of the choices becomes its
|
|
412
|
+
chip again, as the picker would have drawn it. Only here, never while
|
|
413
|
+
typing — a choice can be the start of a longer value, and a chip drawn
|
|
414
|
+
under the cursor would swallow it. */
|
|
415
|
+
const handleBlur = useCallback(
|
|
416
|
+
(event: FocusEvent<HTMLTextAreaElement>): void => {
|
|
417
|
+
const shown = isText ? lastTextRef.current : localValue;
|
|
418
|
+
|
|
419
|
+
if (choiceForValue(shown, allowed_values)) {
|
|
420
|
+
setLocalValue(toLocal(shown));
|
|
421
|
+
}
|
|
422
|
+
(rest as { onBlur?: (event: FocusEvent<HTMLTextAreaElement>) => void }).onBlur?.(event);
|
|
423
|
+
},
|
|
424
|
+
[isText, localValue, choiceKey, (rest as { onBlur?: unknown }).onBlur]
|
|
309
425
|
);
|
|
310
426
|
|
|
311
427
|
const handleKeyDown = useCallback(
|
|
@@ -313,9 +429,26 @@ export const RichTextFormField = memo(({
|
|
|
313
429
|
if (isText && singleLine && event.key === 'Enter') {
|
|
314
430
|
event.preventDefault();
|
|
315
431
|
}
|
|
432
|
+
/* Backspace right after a choice chip opens it rather than deleting it,
|
|
433
|
+
when so configured. Stopped here, at keydown, so the browser raises no
|
|
434
|
+
`beforeinput` for the editor to delete the chip by. */
|
|
435
|
+
if (event.key === 'Backspace' && chipBehavior.backspace === 'unwrap' && editorRef.current) {
|
|
436
|
+
const path = choiceChipAtCursor(editorRef.current);
|
|
437
|
+
|
|
438
|
+
if (path) {
|
|
439
|
+
event.preventDefault();
|
|
440
|
+
openChoiceChip(path);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
316
443
|
(rest as { onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void }).onKeyDown?.(event);
|
|
317
444
|
},
|
|
318
|
-
[
|
|
445
|
+
[
|
|
446
|
+
isText,
|
|
447
|
+
singleLine,
|
|
448
|
+
chipBehavior.backspace,
|
|
449
|
+
openChoiceChip,
|
|
450
|
+
(rest as { onKeyDown?: unknown }).onKeyDown,
|
|
451
|
+
]
|
|
319
452
|
);
|
|
320
453
|
|
|
321
454
|
const tags = useMemo<IReqoreRichTextEditorProps['tags']>((): IReqoreRichTextEditorProps['tags'] => {
|
|
@@ -366,6 +499,7 @@ export const RichTextFormField = memo(({
|
|
|
366
499
|
|
|
367
500
|
return (
|
|
368
501
|
<ReqoreRichTextEditor
|
|
502
|
+
ref={editorRef}
|
|
369
503
|
value={formattedValue}
|
|
370
504
|
onChange={handleChange}
|
|
371
505
|
/* `rest` is spread BEFORE everything this component computes. It used to
|
|
@@ -391,6 +525,7 @@ export const RichTextFormField = memo(({
|
|
|
391
525
|
panelProps={{ fluid: true, style: { minWidth: '150px', ...rest.panelProps?.style } }}
|
|
392
526
|
readOnly={readOnly}
|
|
393
527
|
onKeyDown={handleKeyDown}
|
|
528
|
+
onBlur={handleBlur}
|
|
394
529
|
/* A form field carries no document toolbar. Styling was already off, so
|
|
395
530
|
the bar held only undo and redo — drawn by Reqore as a panel as wide as
|
|
396
531
|
the tag list, which under a field reads as an empty 600px menu with two
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ReqoreTag, ReqoreTagGroup } from '@qoretechnologies/reqore';
|
|
2
2
|
import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea';
|
|
3
|
+
import { TSizes } from '@qoretechnologies/reqore/dist/constants/sizes';
|
|
3
4
|
import { memo } from 'react';
|
|
4
5
|
import {
|
|
5
6
|
getTemplateTagStyle,
|
|
@@ -13,6 +14,7 @@ export interface IReadOnlyTemplateTagProps {
|
|
|
13
14
|
value: string;
|
|
14
15
|
/** Template catalogue, for resolving the display name + image + metadata. */
|
|
15
16
|
templates?: IReqoreFormTemplates;
|
|
17
|
+
size?: TSizes;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -25,19 +27,16 @@ export interface IReadOnlyTemplateTagProps {
|
|
|
25
27
|
* it's shown non-editably.
|
|
26
28
|
*/
|
|
27
29
|
export const ReadOnlyTemplateTag = memo(
|
|
28
|
-
({ value, templates }: IReadOnlyTemplateTagProps) => {
|
|
30
|
+
({ value, templates, size }: IReadOnlyTemplateTagProps) => {
|
|
29
31
|
const { label, item } = describeTemplateReference(templates, value);
|
|
30
32
|
const metadata = item?.metadata as TTemplateMeta | undefined;
|
|
31
33
|
return (
|
|
32
|
-
|
|
33
|
-
// label takes the font of the text it sits in, so a reference is the same size in every view.
|
|
34
|
-
<ReqoreTagGroup size='tiny'>
|
|
34
|
+
<ReqoreTagGroup size={size}>
|
|
35
35
|
<ReqoreTag
|
|
36
36
|
icon='ExchangeDollarLine'
|
|
37
37
|
leftIconProps={{ image: metadata?.image }}
|
|
38
38
|
label={label}
|
|
39
39
|
tooltip={templateTooltip(templates, value)}
|
|
40
|
-
style={{ fontSize: 'inherit' }}
|
|
41
40
|
{...getTemplateTagStyle(metadata)}
|
|
42
41
|
/>
|
|
43
42
|
</ReqoreTagGroup>
|
|
@@ -1595,7 +1595,7 @@ const TemplateFieldImpl = memo(
|
|
|
1595
1595
|
// here; reqraft upgrades the read-only template to a proper picker chip —
|
|
1596
1596
|
// the $-dollar icon + resolved display name + app image, coloured by the
|
|
1597
1597
|
// IDE's intent scheme. Shared with the compact read-first row.
|
|
1598
|
-
return <ReadOnlyTemplateTag value={templateValue} templates={templates} />;
|
|
1598
|
+
return <ReadOnlyTemplateTag value={templateValue} templates={templates} size={rest.size} />;
|
|
1599
1599
|
}
|
|
1600
1600
|
|
|
1601
1601
|
return (
|