@qoretechnologies/reqraft 0.10.9-pr.74.g452d4d3 → 0.10.9
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.d.ts.map +1 -1
- package/dist/components/form/engine/CompactRow.js +28 -3
- package/dist/components/form/engine/CompactRow.js.map +1 -1
- package/dist/components/form/engine/FormEngine.d.ts +16 -1
- package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
- package/dist/components/form/engine/FormEngine.js +83 -1
- package/dist/components/form/engine/FormEngine.js.map +1 -1
- package/dist/components/form/engine/compactRowContext.d.ts +1 -0
- package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
- package/dist/components/form/engine/compactRowContext.js.map +1 -1
- package/dist/components/form/engine/compactRowStyles.d.ts +1 -0
- package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
- package/dist/components/form/engine/compactRowStyles.js +19 -4
- package/dist/components/form/engine/compactRowStyles.js.map +1 -1
- package/dist/components/form/engine/readFirst.d.ts +28 -0
- package/dist/components/form/engine/readFirst.d.ts.map +1 -1
- package/dist/components/form/engine/readFirst.js +24 -1
- package/dist/components/form/engine/readFirst.js.map +1 -1
- package/dist/components/form/fields/binary/Binary.d.ts +15 -0
- package/dist/components/form/fields/binary/Binary.d.ts.map +1 -0
- package/dist/components/form/fields/binary/Binary.js +74 -0
- package/dist/components/form/fields/binary/Binary.js.map +1 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/form/engine/CompactRow.tsx +63 -3
- package/src/components/form/engine/FormEngine.stories.tsx +236 -0
- package/src/components/form/engine/FormEngine.tsx +108 -0
- package/src/components/form/engine/compactRowContext.ts +6 -0
- package/src/components/form/engine/compactRowStyles.ts +21 -0
- package/src/components/form/engine/readFirst.ts +42 -0
- package/src/components/form/fields/binary/Binary.stories.tsx +90 -0
- package/src/components/form/fields/binary/Binary.tsx +81 -0
- package/src/index.tsx +0 -2
- package/dist/components/supportTicket/AttachmentChips.d.ts +0 -7
- package/dist/components/supportTicket/AttachmentChips.d.ts.map +0 -1
- package/dist/components/supportTicket/AttachmentChips.js +0 -26
- package/dist/components/supportTicket/AttachmentChips.js.map +0 -1
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +0 -25
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +0 -1
- package/dist/components/supportTicket/InterfaceReferenceTags.js +0 -28
- package/dist/components/supportTicket/InterfaceReferenceTags.js.map +0 -1
- package/dist/components/supportTicket/TicketMetaTags.d.ts +0 -24
- package/dist/components/supportTicket/TicketMetaTags.d.ts.map +0 -1
- package/dist/components/supportTicket/TicketMetaTags.js +0 -21
- package/dist/components/supportTicket/TicketMetaTags.js.map +0 -1
- package/dist/components/supportTicket/TicketReplyBox.d.ts +0 -59
- package/dist/components/supportTicket/TicketReplyBox.d.ts.map +0 -1
- package/dist/components/supportTicket/TicketReplyBox.js +0 -169
- package/dist/components/supportTicket/TicketReplyBox.js.map +0 -1
- package/dist/components/supportTicket/fileToAttachment.d.ts +0 -9
- package/dist/components/supportTicket/fileToAttachment.d.ts.map +0 -1
- package/dist/components/supportTicket/fileToAttachment.js +0 -28
- package/dist/components/supportTicket/fileToAttachment.js.map +0 -1
- package/dist/components/supportTicket/index.d.ts +0 -7
- package/dist/components/supportTicket/index.d.ts.map +0 -1
- package/dist/components/supportTicket/index.js +0 -23
- package/dist/components/supportTicket/index.js.map +0 -1
- package/dist/components/supportTicket/meta.d.ts +0 -55
- package/dist/components/supportTicket/meta.d.ts.map +0 -1
- package/dist/components/supportTicket/meta.js +0 -77
- package/dist/components/supportTicket/meta.js.map +0 -1
- package/dist/components/ticketThread/TicketThread.d.ts +0 -36
- package/dist/components/ticketThread/TicketThread.d.ts.map +0 -1
- package/dist/components/ticketThread/TicketThread.js +0 -137
- package/dist/components/ticketThread/TicketThread.js.map +0 -1
- package/dist/components/ticketThread/index.d.ts +0 -3
- package/dist/components/ticketThread/index.d.ts.map +0 -1
- package/dist/components/ticketThread/index.js +0 -6
- package/dist/components/ticketThread/index.js.map +0 -1
- package/src/components/supportTicket/AttachmentChips.tsx +0 -50
- package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +0 -102
- package/src/components/supportTicket/InterfaceReferenceTags.tsx +0 -62
- package/src/components/supportTicket/TicketMetaTags.stories.tsx +0 -71
- package/src/components/supportTicket/TicketMetaTags.tsx +0 -58
- package/src/components/supportTicket/TicketReplyBox.stories.tsx +0 -111
- package/src/components/supportTicket/TicketReplyBox.tsx +0 -251
- package/src/components/supportTicket/fileToAttachment.ts +0 -24
- package/src/components/supportTicket/index.ts +0 -6
- package/src/components/supportTicket/meta.ts +0 -135
- package/src/components/ticketThread/TicketThread.stories.tsx +0 -192
- package/src/components/ticketThread/TicketThread.tsx +0 -331
- package/src/components/ticketThread/index.ts +0 -6
|
@@ -715,6 +715,73 @@ export const OptionInheritsRenderPropFromSiblingCompact: Story = {
|
|
|
715
715
|
},
|
|
716
716
|
};
|
|
717
717
|
|
|
718
|
+
// Compact-row code-editor preview: a `code-editor` field with a multi-line
|
|
719
|
+
// string value renders (a) a "N lines · N chars" tag in the value cell instead
|
|
720
|
+
// of the truncated raw string, and (b) a monospace `<pre>` block under the row
|
|
721
|
+
// capped by a `ReqoreCollapsibleContent` — the "Show more" affordance the value
|
|
722
|
+
// cell couldn't provide on its own. Locks the compact preview so a future
|
|
723
|
+
// CompactRow refactor can't silently reduce a Qorus source-code field to an
|
|
724
|
+
// ellipsised one-liner again.
|
|
725
|
+
export const CompactRowCodeEditorPreview: Story = {
|
|
726
|
+
args: {
|
|
727
|
+
compact: true,
|
|
728
|
+
minColumnWidth: '360px',
|
|
729
|
+
componentOverrides: { 'code-editor': CodeEditorStandin },
|
|
730
|
+
value: {
|
|
731
|
+
language: { type: 'string', value: 'qore' },
|
|
732
|
+
source: {
|
|
733
|
+
type: 'string',
|
|
734
|
+
value:
|
|
735
|
+
'%new-style\n%require-types\n%strict-args\n' +
|
|
736
|
+
'%enable-all-warnings\n\n' +
|
|
737
|
+
'class ExampleJob inherits QorusJob {\n' +
|
|
738
|
+
' run() {\n' +
|
|
739
|
+
' logInfo("running");\n' +
|
|
740
|
+
' }\n' +
|
|
741
|
+
'}\n',
|
|
742
|
+
},
|
|
743
|
+
},
|
|
744
|
+
options: {
|
|
745
|
+
language: {
|
|
746
|
+
type: 'string',
|
|
747
|
+
ui_type: 'string',
|
|
748
|
+
display_name: 'Language',
|
|
749
|
+
allowed_values: [
|
|
750
|
+
{ display_name: 'Qore', value: { type: 'string', value: 'qore' } },
|
|
751
|
+
{ display_name: 'Python', value: { type: 'string', value: 'python' } },
|
|
752
|
+
{ display_name: 'Java', value: { type: 'string', value: 'java' } },
|
|
753
|
+
],
|
|
754
|
+
},
|
|
755
|
+
source: {
|
|
756
|
+
type: 'string',
|
|
757
|
+
ui_type: 'code-editor',
|
|
758
|
+
display_name: 'Source Code',
|
|
759
|
+
inherit_props: { language: 'language' },
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
play: async ({ canvasElement }) => {
|
|
764
|
+
// (a) The monospace preview mounted under the row — contains a substring
|
|
765
|
+
// only the source has, proving `showCodePreview` kicked in and the
|
|
766
|
+
// `StyledCodePreview` block is in the DOM.
|
|
767
|
+
await waitFor(
|
|
768
|
+
() => {
|
|
769
|
+
const preview = canvasElement.querySelector('.options-readfirst-code');
|
|
770
|
+
expect(preview).toBeTruthy();
|
|
771
|
+
expect(preview?.textContent).toContain('class ExampleJob inherits QorusJob');
|
|
772
|
+
},
|
|
773
|
+
{ timeout: 5000 }
|
|
774
|
+
);
|
|
775
|
+
// (b) The value cell replaced its truncated raw string with a summary tag —
|
|
776
|
+
// query the tag directly (its label + labelKey render on separate spans,
|
|
777
|
+
// so text-matching across them is fragile). Look for the CodeLine icon
|
|
778
|
+
// that only this tag mounts alongside the source-code row.
|
|
779
|
+
const sourceRow = canvasElement.querySelector('[data-field="source"]');
|
|
780
|
+
expect(sourceRow).toBeTruthy();
|
|
781
|
+
expect(sourceRow?.textContent ?? '').toMatch(/\d+\s*lines?/);
|
|
782
|
+
},
|
|
783
|
+
};
|
|
784
|
+
|
|
718
785
|
// qorus#347-followup, scope forwarding: this story exercises the nested
|
|
719
786
|
// case of the OptionInheritsRenderPropFromSibling contract. The parent
|
|
720
787
|
// form declares `methods: { ui_type: 'list', element_type: 'hash',
|
|
@@ -4335,3 +4402,172 @@ export const CompactFieldSortWithinGroups: Story = {
|
|
|
4335
4402
|
await expect(document.querySelectorAll('.readfirst-cluster-first')).toHaveLength(2);
|
|
4336
4403
|
},
|
|
4337
4404
|
};
|
|
4405
|
+
|
|
4406
|
+
// `autoFocusFirstRequired` drops the user straight into the first field they
|
|
4407
|
+
// must fill. Here `name` is required-but-filled and `description` is
|
|
4408
|
+
// required-but-empty, so the engine expands the `description` row on mount and
|
|
4409
|
+
// its editor takes focus — no click, no DOM scraping.
|
|
4410
|
+
export const CompactAutoFocusFirstRequired: Story = {
|
|
4411
|
+
args: {
|
|
4412
|
+
compact: true,
|
|
4413
|
+
minColumnWidth: '300px',
|
|
4414
|
+
options: CompactSchema,
|
|
4415
|
+
value: CompactValue,
|
|
4416
|
+
groups: CompactGroups,
|
|
4417
|
+
autoFocusFirstRequired: true,
|
|
4418
|
+
},
|
|
4419
|
+
play: async () => {
|
|
4420
|
+
await _testsWaitForText('order-fulfilment');
|
|
4421
|
+
|
|
4422
|
+
// The first empty required field (`description`) auto-expands into its
|
|
4423
|
+
// inline editor without any interaction.
|
|
4424
|
+
await waitFor(
|
|
4425
|
+
() =>
|
|
4426
|
+
expect(
|
|
4427
|
+
document.querySelector(
|
|
4428
|
+
'[data-field="description"] input, [data-field="description"] textarea'
|
|
4429
|
+
)
|
|
4430
|
+
).toBeTruthy(),
|
|
4431
|
+
{ timeout: 10000 }
|
|
4432
|
+
);
|
|
4433
|
+
|
|
4434
|
+
// …and that editor receives focus (CompactRow focuses the expanded row's
|
|
4435
|
+
// control). `name` is required but already filled, so it is skipped.
|
|
4436
|
+
await waitFor(
|
|
4437
|
+
() => {
|
|
4438
|
+
const active = document.activeElement as HTMLElement | null;
|
|
4439
|
+
const field = document.querySelector('[data-field="description"]');
|
|
4440
|
+
expect(!!active && !!field && field.contains(active)).toBe(true);
|
|
4441
|
+
},
|
|
4442
|
+
{ timeout: 10000 }
|
|
4443
|
+
);
|
|
4444
|
+
|
|
4445
|
+
// The already-satisfied required field is NOT expanded/focused.
|
|
4446
|
+
const nameField = document.querySelector('[data-field="name"]');
|
|
4447
|
+
expect(nameField?.contains(document.activeElement)).toBeFalsy();
|
|
4448
|
+
},
|
|
4449
|
+
};
|
|
4450
|
+
|
|
4451
|
+
// A required field can be FILLED yet INVALID — here `endpoint` has a value that
|
|
4452
|
+
// fails its own `validation_regex` (it must be an http(s) URL). It still "needs
|
|
4453
|
+
// attention", so autofocus must land on IT and not skip ahead to the empty
|
|
4454
|
+
// `description` (which the old empty-only selector would have chosen). This is
|
|
4455
|
+
// the regression guard for the "filled-but-invalid" drift.
|
|
4456
|
+
const CompactInvalidFilledSchema: Record<string, TCompactField> = {
|
|
4457
|
+
endpoint: {
|
|
4458
|
+
type: 'string',
|
|
4459
|
+
ui_type: 'string',
|
|
4460
|
+
display_name: 'Endpoint URL',
|
|
4461
|
+
required: true,
|
|
4462
|
+
group: 'info',
|
|
4463
|
+
// Real format constraint: the value must be an http(s) URL. `validation_regex`
|
|
4464
|
+
// is read by the engine's own validation (helpers/validations.ts); it's not on
|
|
4465
|
+
// the base schema type, so cast.
|
|
4466
|
+
validation_regex: '^https?://',
|
|
4467
|
+
} as TCompactField,
|
|
4468
|
+
description: {
|
|
4469
|
+
type: 'string',
|
|
4470
|
+
ui_type: 'string',
|
|
4471
|
+
display_name: 'Description',
|
|
4472
|
+
required: true,
|
|
4473
|
+
group: 'general',
|
|
4474
|
+
},
|
|
4475
|
+
};
|
|
4476
|
+
|
|
4477
|
+
const CompactInvalidFilledValue: IOptions = {
|
|
4478
|
+
endpoint: { type: 'string', value: 'bad-endpoint' }, // filled, but not an http(s) URL → fails validation_regex
|
|
4479
|
+
// description left empty
|
|
4480
|
+
};
|
|
4481
|
+
|
|
4482
|
+
export const CompactAutoFocusTargetsInvalidFilledField: Story = {
|
|
4483
|
+
args: {
|
|
4484
|
+
compact: true,
|
|
4485
|
+
minColumnWidth: '300px',
|
|
4486
|
+
options: CompactInvalidFilledSchema,
|
|
4487
|
+
value: CompactInvalidFilledValue,
|
|
4488
|
+
groups: CompactGroups,
|
|
4489
|
+
autoFocusFirstRequired: true,
|
|
4490
|
+
},
|
|
4491
|
+
play: async () => {
|
|
4492
|
+
await _testsWaitForText('Endpoint URL');
|
|
4493
|
+
|
|
4494
|
+
// Sanity: the value genuinely fails the field's own validation (it isn't an
|
|
4495
|
+
// http(s) URL), using the very same validator the engine runs — so this is a
|
|
4496
|
+
// real filled-but-INVALID field, not a synthetically-flagged one.
|
|
4497
|
+
expect(validateField('string', 'bad-endpoint', { validation_regex: '^https?://' })).toBe(false);
|
|
4498
|
+
|
|
4499
|
+
// `endpoint` has a value but is invalid, so it needs attention and must be
|
|
4500
|
+
// the target — even though `description` is the empty required field the old
|
|
4501
|
+
// empty-only logic would have picked.
|
|
4502
|
+
await waitFor(
|
|
4503
|
+
() =>
|
|
4504
|
+
expect(
|
|
4505
|
+
document.querySelector('[data-field="endpoint"] input, [data-field="endpoint"] textarea')
|
|
4506
|
+
).toBeTruthy(),
|
|
4507
|
+
{ timeout: 10000 }
|
|
4508
|
+
);
|
|
4509
|
+
|
|
4510
|
+
await waitFor(
|
|
4511
|
+
() => {
|
|
4512
|
+
const active = document.activeElement as HTMLElement | null;
|
|
4513
|
+
const field = document.querySelector('[data-field="endpoint"]');
|
|
4514
|
+
expect(!!active && !!field && field.contains(active)).toBe(true);
|
|
4515
|
+
},
|
|
4516
|
+
{ timeout: 10000 }
|
|
4517
|
+
);
|
|
4518
|
+
|
|
4519
|
+
// The empty `description` is NOT the one expanded/focused.
|
|
4520
|
+
const descField = document.querySelector('[data-field="description"]');
|
|
4521
|
+
expect(descField?.contains(document.activeElement)).toBeFalsy();
|
|
4522
|
+
},
|
|
4523
|
+
};
|
|
4524
|
+
|
|
4525
|
+
// DEMO / manual-test story: the first "needs attention" field is a BOOLEAN,
|
|
4526
|
+
// whose compact editor is a `<div tabindex=0>` (ReqoreCheckbox) — NOT an
|
|
4527
|
+
// input/textarea/contenteditable that CompactRow's focus selector matches. Both
|
|
4528
|
+
// `enabled` and `name` are required and unset, so both need attention; `enabled`
|
|
4529
|
+
// is first. Open this in Storybook to see the UX: autofocus TARGETS the boolean
|
|
4530
|
+
// (its row expands) and does NOT skip ahead to the focusable `name` field — but
|
|
4531
|
+
// no element inside actually receives keyboard focus, so the caret is left
|
|
4532
|
+
// nowhere. (This is the non-text-editor focus gap; kept as a playground rather
|
|
4533
|
+
// than a hard assertion until we decide how CompactRow should focus such rows.)
|
|
4534
|
+
const CompactNonFocusableFirstSchema: Record<string, TCompactField> = {
|
|
4535
|
+
enabled: {
|
|
4536
|
+
type: 'bool',
|
|
4537
|
+
ui_type: 'bool',
|
|
4538
|
+
display_name: 'Enabled',
|
|
4539
|
+
short_desc: 'A boolean — the first field that needs attention',
|
|
4540
|
+
required: true,
|
|
4541
|
+
preselected: true,
|
|
4542
|
+
group: 'info',
|
|
4543
|
+
},
|
|
4544
|
+
name: {
|
|
4545
|
+
type: 'string',
|
|
4546
|
+
ui_type: 'string',
|
|
4547
|
+
display_name: 'Name',
|
|
4548
|
+
short_desc: 'A focusable text field — comes second',
|
|
4549
|
+
required: true,
|
|
4550
|
+
preselected: true,
|
|
4551
|
+
group: 'general',
|
|
4552
|
+
},
|
|
4553
|
+
};
|
|
4554
|
+
|
|
4555
|
+
const CompactNonFocusableFirstValue: IOptions = {}; // both unset → both need attention
|
|
4556
|
+
|
|
4557
|
+
export const CompactAutoFocusNonFocusableFirstField: Story = {
|
|
4558
|
+
args: {
|
|
4559
|
+
compact: true,
|
|
4560
|
+
minColumnWidth: '300px',
|
|
4561
|
+
options: CompactNonFocusableFirstSchema,
|
|
4562
|
+
value: CompactNonFocusableFirstValue,
|
|
4563
|
+
groups: CompactGroups,
|
|
4564
|
+
autoFocusFirstRequired: true,
|
|
4565
|
+
},
|
|
4566
|
+
play: async () => {
|
|
4567
|
+
// Smoke: both required-but-unset fields render (both land in "needs
|
|
4568
|
+
// attention"), with the boolean first. Focus behaviour is intentionally left
|
|
4569
|
+
// for manual observation — see the note above.
|
|
4570
|
+
await _testsWaitForText('Enabled');
|
|
4571
|
+
await _testsWaitForText('Name');
|
|
4572
|
+
},
|
|
4573
|
+
};
|
|
@@ -94,6 +94,7 @@ import {
|
|
|
94
94
|
import { OptionFieldMessages } from './OptionFieldMessages';
|
|
95
95
|
import { OptionsHelpDialog } from './OptionsHelpDialog';
|
|
96
96
|
import {
|
|
97
|
+
getFirstAttentionOptionName,
|
|
97
98
|
getOptionGroup,
|
|
98
99
|
getOptionGroupLabel,
|
|
99
100
|
getReadFirstBucket,
|
|
@@ -564,6 +565,22 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
|
|
|
564
565
|
* plumbing for the inherit_props scope-forwarding contract.
|
|
565
566
|
*/
|
|
566
567
|
inheritedFromParent?: Record<string, unknown>;
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Opt-in: on mount, drop the user straight into the first field they must
|
|
571
|
+
* fill — the first empty, focusable field (in schema/sort order) that is
|
|
572
|
+
* `required` or a member of a still-unsatisfied one-of `required_groups`
|
|
573
|
+
* group. Disabled, readonly, read-only-form, and dependency-locked fields are
|
|
574
|
+
* skipped. In compact (read-first) mode the target row is expanded through
|
|
575
|
+
* the engine's own `expandedOptions` state, so its editor gains focus with no
|
|
576
|
+
* DOM scraping or synthetic clicks. Strictly one-shot: it fires the first
|
|
577
|
+
* time the form has focusable content (so an async-loaded schema is covered)
|
|
578
|
+
* and then never again — a later value edit, server-driven field update, or
|
|
579
|
+
* in-place schema reload will not re-focus; only a remount re-arms it. It also
|
|
580
|
+
* never steals focus from a control the user has already moved into. No-op in
|
|
581
|
+
* classic (non-compact) mode. Default: off.
|
|
582
|
+
*/
|
|
583
|
+
autoFocusFirstRequired?: boolean;
|
|
567
584
|
}
|
|
568
585
|
|
|
569
586
|
// Option types rendered full-width (IDE Options parity, commit 8e6b7781).
|
|
@@ -602,6 +619,7 @@ export const FormEngine = ({
|
|
|
602
619
|
optionActions,
|
|
603
620
|
componentOverrides,
|
|
604
621
|
inheritedFromParent,
|
|
622
|
+
autoFocusFirstRequired,
|
|
605
623
|
...rest
|
|
606
624
|
}: IFormEngineProps) => {
|
|
607
625
|
const [options, setOptions] = useState<IQorusFormSchema | undefined>(rest?.options || undefined);
|
|
@@ -1517,6 +1535,94 @@ export const FormEngine = ({
|
|
|
1517
1535
|
[expandMode]
|
|
1518
1536
|
);
|
|
1519
1537
|
|
|
1538
|
+
// --- First-attention-field autofocus (opt-in) -----------------------------
|
|
1539
|
+
// With `autoFocusFirstRequired`, drop the user straight into the first field
|
|
1540
|
+
// they must fix. We reuse the engine's own ordering (`availableOptions`), the
|
|
1541
|
+
// very same `getOptionBucket` the status boxes use to decide "needs attention"
|
|
1542
|
+
// (so we cover empty-required, unsatisfied one-of, AND filled-but-invalid rows
|
|
1543
|
+
// without ever drifting from what the user sees), and dependency gating
|
|
1544
|
+
// (`dependencyLockedNames`), then expand the target row through
|
|
1545
|
+
// `expandedOptions` — the row's editor-focus effect does the rest. No DOM
|
|
1546
|
+
// scraping, no synthetic clicks, no polling.
|
|
1547
|
+
//
|
|
1548
|
+
// Strictly one-shot: it fires the first time the form has focusable content
|
|
1549
|
+
// (so an async-loaded schema is still covered — the empty first pass doesn't
|
|
1550
|
+
// count), then never again for the life of this instance. A server-driven
|
|
1551
|
+
// field update or an in-place schema reload will NOT re-grab focus; only a
|
|
1552
|
+
// genuine remount (a fresh instance) auto-focuses again, which is the intended
|
|
1553
|
+
// on-mount behaviour.
|
|
1554
|
+
const hasAutoFocusedRef = useRef(false);
|
|
1555
|
+
// The field expanded programmatically for autofocus. A ref (not state) so
|
|
1556
|
+
// CompactRow's 60ms focus timer reads the current value regardless of render
|
|
1557
|
+
// batching; CompactRow focuses this one with `preventScroll` so an off-screen
|
|
1558
|
+
// (or below-the-fold) form is never scrolled into view on mount.
|
|
1559
|
+
const autoFocusNameRef = useRef<string | undefined>(undefined);
|
|
1560
|
+
useEffect(() => {
|
|
1561
|
+
if (!autoFocusFirstRequired || !compact || !options) {
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
if (hasAutoFocusedRef.current) {
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const orderedNames = Object.keys(availableOptions);
|
|
1569
|
+
if (!orderedNames.length) {
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Never grab focus from a control the user is already in — including a field
|
|
1574
|
+
// *inside* this form. If a slow/async schema lets the user start typing
|
|
1575
|
+
// before this first-required scan runs, we must not steal their caret. On a
|
|
1576
|
+
// clean mount focus rests on the body, so the intended "drop into the first
|
|
1577
|
+
// field" still fires. Bailing here leaves `hasAutoFocusedRef` false, so it
|
|
1578
|
+
// retries once focus is free.
|
|
1579
|
+
const active = document.activeElement as HTMLElement | null;
|
|
1580
|
+
if (active && active !== document.body) {
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const target = getFirstAttentionOptionName(orderedNames, (fieldName) => {
|
|
1585
|
+
const schema = options[fieldName];
|
|
1586
|
+
if (!schema) {
|
|
1587
|
+
return undefined;
|
|
1588
|
+
}
|
|
1589
|
+
return {
|
|
1590
|
+
focusable:
|
|
1591
|
+
!readOnly &&
|
|
1592
|
+
!schema.disabled &&
|
|
1593
|
+
!(schema as { readonly?: boolean }).readonly &&
|
|
1594
|
+
!dependencyLockedNames.includes(fieldName),
|
|
1595
|
+
// Single source of truth: the same bucket the read-first status boxes
|
|
1596
|
+
// show, so a filled-but-invalid required row is a target too.
|
|
1597
|
+
needsAttention: getOptionBucket(fieldName) === 'attention',
|
|
1598
|
+
};
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
// Mark done even when nothing needs attention, so later value edits or an
|
|
1602
|
+
// in-place schema reload never re-scan or re-focus. Only a remount arms it
|
|
1603
|
+
// again.
|
|
1604
|
+
hasAutoFocusedRef.current = true;
|
|
1605
|
+
|
|
1606
|
+
if (target) {
|
|
1607
|
+
// Set the ref before the state update so CompactRow's focus timer sees it.
|
|
1608
|
+
autoFocusNameRef.current = target;
|
|
1609
|
+
setExpandedOptions((prev) =>
|
|
1610
|
+
prev.includes(target) ? prev
|
|
1611
|
+
: expandMode === 'multi' ? [...prev, target]
|
|
1612
|
+
: [target]
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
}, [
|
|
1616
|
+
autoFocusFirstRequired,
|
|
1617
|
+
compact,
|
|
1618
|
+
options,
|
|
1619
|
+
availableOptions,
|
|
1620
|
+
readOnly,
|
|
1621
|
+
dependencyLockedNames,
|
|
1622
|
+
getOptionBucket,
|
|
1623
|
+
expandMode,
|
|
1624
|
+
]);
|
|
1625
|
+
|
|
1520
1626
|
// Read-first completion summary (how many shown options have a value set),
|
|
1521
1627
|
// surfaced as a progress meter at the top of the compact form.
|
|
1522
1628
|
const readFirstCompletion = useMemo(
|
|
@@ -1871,6 +1977,7 @@ export const FormEngine = ({
|
|
|
1871
1977
|
showFieldTypes,
|
|
1872
1978
|
showAllDescriptions,
|
|
1873
1979
|
expandedOptions,
|
|
1980
|
+
autoFocusNameRef,
|
|
1874
1981
|
highlightedOptions,
|
|
1875
1982
|
flashedOptions,
|
|
1876
1983
|
infoPanelOverrides,
|
|
@@ -1915,6 +2022,7 @@ export const FormEngine = ({
|
|
|
1915
2022
|
showFieldTypes,
|
|
1916
2023
|
showAllDescriptions,
|
|
1917
2024
|
expandedOptions,
|
|
2025
|
+
autoFocusNameRef,
|
|
1918
2026
|
highlightedOptions,
|
|
1919
2027
|
flashedOptions,
|
|
1920
2028
|
infoPanelOverrides,
|
|
@@ -34,6 +34,12 @@ export interface ICompactRowContext {
|
|
|
34
34
|
// display name in the read-first chip.
|
|
35
35
|
templates?: IReqoreFormTemplates;
|
|
36
36
|
expandedOptions: string[];
|
|
37
|
+
// Name of the field the engine expanded programmatically for
|
|
38
|
+
// `autoFocusFirstRequired`. CompactRow focuses this one with `preventScroll`
|
|
39
|
+
// so an off-screen / below-the-fold form is never scrolled into view on
|
|
40
|
+
// mount. A ref (not state) so it's current when the 60ms focus timer fires,
|
|
41
|
+
// regardless of render batching.
|
|
42
|
+
autoFocusNameRef?: MutableRefObject<string | undefined>;
|
|
37
43
|
highlightedOptions: string[];
|
|
38
44
|
flashedOptions: string[];
|
|
39
45
|
infoPanelOverrides: Record<string, boolean>;
|
|
@@ -335,6 +335,27 @@ export const StyledRowInset = styled.div`
|
|
|
335
335
|
margin-top: 4px;
|
|
336
336
|
`;
|
|
337
337
|
|
|
338
|
+
// A collapsed code-block preview shown under the value summary for a
|
|
339
|
+
// `code-editor` field. Multi-line, monospace, subtle background — matches
|
|
340
|
+
// the aesthetic of the classic code-view surface but small enough to sit
|
|
341
|
+
// inside a read-row. Height is capped by the wrapping `ReqoreCollapsibleContent`
|
|
342
|
+
// so the "Show more" affordance stays useful.
|
|
343
|
+
export const StyledCodePreview = styled.pre<{ $bg: string; $border: string; $fg: string }>`
|
|
344
|
+
margin: 0;
|
|
345
|
+
padding: 8px 10px;
|
|
346
|
+
border-radius: 4px;
|
|
347
|
+
background: ${({ $bg }) => $bg};
|
|
348
|
+
border: 1px solid ${({ $border }) => $border};
|
|
349
|
+
color: ${({ $fg }) => $fg};
|
|
350
|
+
font-family:
|
|
351
|
+
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
|
352
|
+
font-size: 11.5px;
|
|
353
|
+
line-height: 1.5;
|
|
354
|
+
white-space: pre;
|
|
355
|
+
overflow: auto;
|
|
356
|
+
max-width: 100%;
|
|
357
|
+
`;
|
|
358
|
+
|
|
338
359
|
// A small inline colour swatch shown before an rgbcolor value's hex string.
|
|
339
360
|
export const StyledColorSwatch = styled.span<{ $color: string; $border: string }>`
|
|
340
361
|
width: 12px;
|
|
@@ -502,3 +502,45 @@ export const getReadFirstCompletion = (
|
|
|
502
502
|
|
|
503
503
|
return { total, set, pct };
|
|
504
504
|
};
|
|
505
|
+
|
|
506
|
+
/** What the "first field to fix" selector needs to know about one field.
|
|
507
|
+
* Kept deliberately minimal — both verdicts are supplied by the caller — so the
|
|
508
|
+
* selector is decoupled from the engine's value/schema shapes and unit-testable
|
|
509
|
+
* in isolation. */
|
|
510
|
+
export interface IFirstAttentionFieldMeta {
|
|
511
|
+
/** Whether the field can actually receive focus — the caller folds in
|
|
512
|
+
* disabled / readonly / read-only-form / unmet-dependency gating here. */
|
|
513
|
+
focusable: boolean;
|
|
514
|
+
/** Whether the form considers this field to "need attention". The caller
|
|
515
|
+
* supplies this from the SAME `getOptionBucket` the read-first status boxes
|
|
516
|
+
* use, so it covers every attention case — empty-required, an unsatisfied
|
|
517
|
+
* one-of group, AND a filled-but-invalid value — and the autofocus target can
|
|
518
|
+
* never drift from the visible needs-attention set. */
|
|
519
|
+
needsAttention: boolean;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Pick the first field the user must fix: the first focusable field — in the
|
|
524
|
+
* supplied (already ordered) name list — that the form buckets as "needs
|
|
525
|
+
* attention".
|
|
526
|
+
*
|
|
527
|
+
* Pure and side-effect free. It deliberately does NOT re-derive emptiness /
|
|
528
|
+
* required / validity itself; the caller passes `needsAttention` straight from
|
|
529
|
+
* the engine's own `getOptionBucket`, so there is a single source of truth and
|
|
530
|
+
* the "focus the first field to fix" affordance can never disagree with the
|
|
531
|
+
* per-row status the user sees (including filled-but-invalid rows). Returns
|
|
532
|
+
* `undefined` when nothing needs attention.
|
|
533
|
+
*/
|
|
534
|
+
export const getFirstAttentionOptionName = (
|
|
535
|
+
orderedNames: string[],
|
|
536
|
+
getFieldMeta: (name: string) => IFirstAttentionFieldMeta | undefined
|
|
537
|
+
): string | undefined => {
|
|
538
|
+
for (const name of orderedNames) {
|
|
539
|
+
const meta = getFieldMeta(name);
|
|
540
|
+
if (meta?.focusable && meta.needsAttention) {
|
|
541
|
+
return name;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return undefined;
|
|
546
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { ReqoreControlGroup } from '@qoretechnologies/reqore';
|
|
2
|
+
import { StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
|
|
5
|
+
|
|
6
|
+
import { StoryMeta } from '../../../../types';
|
|
7
|
+
import { ReqraftBinaryFormField } from './Binary';
|
|
8
|
+
|
|
9
|
+
const meta = {
|
|
10
|
+
component: ReqraftBinaryFormField,
|
|
11
|
+
title: 'Components/Form/Binary',
|
|
12
|
+
args: {
|
|
13
|
+
onChange: fn(),
|
|
14
|
+
'aria-label': 'Binary',
|
|
15
|
+
},
|
|
16
|
+
render(args) {
|
|
17
|
+
const [value, setValue] = useState(args.value);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<ReqoreControlGroup>
|
|
21
|
+
<ReqraftBinaryFormField
|
|
22
|
+
{...args}
|
|
23
|
+
value={value}
|
|
24
|
+
onChange={(value) => {
|
|
25
|
+
args.onChange?.(value);
|
|
26
|
+
setValue(value);
|
|
27
|
+
}}
|
|
28
|
+
/>
|
|
29
|
+
</ReqoreControlGroup>
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
} as StoryMeta<typeof ReqraftBinaryFormField>;
|
|
33
|
+
|
|
34
|
+
export default meta;
|
|
35
|
+
type Story = StoryObj<typeof meta>;
|
|
36
|
+
|
|
37
|
+
export const WithValue: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
value: 'cGFzc3dvcmQ=',
|
|
40
|
+
},
|
|
41
|
+
async play({ canvasElement }) {
|
|
42
|
+
const canvas = within(canvasElement);
|
|
43
|
+
const textarea = canvas.getByLabelText('Binary');
|
|
44
|
+
|
|
45
|
+
await expect(textarea).toBeInTheDocument();
|
|
46
|
+
await expect(textarea).toHaveValue('cGFzc3dvcmQ=');
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const TypingUpdatesValue: Story = {
|
|
51
|
+
async play({ canvasElement, args }) {
|
|
52
|
+
const canvas = within(canvasElement);
|
|
53
|
+
const textarea = canvas.getByLabelText('Binary');
|
|
54
|
+
|
|
55
|
+
await userEvent.type(textarea, 'YWJj');
|
|
56
|
+
await expect(textarea).toHaveValue('YWJj');
|
|
57
|
+
await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith('YWJj'), {
|
|
58
|
+
timeout: 500,
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const UploadEncodesFileAsBase64: Story = {
|
|
64
|
+
async play({ canvasElement, args }) {
|
|
65
|
+
const file = new File(['hello'], 'test.bin', { type: 'application/octet-stream' });
|
|
66
|
+
// the hidden file input rendered by the reused ReqraftFileFormField drop zone
|
|
67
|
+
const input = canvasElement.querySelector('input[type="file"]') as HTMLInputElement;
|
|
68
|
+
|
|
69
|
+
await expect(input).toBeInTheDocument();
|
|
70
|
+
await userEvent.upload(input, file);
|
|
71
|
+
|
|
72
|
+
// base64 of "hello" is "aGVsbG8="; the data: URL prefix must be stripped
|
|
73
|
+
await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith('aGVsbG8='), {
|
|
74
|
+
timeout: 1000,
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const Disabled: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
value: 'cGFzc3dvcmQ=',
|
|
82
|
+
disabled: true,
|
|
83
|
+
},
|
|
84
|
+
async play({ canvasElement }) {
|
|
85
|
+
const canvas = within(canvasElement);
|
|
86
|
+
const textarea = canvas.getByLabelText('Binary');
|
|
87
|
+
|
|
88
|
+
await expect(textarea).toBeDisabled();
|
|
89
|
+
},
|
|
90
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ReqoreControlGroup, ReqoreTextarea } from '@qoretechnologies/reqore';
|
|
2
|
+
import { IReqoreTextareaProps } from '@qoretechnologies/reqore/dist/components/Textarea';
|
|
3
|
+
import { ChangeEvent, memo, useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { useDebounce } from 'react-use';
|
|
5
|
+
import { IFileFormFieldValue, ReqraftFileFormField } from '../file/File';
|
|
6
|
+
|
|
7
|
+
export interface IReqraftBinaryFormFieldProps extends Omit<IReqoreTextareaProps, 'onChange' | 'value'> {
|
|
8
|
+
value?: string;
|
|
9
|
+
onChange?: (value: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Strip a `data:<mime-type>;base64,` prefix so the emitted value carries raw base64, matching the
|
|
14
|
+
* canonical binary wire form the Qorus server expects. `ReqraftFileFormField` reads uploads with
|
|
15
|
+
* `readAsDataURL`, so its `content` always carries that prefix.
|
|
16
|
+
*/
|
|
17
|
+
const stripDataUrlPrefix = (dataUrl: string): string => {
|
|
18
|
+
const commaIndex = dataUrl.indexOf(',');
|
|
19
|
+
return commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A field for binary values encoded as base64. The value can be typed or pasted directly into the
|
|
24
|
+
* textarea, or populated by uploading a file — the upload reuses {@link ReqraftFileFormField} (the
|
|
25
|
+
* shared Reqraft file picker / drop zone) rather than a bespoke dropzone; its data-URL content is
|
|
26
|
+
* decoded to raw base64 for the field value.
|
|
27
|
+
*/
|
|
28
|
+
export const ReqraftBinaryFormField = memo(
|
|
29
|
+
({ value, onChange, ...rest }: IReqraftBinaryFormFieldProps) => {
|
|
30
|
+
const [localValue, setLocalValue] = useState<string>(value ?? '');
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (value !== localValue) {
|
|
34
|
+
setLocalValue(value ?? '');
|
|
35
|
+
}
|
|
36
|
+
}, [value]);
|
|
37
|
+
|
|
38
|
+
useDebounce(
|
|
39
|
+
() => {
|
|
40
|
+
if (localValue !== value) {
|
|
41
|
+
onChange?.(localValue);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
100,
|
|
45
|
+
[localValue, onChange]
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const handleChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>): void => {
|
|
49
|
+
setLocalValue(event.target.value);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const handleFileChange = useCallback(
|
|
53
|
+
(file?: IFileFormFieldValue) => {
|
|
54
|
+
const base64 = stripDataUrlPrefix(file?.content ?? '');
|
|
55
|
+
setLocalValue(base64);
|
|
56
|
+
onChange?.(base64);
|
|
57
|
+
},
|
|
58
|
+
[onChange]
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<ReqoreControlGroup vertical fluid>
|
|
63
|
+
<ReqoreTextarea
|
|
64
|
+
scaleWithContent
|
|
65
|
+
fluid
|
|
66
|
+
value={localValue}
|
|
67
|
+
onChange={handleChange}
|
|
68
|
+
onClearClick={() => {
|
|
69
|
+
setLocalValue('');
|
|
70
|
+
onChange?.('');
|
|
71
|
+
}}
|
|
72
|
+
placeholder='Base64-encoded binary value'
|
|
73
|
+
{...rest}
|
|
74
|
+
/>
|
|
75
|
+
<ReqraftFileFormField disabled={rest.disabled} onChange={handleFileChange} />
|
|
76
|
+
</ReqoreControlGroup>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
export default ReqraftBinaryFormField;
|
package/src/index.tsx
CHANGED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
export interface IAttachmentChipsProps {
|
|
2
|
-
files: File[];
|
|
3
|
-
onRemove: (index: number) => void;
|
|
4
|
-
disabled?: boolean;
|
|
5
|
-
}
|
|
6
|
-
export declare const AttachmentChips: ({ files, onRemove, disabled }: IAttachmentChipsProps) => import("react/jsx-runtime").JSX.Element;
|
|
7
|
-
//# sourceMappingURL=AttachmentChips.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"AttachmentChips.d.ts","sourceRoot":"","sources":["../../../src/components/supportTicket/AttachmentChips.tsx"],"names":[],"mappings":"AAWA,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,eAAO,MAAM,eAAe,kCAAmC,qBAAqB,4CAgCnF,CAAC"}
|