@qoretechnologies/reqraft 0.10.51 → 0.10.52

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.
Files changed (50) hide show
  1. package/dist/components/form/expressions/ExpressionField.d.ts +4 -2
  2. package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
  3. package/dist/components/form/expressions/ExpressionField.js +2 -2
  4. package/dist/components/form/expressions/ExpressionField.js.map +1 -1
  5. package/dist/components/form/expressions/builder/argumentWrapper.d.ts +13 -2
  6. package/dist/components/form/expressions/builder/argumentWrapper.d.ts.map +1 -1
  7. package/dist/components/form/expressions/builder/argumentWrapper.js +61 -3
  8. package/dist/components/form/expressions/builder/argumentWrapper.js.map +1 -1
  9. package/dist/components/form/expressions/builder/index.d.ts +4 -2
  10. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  11. package/dist/components/form/expressions/builder/index.js +96 -15
  12. package/dist/components/form/expressions/builder/index.js.map +1 -1
  13. package/dist/components/form/expressions/builder/moveToPositionStrip.d.ts +19 -0
  14. package/dist/components/form/expressions/builder/moveToPositionStrip.d.ts.map +1 -0
  15. package/dist/components/form/expressions/builder/moveToPositionStrip.js +62 -0
  16. package/dist/components/form/expressions/builder/moveToPositionStrip.js.map +1 -0
  17. package/dist/components/form/expressions/mockExpressions.d.ts.map +1 -1
  18. package/dist/components/form/expressions/mockExpressions.js +18 -0
  19. package/dist/components/form/expressions/mockExpressions.js.map +1 -1
  20. package/dist/components/form/expressions/types.d.ts +8 -0
  21. package/dist/components/form/expressions/types.d.ts.map +1 -1
  22. package/dist/components/form/fields/schema-definition/CatalogNodeEditor.js +3 -2
  23. package/dist/components/form/fields/schema-definition/CatalogNodeEditor.js.map +1 -1
  24. package/dist/components/form/fields/schema-definition/MigrationsTab.js +3 -3
  25. package/dist/components/form/fields/schema-definition/MigrationsTab.js.map +1 -1
  26. package/dist/components/form/fields/schema-definition/helpers.d.ts +0 -2
  27. package/dist/components/form/fields/schema-definition/helpers.d.ts.map +1 -1
  28. package/dist/components/form/fields/schema-definition/helpers.js +1 -20
  29. package/dist/components/form/fields/schema-definition/helpers.js.map +1 -1
  30. package/dist/components/form/fields/template/TemplateField.d.ts +33 -4
  31. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  32. package/dist/components/form/fields/template/TemplateField.js +57 -7
  33. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  34. package/dist/helpers/common.d.ts +4 -0
  35. package/dist/helpers/common.d.ts.map +1 -1
  36. package/dist/helpers/common.js +30 -1
  37. package/dist/helpers/common.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/components/form/expressions/ExpressionField.tsx +5 -1
  40. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +386 -0
  41. package/src/components/form/expressions/builder/argumentWrapper.tsx +137 -11
  42. package/src/components/form/expressions/builder/index.tsx +153 -5
  43. package/src/components/form/expressions/builder/moveToPositionStrip.tsx +156 -0
  44. package/src/components/form/expressions/mockExpressions.ts +18 -0
  45. package/src/components/form/expressions/types.ts +10 -0
  46. package/src/components/form/fields/schema-definition/CatalogNodeEditor.tsx +1 -1
  47. package/src/components/form/fields/schema-definition/MigrationsTab.tsx +1 -1
  48. package/src/components/form/fields/schema-definition/helpers.ts +0 -9
  49. package/src/components/form/fields/template/TemplateField.tsx +112 -4
  50. package/src/helpers/common.ts +31 -0
@@ -601,6 +601,392 @@ export const VariableArgumentsCanBeAdded: Story = {
601
601
  },
602
602
  };
603
603
 
604
+ // --- Varargs reorder family -------------------------------------------------
605
+ // Toolkit-only (no IDE counterpart): a varargs expression lets the user move
606
+ // its operands. `reorder` picks the surface — the field's ⋮ menu by default,
607
+ // a drag grip and/or a position dropdown on request. Seeded on `concat` so
608
+ // the operand order is readable.
609
+
610
+ const CONCAT_OPERANDS = ['first', 'second', 'third'];
611
+ const OPERAND_LITERALS = [...CONCAT_OPERANDS, 'plain', '$local:some-richtext'];
612
+
613
+ /** Seeded operand values in DOM order — only the seeded ones, so stray fields don't count. */
614
+ const operandValues = () =>
615
+ Array.from(
616
+ document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(
617
+ '.expression textarea, .expression input'
618
+ )
619
+ )
620
+ .map((field) => field.value)
621
+ .filter((value) => OPERAND_LITERALS.includes(value));
622
+
623
+ const count = (selector: string) => document.querySelectorAll(selector).length;
624
+
625
+ /** Open the `⋮` menu of the nth operand field and expand its "Move Argument" section. */
626
+ const openMoveMenu = async (nth: number) => {
627
+ await clickSelector('.template-more', nth);
628
+ await waitForText('Move Argument');
629
+ // The section's className lands on its toggle button.
630
+ await clickSelector('.template-menu-actions');
631
+ };
632
+
633
+ /** Open the `⋮` menu of the nth operand field. */
634
+ const openFieldMenu = (nth: number) => clickSelector('.template-more', nth);
635
+
636
+ const waitForOrder = (order: string[]) =>
637
+ waitFor(() => expect(operandValues()).toEqual(order), { timeout: 10000 });
638
+
639
+ const expectReportedOrder = (context: { args: { onChange?: unknown } }, order: string[]) =>
640
+ expect(context.args.onChange).toHaveBeenLastCalledWith(
641
+ expect.objectContaining({
642
+ value: expect.objectContaining({
643
+ exp: 'concat',
644
+ args: order.map((value) => ({ type: 'string', value })),
645
+ }),
646
+ })
647
+ );
648
+
649
+ const concatArgs: Story['args'] = {
650
+ type: 'string',
651
+ returnType: 'string',
652
+ value: {
653
+ value: {
654
+ exp: 'concat',
655
+ args: CONCAT_OPERANDS.map((value) => ({ type: 'string', value })),
656
+ },
657
+ is_expression: true,
658
+ },
659
+ };
660
+
661
+ /** Base: a `concat` varargs expression with three string operands, default reorder surfaces. */
662
+ export const ConcatExpression: Story = {
663
+ args: concatArgs,
664
+ parameters: {
665
+ docs: {
666
+ description: {
667
+ story:
668
+ 'Renders a "concat" varargs expression with three string operands ("first", "second", "third"). By default each operand gets a drag grip and a "Move Argument" section in its ⋮ menu — opened and expanded here on the second operand to show "Move before / after / to start / to end" and, at three operands, a "Move to position" caption with a single "2nd" position dropdown beside it, opened to show 1st / 2nd / 3rd.',
669
+ },
670
+ },
671
+ },
672
+ play: async () => {
673
+ await waitFor(() => expect(expressionCount()).toBe(1), { timeout: 10000 });
674
+ await waitForOrder(CONCAT_OPERANDS);
675
+ expect(removeArgCount()).toBe(2);
676
+ expect(count('.expression-arg-drag-handle')).toBe(3);
677
+ expect(count('.expression-arg-position')).toBe(0);
678
+
679
+ await openMoveMenu(1);
680
+ await waitForText('Move before');
681
+ expect(count('.expression-arg-move-after')).toBe(1);
682
+ expect(count('.expression-arg-move-start')).toBe(1);
683
+ expect(count('.expression-arg-move-end')).toBe(1);
684
+ expect(count('.expression-arg-move-to')).toBe(1);
685
+ // Three operands: one position dropdown — no segments, no typed field, no "More".
686
+ expect(count('.expression-arg-move-to-picker')).toBe(1);
687
+ expect(count('.expression-arg-move-to-input')).toBe(0);
688
+ expect(count('.expression-arg-move-to-more')).toBe(0);
689
+ // Open the picker so the capture shows its three positions.
690
+ await clickSelector('.expression-arg-move-to-picker');
691
+ await waitForText('3rd');
692
+ },
693
+ };
694
+
695
+ const LONG_OPERANDS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh'];
696
+
697
+ /** Seven operands: the strip grows a typed field and a "More" list past the four segments. */
698
+ export const ReorderLongConcat: Story = {
699
+ args: {
700
+ ...concatArgs,
701
+ value: {
702
+ value: {
703
+ exp: 'concat',
704
+ args: LONG_OPERANDS.map((value) => ({ type: 'string', value })),
705
+ },
706
+ is_expression: true,
707
+ },
708
+ },
709
+ parameters: {
710
+ docs: {
711
+ description: {
712
+ story:
713
+ 'Renders a seven-operand "concat": typing 6 and Enter into the second operand\'s "Move to position" field moves it to sixth, "More → 7th" moves it last, and the menu is then reopened on that operand with "Move Argument" expanded and "More" open — the strip shows the segments 1–4, the number field for 5–7 with its arrow, and the list 5th / 6th / 7th with 7th (the current position) disabled.',
714
+ },
715
+ },
716
+ },
717
+ play: async (context) => {
718
+ await waitFor(() => expect(expressionCount()).toBe(1), { timeout: 10000 });
719
+ await waitFor(() => expect(count('.expression-arg-drag-handle')).toBe(7), { timeout: 10000 });
720
+
721
+ await openMoveMenu(1);
722
+ await waitForText('Move to position');
723
+ expect(count('.expression-arg-move-to-4')).toBe(1);
724
+ expect(count('.expression-arg-move-to-input')).toBe(1);
725
+ expect(count('.expression-arg-move-to-more')).toBe(1);
726
+
727
+ // change + keyDown rather than `userEvent.type`: the typing helper also
728
+ // emits hover events, which React bubbles through the popover's portal to
729
+ // the panel and raises its floating "Wrap" layer over the capture.
730
+ const field = document.querySelector<HTMLInputElement>('.expression-arg-move-to-input')!;
731
+ await fireEvent.change(field, { target: { value: '6' } });
732
+ await fireEvent.keyDown(field, { key: 'Enter' });
733
+ await waitFor(
734
+ () =>
735
+ expect(
736
+ Array.from(
737
+ document.querySelectorAll<HTMLTextAreaElement>('.expression textarea')
738
+ ).map((t) => t.value)
739
+ ).toEqual(['first', 'third', 'fourth', 'fifth', 'sixth', 'second', 'seventh']),
740
+ { timeout: 10000 }
741
+ );
742
+
743
+ await openMoveMenu(5);
744
+ await waitForText('Move to position');
745
+ await clickSelector('.expression-arg-move-to-more');
746
+ await waitForText('7th');
747
+ await clickSelector('.expression-arg-move-to-7');
748
+ await waitFor(
749
+ () =>
750
+ expect(
751
+ Array.from(
752
+ document.querySelectorAll<HTMLTextAreaElement>('.expression textarea')
753
+ ).map((t) => t.value)
754
+ ).toEqual(['first', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'second']),
755
+ { timeout: 10000 }
756
+ );
757
+ expect(context.args.onChange).toHaveBeenLastCalledWith(
758
+ expect.objectContaining({
759
+ value: expect.objectContaining({
760
+ args: ['first', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'second'].map(
761
+ (value) => ({ type: 'string', value })
762
+ ),
763
+ }),
764
+ })
765
+ );
766
+
767
+ // Leave the strip and its "More" list on screen for the capture.
768
+ await openMoveMenu(6);
769
+ await waitForText('Move to position');
770
+ await clickSelector('.expression-arg-move-to-more');
771
+ await waitForText('5th');
772
+ // A remounted operand refetches the expression catalogue; the "Use
773
+ // Expression" row is a skeleton until that settles, so wait it out.
774
+ await waitFor(() => expect(count('.reqore-skeleton')).toBe(0), { timeout: 30000 });
775
+ },
776
+ };
777
+
778
+ /** `reorder: ['overflowMenu']` — "Move to start", "Move after", then "Move to position". */
779
+ export const ReorderViaOverflowMenu: Story = {
780
+ args: { ...concatArgs, reorder: ['overflowMenu'] },
781
+ parameters: {
782
+ docs: {
783
+ description: {
784
+ story:
785
+ 'Renders the "concat" expression with only the ⋮ menu surface (no grip) and reorders it through the "Move Argument" section: "Move to start" on the third operand, "Move after" on the operand that is now second, then "3rd" from the "Move to position" dropdown on the first — the order goes first/second/third → third/first/second → third/second/first → second/first/third, each move closes its menu, and onChange reports the reordered args.',
786
+ },
787
+ },
788
+ },
789
+ play: async (context) => {
790
+ await waitFor(() => expect(expressionCount()).toBe(1), { timeout: 10000 });
791
+ await waitForOrder(CONCAT_OPERANDS);
792
+ expect(count('.expression-arg-drag-handle')).toBe(0);
793
+
794
+ await openMoveMenu(2);
795
+ await waitForText('Move to start');
796
+ await clickSelector('.expression-arg-move-start');
797
+ await waitForOrder(['third', 'first', 'second']);
798
+ // A move closes the menu it came from.
799
+ await waitFor(() => expect(count('.expression-arg-move-start')).toBe(0), { timeout: 10000 });
800
+
801
+ await openMoveMenu(1);
802
+ await waitForText('Move after');
803
+ await clickSelector('.expression-arg-move-after');
804
+ await waitForOrder(['third', 'second', 'first']);
805
+
806
+ await openMoveMenu(0);
807
+ await waitForText('Move to position');
808
+ await clickSelector('.expression-arg-move-to-picker');
809
+ await waitForText('3rd');
810
+ await clickSelector('.expression-arg-move-to-3');
811
+ await waitForOrder(['second', 'first', 'third']);
812
+
813
+ expectReportedOrder(context, ['second', 'first', 'third']);
814
+ },
815
+ };
816
+
817
+ /** `reorder: ['dragHandle']` — a grip before each operand, no menu rows. */
818
+ export const ReorderViaDragHandle: Story = {
819
+ args: { ...concatArgs, reorder: ['dragHandle'] },
820
+ parameters: {
821
+ docs: {
822
+ description: {
823
+ story:
824
+ 'Renders the "concat" expression with a drag grip before each operand and no "Move Argument" section in the ⋮ menu. Dragging the third operand onto the first puts it first — the order becomes third/first/second.',
825
+ },
826
+ },
827
+ },
828
+ play: async (context) => {
829
+ await waitFor(() => expect(expressionCount()).toBe(1), { timeout: 10000 });
830
+ await waitForOrder(CONCAT_OPERANDS);
831
+ expect(count('.expression-arg-drag-handle')).toBe(3);
832
+ expect(count('.expression-arg-position')).toBe(0);
833
+
834
+ await openFieldMenu(1);
835
+ await waitForText('Use Template');
836
+ expect(count('.template-menu-actions')).toBe(0);
837
+
838
+ const handles = document.querySelectorAll('.expression-arg-drag-handle');
839
+ const target = handles[0].closest('.expression-arg')!;
840
+ expect(handles[2].getAttribute('draggable')).toBe('true');
841
+ await fireEvent.dragStart(handles[2]);
842
+ await fireEvent.dragOver(target);
843
+ await fireEvent.drop(target);
844
+ await fireEvent.dragEnd(handles[2]);
845
+
846
+ await waitForOrder(['third', 'first', 'second']);
847
+ expectReportedOrder(context, ['third', 'first', 'second']);
848
+ },
849
+ };
850
+
851
+ /** A template operand moved past a literal: each field shows the operand now in its slot. */
852
+ export const ReorderKeepsTemplateOperand: Story = {
853
+ args: {
854
+ ...concatArgs,
855
+ value: {
856
+ value: {
857
+ exp: 'concat',
858
+ args: [
859
+ { type: 'string', value: '$local:some-richtext' },
860
+ { type: 'string', value: 'plain' },
861
+ ],
862
+ },
863
+ is_expression: true,
864
+ },
865
+ },
866
+ parameters: {
867
+ docs: {
868
+ description: {
869
+ story:
870
+ 'Renders a "concat" (default surfaces: grip and ⋮ menu) whose first operand is the template reference "$local:some-richtext" and whose second is the literal "plain". "Move to end" on the template operand swaps them — the literal now sits first and the reference second, each in its own field, and nothing shows the reference twice.',
871
+ },
872
+ },
873
+ },
874
+ play: async (context) => {
875
+ await waitFor(() => expect(expressionCount()).toBe(1), { timeout: 10000 });
876
+ await waitForOrder(['$local:some-richtext', 'plain']);
877
+
878
+ await openMoveMenu(0);
879
+ await waitForText('Move to end');
880
+ await clickSelector('.expression-arg-move-end');
881
+
882
+ // Without remounting the slots, slot 0 would keep its template state and
883
+ // still show the reference while the literal showed nowhere.
884
+ await waitForOrder(['plain', '$local:some-richtext']);
885
+ expect(context.args.onChange).toHaveBeenLastCalledWith(
886
+ expect.objectContaining({
887
+ value: expect.objectContaining({
888
+ exp: 'concat',
889
+ args: [
890
+ { type: 'string', value: 'plain' },
891
+ { type: 'string', value: '$local:some-richtext' },
892
+ ],
893
+ }),
894
+ })
895
+ );
896
+ },
897
+ };
898
+
899
+ /** `reorder: ['positionPicker']` — an ordinal dropdown before each operand. */
900
+ export const ReorderViaPositionPicker: Story = {
901
+ args: { ...concatArgs, reorder: ['positionPicker'] },
902
+ parameters: {
903
+ docs: {
904
+ description: {
905
+ story:
906
+ 'Renders the "concat" expression with each operand\'s position ("1st", "2nd", "3rd") as a dropdown before its field. Picking "1st" on the third operand moves it to the front — the order becomes third/first/second.',
907
+ },
908
+ },
909
+ },
910
+ play: async (context) => {
911
+ await waitFor(() => expect(expressionCount()).toBe(1), { timeout: 10000 });
912
+ await waitForOrder(CONCAT_OPERANDS);
913
+ expect(count('.expression-arg-position')).toBe(3);
914
+ expect(count('.expression-arg-drag-handle')).toBe(0);
915
+
916
+ await clickSelector('.expression-arg-position', 2);
917
+ // The open list adds a second "1st" — the first is the first operand's own trigger.
918
+ await waitFor(() => expect(within(document.body).queryAllByText('1st').length).toBe(2), {
919
+ timeout: 10000,
920
+ });
921
+ const items = within(document.body).queryAllByText('1st');
922
+ await fireEvent.click(items[items.length - 1]);
923
+
924
+ await waitForOrder(['third', 'first', 'second']);
925
+ expectReportedOrder(context, ['third', 'first', 'second']);
926
+ },
927
+ };
928
+
929
+ /** Every surface at once. */
930
+ export const ReorderAllSurfaces: Story = {
931
+ args: { ...concatArgs, reorder: ['overflowMenu', 'dragHandle', 'positionPicker'] },
932
+ parameters: {
933
+ docs: {
934
+ description: {
935
+ story:
936
+ 'Renders the "concat" expression with every reorder surface at once — a drag grip and a position dropdown before each operand, plus the "Move Argument" section in the ⋮ menu, opened and expanded on the second operand.',
937
+ },
938
+ },
939
+ },
940
+ play: async () => {
941
+ await waitFor(() => expect(expressionCount()).toBe(1), { timeout: 10000 });
942
+ await waitForOrder(CONCAT_OPERANDS);
943
+ expect(count('.expression-arg-drag-handle')).toBe(3);
944
+ expect(count('.expression-arg-position')).toBe(3);
945
+
946
+ await openMoveMenu(1);
947
+ await waitForText('Move before');
948
+ },
949
+ };
950
+
951
+ /** `reorder: false` — nothing but the remove buttons. */
952
+ export const ReorderDisabled: Story = {
953
+ args: { ...concatArgs, reorder: false },
954
+ parameters: {
955
+ docs: {
956
+ description: {
957
+ story:
958
+ 'Renders the "concat" expression with reordering turned off — no grip, no position dropdown, and the ⋮ menu (opened on the second operand) has no "Move Argument" section; only the remove buttons remain.',
959
+ },
960
+ },
961
+ },
962
+ play: async () => {
963
+ await waitFor(() => expect(expressionCount()).toBe(1), { timeout: 10000 });
964
+ await waitForOrder(CONCAT_OPERANDS);
965
+ expect(removeArgCount()).toBe(2);
966
+ expect(count('.expression-arg-drag-handle')).toBe(0);
967
+ expect(count('.expression-arg-position')).toBe(0);
968
+
969
+ await openFieldMenu(1);
970
+ await waitForText('Use Template');
971
+ expect(count('.template-menu-actions')).toBe(0);
972
+ },
973
+ };
974
+
975
+ /** Phone width: the operands stack, the ⋮ menu still carries the move rows. */
976
+ export const ReorderOnPhone: Story = {
977
+ args: { ...concatArgs, reorder: ['overflowMenu', 'dragHandle', 'positionPicker'] },
978
+ parameters: {
979
+ qlip: { viewport: { width: 390, height: 844 } },
980
+ docs: {
981
+ description: {
982
+ story:
983
+ 'Renders the phone presentation of the "concat" expression with every reorder surface — the operands stack into a column, each keeps its grip and position dropdown, and the ⋮ menu (opened and expanded on the second operand) still carries the "Move Argument" section, so reordering needs no hover or drag.',
984
+ },
985
+ },
986
+ },
987
+ play: ReorderAllSurfaces.play,
988
+ };
989
+
604
990
  // --- Wrap / unwrap family ---------------------------------------------------
605
991
  // Ported from the IDE `ExpressionCanBeWrapped` / `ExpressionCanBeUnwrapped`.
606
992
  // Adaptations: (1) the offline `Convert to Boolean` (base op) and `Join List
@@ -1,6 +1,18 @@
1
- import { ReqoreButton, ReqoreControlGroup, ReqorePanel } from '@qoretechnologies/reqore';
1
+ import {
2
+ ReqoreButton,
3
+ ReqoreControlGroup,
4
+ ReqoreDropdown,
5
+ ReqorePanel,
6
+ useReqoreTheme,
7
+ } from '@qoretechnologies/reqore';
2
8
  import { memo } from 'react';
3
- import { IExpression, IExpressionSchema, IExpressionSchemaArg } from '../types';
9
+ import { ordinal } from '../../../../helpers/common';
10
+ import {
11
+ IExpression,
12
+ IExpressionSchema,
13
+ IExpressionSchemaArg,
14
+ TExpressionReorderSurface,
15
+ } from '../types';
4
16
  import { ExpressionBuilderArgumentLabel } from './argumentLabel';
5
17
 
6
18
  export interface IExpressionBuilderArgumentWrapperProps {
@@ -13,6 +25,17 @@ export interface IExpressionBuilderArgumentWrapperProps {
13
25
  expressions: IExpressionSchema[];
14
26
  label?: string;
15
27
  readOnly?: boolean;
28
+ /** Reorder surfaces to render; undefined = this operand cannot be moved. */
29
+ reorder?: TExpressionReorderSurface[];
30
+ argIndex?: number;
31
+ argCount?: number;
32
+ /** Another operand is being dragged over this one. */
33
+ dragOver?: boolean;
34
+ onMoveArg?: (from: number, to: number) => void;
35
+ onArgDragStart?: (index: number) => void;
36
+ onArgDragOver?: (index: number) => void;
37
+ onArgDrop?: (index: number) => void;
38
+ onArgDragEnd?: () => void;
16
39
  }
17
40
 
18
41
  export const ExpressionBuilderArgumentWrapper = memo(
@@ -25,19 +48,114 @@ export const ExpressionBuilderArgumentWrapper = memo(
25
48
  expressions,
26
49
  label,
27
50
  readOnly,
51
+ reorder,
52
+ argIndex = 0,
53
+ argCount = 0,
54
+ dragOver,
55
+ onMoveArg,
56
+ onArgDragStart,
57
+ onArgDragOver,
58
+ onArgDrop,
59
+ onArgDragEnd,
28
60
  }: IExpressionBuilderArgumentWrapperProps) => {
61
+ const theme = useReqoreTheme();
29
62
  const type = arg?.type || schema?.ui_type || 'context';
63
+ const draggable = !!reorder?.includes('dragHandle');
64
+
65
+ // Only the grip is `draggable`, so a drag never starts from inside the
66
+ // field and text selection there is untouched; the drag image is the
67
+ // whole operand. Drag events stop here: an operand of a nested builder
68
+ // must not light up (or drop into) the parent builder's slots.
69
+ const dragProps: React.HTMLAttributes<HTMLDivElement> = draggable
70
+ ? {
71
+ onDragOver: (event) => {
72
+ event.preventDefault();
73
+ event.stopPropagation();
74
+ onArgDragOver?.(argIndex);
75
+ },
76
+ onDrop: (event) => {
77
+ event.preventDefault();
78
+ event.stopPropagation();
79
+ onArgDrop?.(argIndex);
80
+ },
81
+ onDragEnd: (event) => {
82
+ event.stopPropagation();
83
+ onArgDragEnd?.();
84
+ },
85
+ style: dragOver
86
+ ? { outline: `1px dashed ${theme.intents.info}`, outlineOffset: 4, borderRadius: 4 }
87
+ : undefined,
88
+ }
89
+ : {};
90
+
91
+ // A literal fragment, so the control group still clones its size onto
92
+ // the grip and the dropdown.
93
+ const renderReorderControls = () => (
94
+ <>
95
+ {draggable && (
96
+ <ReqoreButton
97
+ compact
98
+ fixed
99
+ minimal
100
+ flat
101
+ transparent
102
+ className='expression-arg-drag-handle'
103
+ icon='Draggable'
104
+ tooltip='Drag to reorder'
105
+ // As tight as the field's `⋮` button next to it.
106
+ style={{ cursor: 'grab', paddingLeft: 0, paddingRight: 0, minWidth: '10px' }}
107
+ draggable
108
+ onDragStart={(event) => {
109
+ event.stopPropagation();
110
+ // Synthetic drag events (play tests) carry no dataTransfer.
111
+ const operand = event.currentTarget.closest('.expression-arg');
112
+ if (event.dataTransfer && operand) {
113
+ event.dataTransfer.effectAllowed = 'move';
114
+ event.dataTransfer.setDragImage(operand, 0, 0);
115
+ }
116
+ onArgDragStart?.(argIndex);
117
+ }}
118
+ />
119
+ )}
120
+ {reorder?.includes('positionPicker') && (
121
+ <ReqoreDropdown
122
+ compact
123
+ fixed
124
+ minimal
125
+ flat
126
+ className='expression-arg-position'
127
+ label={ordinal(argIndex + 1)}
128
+ tooltip='Position — pick a new one to move this argument'
129
+ items={Array.from({ length: argCount }, (_, target) => ({
130
+ label: ordinal(target + 1),
131
+ selected: target === argIndex,
132
+ disabled: target === argIndex,
133
+ onClick: () => onMoveArg?.(argIndex, target),
134
+ }))}
135
+ />
136
+ )}
137
+ </>
138
+ );
30
139
 
31
140
  if (arg?.is_expression) {
32
141
  return (
33
- <ReqoreControlGroup vertical fluid gapSize='small'>
34
- <ExpressionBuilderArgumentLabel
35
- arg={arg}
36
- schema={schema}
37
- label={label || ''}
38
- expressions={expressions}
39
- type={type}
40
- />
142
+ <ReqoreControlGroup
143
+ vertical
144
+ fluid
145
+ gapSize='small'
146
+ className='expression-arg'
147
+ {...dragProps}
148
+ >
149
+ <ReqoreControlGroup verticalAlign='center' gapSize='small'>
150
+ <ExpressionBuilderArgumentLabel
151
+ arg={arg}
152
+ schema={schema}
153
+ label={label || ''}
154
+ expressions={expressions}
155
+ type={type}
156
+ />
157
+ {reorder && renderReorderControls()}
158
+ </ReqoreControlGroup>
41
159
  <ReqorePanel
42
160
  minimal
43
161
  fluid
@@ -56,7 +174,14 @@ export const ExpressionBuilderArgumentWrapper = memo(
56
174
  }
57
175
 
58
176
  return (
59
- <ReqoreControlGroup vertical wrap style={{ flexShrink: 1 }} size='small'>
177
+ <ReqoreControlGroup
178
+ vertical
179
+ wrap
180
+ size='small'
181
+ className='expression-arg'
182
+ {...dragProps}
183
+ style={{ flexShrink: 1, ...dragProps.style }}
184
+ >
60
185
  <ExpressionBuilderArgumentLabel
61
186
  arg={arg}
62
187
  schema={schema}
@@ -64,6 +189,7 @@ export const ExpressionBuilderArgumentWrapper = memo(
64
189
  expressions={expressions}
65
190
  />
66
191
  <ReqoreControlGroup verticalAlign='flex-start' wrap fluid>
192
+ {reorder && renderReorderControls()}
67
193
  {children}
68
194
  {hasMultipleArgs && (
69
195
  <ReqoreButton