@qoretechnologies/reqraft 0.10.13-pr.74.gccefa54 → 0.10.13

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 (125) hide show
  1. package/.claude/CLAUDE.md +8 -11
  2. package/dist/components/form/engine/FormEngine.d.ts +1 -1
  3. package/dist/helpers/common.d.ts +0 -5
  4. package/dist/helpers/common.d.ts.map +1 -1
  5. package/dist/helpers/common.js +1 -20
  6. package/dist/helpers/common.js.map +1 -1
  7. package/dist/index.d.ts +0 -5
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -7
  10. package/dist/index.js.map +1 -1
  11. package/dist/providers/StorageProvider.d.ts +13 -0
  12. package/dist/providers/StorageProvider.d.ts.map +1 -1
  13. package/dist/providers/StorageProvider.js +29 -5
  14. package/dist/providers/StorageProvider.js.map +1 -1
  15. package/package.json +3 -3
  16. package/src/helpers/common.ts +0 -19
  17. package/src/hooks/useStorage/useStorage.stories.tsx +52 -0
  18. package/src/index.tsx +0 -5
  19. package/src/providers/StorageProvider.tsx +30 -4
  20. package/dist/components/composer/Composer.d.ts +0 -108
  21. package/dist/components/composer/Composer.d.ts.map +0 -1
  22. package/dist/components/composer/Composer.js +0 -238
  23. package/dist/components/composer/Composer.js.map +0 -1
  24. package/dist/components/composer/helpers.d.ts +0 -21
  25. package/dist/components/composer/helpers.d.ts.map +0 -1
  26. package/dist/components/composer/helpers.js +0 -75
  27. package/dist/components/composer/helpers.js.map +0 -1
  28. package/dist/components/composer/index.d.ts +0 -3
  29. package/dist/components/composer/index.d.ts.map +0 -1
  30. package/dist/components/composer/index.js +0 -19
  31. package/dist/components/composer/index.js.map +0 -1
  32. package/dist/components/imageLightbox/ImageLightbox.d.ts +0 -17
  33. package/dist/components/imageLightbox/ImageLightbox.d.ts.map +0 -1
  34. package/dist/components/imageLightbox/ImageLightbox.js +0 -129
  35. package/dist/components/imageLightbox/ImageLightbox.js.map +0 -1
  36. package/dist/components/imageLightbox/index.d.ts +0 -2
  37. package/dist/components/imageLightbox/index.d.ts.map +0 -1
  38. package/dist/components/imageLightbox/index.js +0 -18
  39. package/dist/components/imageLightbox/index.js.map +0 -1
  40. package/dist/components/supportTicket/AttachmentChips.d.ts +0 -10
  41. package/dist/components/supportTicket/AttachmentChips.d.ts.map +0 -1
  42. package/dist/components/supportTicket/AttachmentChips.js +0 -119
  43. package/dist/components/supportTicket/AttachmentChips.js.map +0 -1
  44. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +0 -35
  45. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +0 -1
  46. package/dist/components/supportTicket/InterfaceReferenceTags.js +0 -31
  47. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +0 -1
  48. package/dist/components/supportTicket/ReferencePicker.d.ts +0 -41
  49. package/dist/components/supportTicket/ReferencePicker.d.ts.map +0 -1
  50. package/dist/components/supportTicket/ReferencePicker.js +0 -86
  51. package/dist/components/supportTicket/ReferencePicker.js.map +0 -1
  52. package/dist/components/supportTicket/TicketHeader.d.ts +0 -86
  53. package/dist/components/supportTicket/TicketHeader.d.ts.map +0 -1
  54. package/dist/components/supportTicket/TicketHeader.js +0 -149
  55. package/dist/components/supportTicket/TicketHeader.js.map +0 -1
  56. package/dist/components/supportTicket/TicketMetaTags.d.ts +0 -32
  57. package/dist/components/supportTicket/TicketMetaTags.d.ts.map +0 -1
  58. package/dist/components/supportTicket/TicketMetaTags.js +0 -21
  59. package/dist/components/supportTicket/TicketMetaTags.js.map +0 -1
  60. package/dist/components/supportTicket/TicketReplyBox.d.ts +0 -63
  61. package/dist/components/supportTicket/TicketReplyBox.d.ts.map +0 -1
  62. package/dist/components/supportTicket/TicketReplyBox.js +0 -115
  63. package/dist/components/supportTicket/TicketReplyBox.js.map +0 -1
  64. package/dist/components/supportTicket/fileToAttachment.d.ts +0 -9
  65. package/dist/components/supportTicket/fileToAttachment.d.ts.map +0 -1
  66. package/dist/components/supportTicket/fileToAttachment.js +0 -28
  67. package/dist/components/supportTicket/fileToAttachment.js.map +0 -1
  68. package/dist/components/supportTicket/index.d.ts +0 -9
  69. package/dist/components/supportTicket/index.d.ts.map +0 -1
  70. package/dist/components/supportTicket/index.js +0 -25
  71. package/dist/components/supportTicket/index.js.map +0 -1
  72. package/dist/components/supportTicket/meta.d.ts +0 -63
  73. package/dist/components/supportTicket/meta.d.ts.map +0 -1
  74. package/dist/components/supportTicket/meta.js +0 -79
  75. package/dist/components/supportTicket/meta.js.map +0 -1
  76. package/dist/components/ticketReferences/TicketReferences.d.ts +0 -52
  77. package/dist/components/ticketReferences/TicketReferences.d.ts.map +0 -1
  78. package/dist/components/ticketReferences/TicketReferences.js +0 -453
  79. package/dist/components/ticketReferences/TicketReferences.js.map +0 -1
  80. package/dist/components/ticketReferences/index.d.ts +0 -3
  81. package/dist/components/ticketReferences/index.d.ts.map +0 -1
  82. package/dist/components/ticketReferences/index.js +0 -6
  83. package/dist/components/ticketReferences/index.js.map +0 -1
  84. package/dist/components/ticketThread/TicketThread.d.ts +0 -52
  85. package/dist/components/ticketThread/TicketThread.d.ts.map +0 -1
  86. package/dist/components/ticketThread/TicketThread.js +0 -248
  87. package/dist/components/ticketThread/TicketThread.js.map +0 -1
  88. package/dist/components/ticketThread/index.d.ts +0 -3
  89. package/dist/components/ticketThread/index.d.ts.map +0 -1
  90. package/dist/components/ticketThread/index.js +0 -7
  91. package/dist/components/ticketThread/index.js.map +0 -1
  92. package/dist/helpers/scrollbar.d.ts +0 -2
  93. package/dist/helpers/scrollbar.d.ts.map +0 -1
  94. package/dist/helpers/scrollbar.js +0 -25
  95. package/dist/helpers/scrollbar.js.map +0 -1
  96. package/src/components/composer/Composer.stories.tsx +0 -238
  97. package/src/components/composer/Composer.tsx +0 -545
  98. package/src/components/composer/helpers.ts +0 -74
  99. package/src/components/composer/index.ts +0 -2
  100. package/src/components/imageLightbox/ImageLightbox.stories.tsx +0 -156
  101. package/src/components/imageLightbox/ImageLightbox.tsx +0 -169
  102. package/src/components/imageLightbox/index.ts +0 -1
  103. package/src/components/supportTicket/AttachmentChips.stories.tsx +0 -122
  104. package/src/components/supportTicket/AttachmentChips.tsx +0 -142
  105. package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +0 -134
  106. package/src/components/supportTicket/InterfaceReferenceTags.tsx +0 -85
  107. package/src/components/supportTicket/ReferencePicker.stories.tsx +0 -296
  108. package/src/components/supportTicket/ReferencePicker.tsx +0 -237
  109. package/src/components/supportTicket/TicketConversation.stories.tsx +0 -122
  110. package/src/components/supportTicket/TicketHeader.stories.tsx +0 -183
  111. package/src/components/supportTicket/TicketHeader.tsx +0 -247
  112. package/src/components/supportTicket/TicketMetaTags.stories.tsx +0 -103
  113. package/src/components/supportTicket/TicketMetaTags.tsx +0 -85
  114. package/src/components/supportTicket/TicketReplyBox.stories.tsx +0 -355
  115. package/src/components/supportTicket/TicketReplyBox.tsx +0 -146
  116. package/src/components/supportTicket/fileToAttachment.ts +0 -24
  117. package/src/components/supportTicket/index.ts +0 -8
  118. package/src/components/supportTicket/meta.ts +0 -145
  119. package/src/components/ticketReferences/TicketReferences.stories.tsx +0 -301
  120. package/src/components/ticketReferences/TicketReferences.tsx +0 -899
  121. package/src/components/ticketReferences/index.ts +0 -2
  122. package/src/components/ticketThread/TicketThread.stories.tsx +0 -751
  123. package/src/components/ticketThread/TicketThread.tsx +0 -650
  124. package/src/components/ticketThread/index.ts +0 -6
  125. package/src/helpers/scrollbar.ts +0 -32
@@ -1,156 +0,0 @@
1
- import { StoryObj } from '@storybook/react-vite';
2
- import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
3
- import { useEffect, useState } from 'react';
4
- import { StoryMeta } from '../../types';
5
- import { IImageLightboxImage, ImageLightbox } from './ImageLightbox';
6
-
7
- /**
8
- * The shared full-image viewer, on `ReqoreModal`. Opened by the ticket thread (an image
9
- * on a message) and the References tab (the screenshot grid) alike. Pages across a set,
10
- * click-to-zoom, download; the shell/backdrop/Esc are the modal's.
11
- */
12
- const meta = {
13
- component: ImageLightbox,
14
- title: 'Components/ImageLightbox',
15
- } as StoryMeta<typeof ImageLightbox>;
16
- export default meta;
17
- type Story = StoryObj<typeof meta>;
18
-
19
- const shot = (label: string, hue: number): string =>
20
- `data:image/svg+xml,${encodeURIComponent(
21
- `<svg xmlns='http://www.w3.org/2000/svg' width='640' height='400'>` +
22
- `<rect width='640' height='400' fill='hsl(${hue},38%,16%)'/>` +
23
- `<text x='24' y='210' fill='white' font-family='sans-serif' font-size='28' opacity='0.7'>${label}</text>` +
24
- `</svg>`
25
- )}`;
26
-
27
- const IMAGES: IImageLightboxImage[] = [
28
- { attachment_id: 'i1', filename: 'canvas-error.png' },
29
- { attachment_id: 'i2', filename: 'console.png' },
30
- { attachment_id: 'i3', filename: 'network.png' },
31
- ];
32
-
33
- /** Open on a set of three, so the paging chrome (`1 / 3`, prev/next) shows. */
34
- export const Paging: Story = {
35
- parameters: {
36
- docs: {
37
- description: {
38
- story:
39
- "Renders the lightbox open on a set of three images, so the paging chrome ('1 / 3', prev/next) shows, and arrow keys page through the set.",
40
- },
41
- },
42
- },
43
- render: function Render() {
44
- const [index, setIndex] = useState<number | null>(0);
45
- if (index === null) {
46
- return <div>closed</div>;
47
- }
48
- return (
49
- <ImageLightbox
50
- images={IMAGES}
51
- index={index}
52
- onIndex={setIndex}
53
- onClose={() => setIndex(null)}
54
- fetchAttachmentUrl={async (id) => shot(id, id === 'i1' ? 265 : id === 'i2' ? 205 : 150)}
55
- onDownload={fn()}
56
- />
57
- );
58
- },
59
- async play() {
60
- // the modal portals to the body, so query document-wide
61
- await waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull());
62
- const dialog = within(document.body);
63
- await expect(await dialog.findByText('canvas-error.png')).toBeInTheDocument();
64
- await expect(dialog.getByText('1 / 3')).toBeInTheDocument();
65
- // page forward via the keyboard (the action buttons are icon-only)
66
- await userEvent.keyboard('{ArrowRight}');
67
- await waitFor(() => expect(dialog.getByText('2 / 3')).toBeInTheDocument());
68
- },
69
- };
70
-
71
- /**
72
- * Regression guard for the "screenshot preview stuck loading" bug.
73
- *
74
- * Consumers pass `fetchAttachmentUrl` as an inline arrow — a new identity every render —
75
- * and re-render for reasons unrelated to the lightbox. Here the parent ticks on a timer
76
- * (handing a fresh fetcher each render) while the fetch is deliberately slow, so those
77
- * re-renders land WHILE the image is still loading. When the fetch effect depended on
78
- * `fetchAttachmentUrl`, each re-render tore down the in-flight fetch and the `requested`
79
- * guard blocked a retry — the spinner hung forever. The image resolving proves the effect
80
- * now depends on `id` alone. Removing that fix makes this story time out.
81
- */
82
- export const ResolvesDespiteParentReRenders: Story = {
83
- parameters: {
84
- docs: {
85
- description: {
86
- story:
87
- 'Renders the lightbox while the parent re-renders on a timer with a deliberately slow fetch — a regression guard proving the image still resolves instead of the spinner hanging forever.',
88
- },
89
- },
90
- },
91
- render: function Render() {
92
- const [index, setIndex] = useState<number | null>(0);
93
- const [, setTick] = useState(0);
94
- useEffect(() => {
95
- const timer = setInterval(() => setTick((n) => n + 1), 30);
96
- return () => clearInterval(timer);
97
- }, []);
98
- if (index === null) {
99
- return <div>closed</div>;
100
- }
101
- return (
102
- <ImageLightbox
103
- images={[IMAGES[0]]}
104
- index={index}
105
- onIndex={setIndex}
106
- onClose={() => setIndex(null)}
107
- // a brand-new arrow every render, and a slow fetch so the re-renders overlap it
108
- fetchAttachmentUrl={async (id) => {
109
- await new Promise((resolve) => setTimeout(resolve, 120));
110
- return shot(id, 265);
111
- }}
112
- onDownload={fn()}
113
- />
114
- );
115
- },
116
- async play() {
117
- await expect(await within(document.body).findByText('canvas-error.png')).toBeInTheDocument();
118
- // the image resolves and paints — it never would while the spinner was hung
119
- await waitFor(
120
- () => expect(document.body.querySelector('img[alt="canvas-error.png"]')).not.toBeNull(),
121
- { timeout: 3000 }
122
- );
123
- },
124
- };
125
-
126
- /** A single image: no paging chrome. */
127
- export const Single: Story = {
128
- parameters: {
129
- docs: {
130
- description: {
131
- story: 'Renders the lightbox on a single image, so no paging chrome appears.',
132
- },
133
- },
134
- },
135
- render: function Render() {
136
- const [index, setIndex] = useState<number | null>(0);
137
- if (index === null) {
138
- return <div>closed</div>;
139
- }
140
- return (
141
- <ImageLightbox
142
- images={[IMAGES[0]]}
143
- index={index}
144
- onIndex={setIndex}
145
- onClose={() => setIndex(null)}
146
- fetchAttachmentUrl={async (id) => shot(id, 265)}
147
- onDownload={fn()}
148
- />
149
- );
150
- },
151
- async play() {
152
- const dialog = within(document.body);
153
- await expect(await dialog.findByText('canvas-error.png')).toBeInTheDocument();
154
- await expect(dialog.queryByText('1 / 1')).not.toBeInTheDocument();
155
- },
156
- };
@@ -1,169 +0,0 @@
1
- import { ReqoreModal, ReqoreSpan, ReqoreSpinner, useLatestZIndex } from '@qoretechnologies/reqore';
2
- import { useCallback, useEffect, useRef, useState } from 'react';
3
- import styled from 'styled-components';
4
-
5
- /*
6
- * The shared full-image viewer: a `ReqoreModal` holding one image at a time, with
7
- * click-to-zoom, keyboard/arrow paging across a set, and a download action. Both the
8
- * ticket thread (an image attachment on a message) and the References tab (the ticket's
9
- * screenshot grid) open this, so there is one lightbox rather than a Modal-backed one
10
- * here and a hand-rolled overlay there.
11
- *
12
- * reqore has no image-viewer primitive, so the zoom stage below is bespoke — but the
13
- * shell, backdrop, focus trap, Esc-to-close and header/actions are all `ReqoreModal`.
14
- * When reqore gains a real viewer this collapses onto it. It fetches the shown image's
15
- * bytes itself (caching per id), so a consumer only has to hand it `fetchAttachmentUrl`.
16
- */
17
-
18
- const Stage = styled.div<{ $zoom: boolean }>`
19
- display: flex;
20
- align-items: center;
21
- justify-content: center;
22
- min-height: 40vh;
23
- max-height: 74vh;
24
- overflow: auto;
25
- background: rgba(0, 0, 0, 0.25);
26
- border-radius: 8px;
27
-
28
- img {
29
- display: block;
30
- cursor: ${({ $zoom }) => ($zoom ? 'zoom-out' : 'zoom-in')};
31
- ${({ $zoom }) =>
32
- $zoom
33
- ? 'max-width: none; max-height: none;'
34
- : 'max-width: 100%; max-height: 74vh; object-fit: contain;'}
35
- }
36
- `;
37
-
38
- export interface IImageLightboxImage {
39
- attachment_id: string;
40
- filename: string;
41
- }
42
-
43
- export interface IImageLightboxProps {
44
- /** the set the viewer pages across — one image is a set of one (no paging chrome) */
45
- images: IImageLightboxImage[];
46
- /** which image is shown */
47
- index: number;
48
- onIndex: (index: number) => void;
49
- onClose: () => void;
50
- /** resolve an image's bytes as an object URL (the consumer's authed endpoint) */
51
- fetchAttachmentUrl: (attachmentId: string) => Promise<string>;
52
- onDownload?: (attachmentId: string, filename: string) => void;
53
- }
54
-
55
- export const ImageLightbox = ({
56
- images,
57
- index,
58
- onIndex,
59
- onClose,
60
- fetchAttachmentUrl,
61
- onDownload,
62
- }: IImageLightboxProps) => {
63
- const [zoom, setZoom] = useState<boolean>(false);
64
- // url per attachment: undefined = not fetched, null = fetch failed, string = ready
65
- const [urls, setUrls] = useState<Record<string, string | null>>({});
66
- const requested = useRef<Set<string>>(new Set());
67
- // Render above everything currently on screen: the ticket composer that can open this
68
- // lightbox floats at its own climbed z-index (also useLatestZIndex), so a modal on a fixed
69
- // z-index ends up BEHIND it. Climbing past the current top keeps the viewer on top wherever
70
- // it's opened from.
71
- const zIndex = useLatestZIndex();
72
-
73
- const image = images[index];
74
- const id = image?.attachment_id;
75
- const url = id ? urls[id] : undefined;
76
- const multiple = images.length > 1;
77
-
78
- // Keep the latest fetcher in a ref so the fetch effect can depend on `id` ALONE.
79
- // Consumers routinely pass `fetchAttachmentUrl` as an inline arrow — a new identity
80
- // every render. If the effect depended on it, each parent re-render would re-run the
81
- // effect: the previous run's cleanup fired first, so its in-flight fetch resolved with
82
- // nothing to write to, and because `requested` already held the id nothing re-fetched.
83
- // Net result: the spinner hung forever. Depending on `id` only breaks that cycle.
84
- const fetchRef = useRef(fetchAttachmentUrl);
85
- fetchRef.current = fetchAttachmentUrl;
86
- // True only while mounted: a fetch that resolves after unmount is dropped, but one that
87
- // resolves after a benign re-render still caches — the distinction a per-run flag missed.
88
- const mounted = useRef(true);
89
- useEffect(
90
- () => () => {
91
- mounted.current = false;
92
- },
93
- []
94
- );
95
-
96
- // fetch the shown image's bytes once per id; `requested` guards against re-fetching a
97
- // page you've already visited
98
- useEffect(() => {
99
- if (!id || requested.current.has(id)) {
100
- return undefined;
101
- }
102
- requested.current.add(id);
103
- fetchRef.current(id)
104
- .then((resolved) => mounted.current && setUrls((current) => ({ ...current, [id]: resolved })))
105
- .catch(() => mounted.current && setUrls((current) => ({ ...current, [id]: null })));
106
- return undefined;
107
- }, [id]);
108
-
109
- const step = useCallback(
110
- (delta: number) => {
111
- setZoom(false);
112
- onIndex((index + delta + images.length) % images.length);
113
- },
114
- [index, images.length, onIndex]
115
- );
116
-
117
- useEffect(() => {
118
- if (!multiple) {
119
- return undefined;
120
- }
121
- const handler = (event: KeyboardEvent) => {
122
- if (event.key === 'ArrowRight') {
123
- step(1);
124
- } else if (event.key === 'ArrowLeft') {
125
- step(-1);
126
- }
127
- };
128
- window.addEventListener('keydown', handler);
129
- return () => window.removeEventListener('keydown', handler);
130
- }, [step, multiple]);
131
-
132
- if (!image) {
133
- return null;
134
- }
135
-
136
- return (
137
- <ReqoreModal
138
- isOpen
139
- onClose={onClose}
140
- customZIndex={zIndex}
141
- blur={3}
142
- icon='ImageLine'
143
- label={image.filename}
144
- badge={multiple ? `${index + 1} / ${images.length}` : undefined}
145
- actions={[
146
- { icon: 'ArrowLeftSLine', tooltip: 'Previous', onClick: () => step(-1), show: multiple },
147
- { icon: 'ArrowRightSLine', tooltip: 'Next', onClick: () => step(1), show: multiple },
148
- {
149
- icon: 'DownloadLine',
150
- tooltip: 'Download',
151
- onClick: () => onDownload?.(image.attachment_id, image.filename),
152
- show: !!onDownload,
153
- },
154
- ]}
155
- >
156
- <Stage $zoom={zoom}>
157
- {url ? (
158
- <img src={url} alt={image.filename} onClick={() => setZoom((z) => !z)} />
159
- ) : url === null ? (
160
- <ReqoreSpan effect={{ opacity: 0.6 }}>Preview unavailable — use Download.</ReqoreSpan>
161
- ) : (
162
- <ReqoreSpinner iconColor='info' size='big'>
163
- Loading…
164
- </ReqoreSpinner>
165
- )}
166
- </Stage>
167
- </ReqoreModal>
168
- );
169
- };
@@ -1 +0,0 @@
1
- export * from './ImageLightbox';
@@ -1,122 +0,0 @@
1
- import { StoryObj } from '@storybook/react-vite';
2
- import { useState } from 'react';
3
- import { expect, userEvent, waitFor, within } from 'storybook/test';
4
- import { StoryMeta } from '../../types';
5
- import { AttachmentChips } from './AttachmentChips';
6
-
7
- /**
8
- * Staged-file chips for a ticket composer. Image files show a thumbnail and open the shared
9
- * `ImageLightbox` on click; other files show a paperclip. The pencil renames a file inline
10
- * (a File name is read-only, so the host swaps in a renamed copy); the ✕ removes it.
11
- */
12
- const meta = {
13
- component: AttachmentChips,
14
- title: 'Components/AttachmentChips',
15
- } as StoryMeta<typeof AttachmentChips>;
16
- export default meta;
17
- type Story = StoryObj<typeof meta>;
18
-
19
- const imageFile = (label: string, hue: number, name: string): File => {
20
- const svg =
21
- `<svg xmlns='http://www.w3.org/2000/svg' width='640' height='400'>` +
22
- `<rect width='640' height='400' fill='hsl(${hue},40%,16%)'/>` +
23
- `<text x='24' y='210' fill='white' font-family='sans-serif' font-size='30'>${label}</text></svg>`;
24
- return new File([svg], name, { type: 'image/svg+xml' });
25
- };
26
- const textFile = (name: string): File => new File(['log contents'], name, { type: 'text/plain' });
27
-
28
- const FILES = [
29
- imageFile('before', 265, 'screenshot-2026-07-26-17-41-44.png'),
30
- imageFile('after', 205, 'canvas-after.png'),
31
- textFile('server.log'),
32
- ];
33
-
34
- // Plays the composer's role: owns the file set, swaps in a renamed copy on rename.
35
- const Harness = () => {
36
- const [files, setFiles] = useState<File[]>(FILES);
37
- return (
38
- <AttachmentChips
39
- files={files}
40
- onRemove={(index) => setFiles((current) => current.filter((_, j) => j !== index))}
41
- onRename={(index, name) =>
42
- setFiles((current) =>
43
- current.map((file, i) => (i === index ? new File([file], name, { type: file.type }) : file))
44
- )
45
- }
46
- />
47
- );
48
- };
49
-
50
- /**
51
- * Two screenshots + a log. Clicking a screenshot chip opens the lightbox (paging across the two
52
- * images); the log stays a plain chip. Proves the preview opens and paints the image.
53
- */
54
- export const WithImages: Story = {
55
- parameters: {
56
- docs: {
57
- description: {
58
- story:
59
- 'Renders staged chips for two screenshots and a log, where clicking a screenshot chip opens the shared lightbox while the log stays a plain chip.',
60
- },
61
- },
62
- },
63
- render: () => <Harness />,
64
- play: async ({ canvasElement }) => {
65
- const canvas = within(canvasElement);
66
- await expect(canvas.getByText('screenshot-2026-07-26-17-41-44.png')).toBeInTheDocument();
67
- await expect(canvas.getByText('server.log')).toBeInTheDocument();
68
-
69
- // clicking an image chip opens the shared lightbox (portals to the body)
70
- await userEvent.click(canvas.getByText('canvas-after.png'));
71
- await waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull());
72
- await waitFor(() =>
73
- expect(document.body.querySelector('img[alt="canvas-after.png"]')).not.toBeNull()
74
- );
75
- },
76
- };
77
-
78
- /**
79
- * Renaming an auto-named screenshot: the pencil turns the chip into an inline input; Enter
80
- * commits and the chip shows the new name. Discriminating — remove the rename wiring and the
81
- * chip keeps its `screenshot-<timestamp>.png` name.
82
- */
83
- export const Rename: Story = {
84
- parameters: {
85
- docs: {
86
- description: {
87
- story:
88
- 'Renders the staged chips and renames an auto-named screenshot inline — the pencil opens an input, Enter commits, and the chip shows the new name.',
89
- },
90
- },
91
- },
92
- render: () => <Harness />,
93
- play: async ({ canvasElement }) => {
94
- const canvas = within(canvasElement);
95
- const chip = canvas.getByText('screenshot-2026-07-26-17-41-44.png').closest('.reqore-tag');
96
- if (!chip) {
97
- throw new Error('chip not found');
98
- }
99
- // click the rename pencil (its own action; the ✕ is a separate reqore-tag-remove)
100
- const renameAction = chip.querySelector<HTMLElement>('.reqore-tag-action:not(.reqore-tag-remove)');
101
- if (!renameAction) {
102
- throw new Error('rename action not rendered');
103
- }
104
- await userEvent.click(renameAction);
105
-
106
- // the inline input appears, pre-filled with the current name
107
- const input = await waitFor(() => {
108
- const el = canvasElement.querySelector<HTMLInputElement>('input');
109
- if (!el) {
110
- throw new Error('rename input not shown');
111
- }
112
- return el;
113
- });
114
- await userEvent.clear(input);
115
- await userEvent.type(input, 'mapper-error.png');
116
- await userEvent.keyboard('{Enter}');
117
-
118
- // the chip now carries the new name; the auto-name is gone
119
- await waitFor(() => expect(canvas.getByText('mapper-error.png')).toBeInTheDocument());
120
- await expect(canvas.queryByText('screenshot-2026-07-26-17-41-44.png')).not.toBeInTheDocument();
121
- },
122
- };
@@ -1,142 +0,0 @@
1
- import { ReqoreControlGroup, ReqoreInput, ReqoreTag } from '@qoretechnologies/reqore';
2
- import { useEffect, useState } from 'react';
3
- import { ImageLightbox } from '../imageLightbox';
4
-
5
- /*
6
- * Renders the files staged on a ticket composer as removable chips. Image files — whether
7
- * uploaded, pasted, or captured as a screenshot — show a thumbnail and open the shared
8
- * `ImageLightbox` on click (paging across the staged images, click-to-zoom); everything else
9
- * shows a paperclip + filename. When `onRename` is given, a pencil turns the chip into an
10
- * inline input so a captured `screenshot-<timestamp>.png` can be given a meaningful name (a
11
- * File's name is read-only, so the host swaps in a renamed copy).
12
- *
13
- * The thumbnail object URLs ARE the full-image URLs the lightbox shows, so they're created
14
- * once here and revoked when the file set changes or on unmount. Renders nothing when empty.
15
- */
16
-
17
- export interface IAttachmentChipsProps {
18
- files: File[];
19
- onRemove: (index: number) => void;
20
- /** Rename a staged file. Omit to make chips non-renamable. The host replaces the File with a
21
- * renamed copy (`new File([file], newName, { type: file.type })`). */
22
- onRename?: (index: number, newName: string) => void;
23
- disabled?: boolean;
24
- }
25
-
26
- export const AttachmentChips = ({ files, onRemove, onRename, disabled }: IAttachmentChipsProps) => {
27
- const [thumbnails, setThumbnails] = useState<string[]>([]);
28
- // index into the image subset that's open in the lightbox, or null when closed.
29
- const [previewIndex, setPreviewIndex] = useState<number | null>(null);
30
- // the file index currently being renamed inline, plus its working name.
31
- const [editingIndex, setEditingIndex] = useState<number | null>(null);
32
- const [draftName, setDraftName] = useState<string>('');
33
-
34
- useEffect(() => {
35
- const urls = files.map((file) =>
36
- file.type.startsWith('image/') ? URL.createObjectURL(file) : ''
37
- );
38
- setThumbnails(urls);
39
- return () => urls.forEach((url) => url && URL.revokeObjectURL(url));
40
- }, [files]);
41
-
42
- if (!files.length) {
43
- return null;
44
- }
45
-
46
- // The staged images in order, each with its original file index + object URL — the set the
47
- // lightbox pages across. Non-image files are skipped.
48
- const imageEntries = files
49
- .map((file, index) => ({ file, index, url: thumbnails[index] }))
50
- .filter((entry) => entry.url);
51
-
52
- const startRename = (index: number) => {
53
- setDraftName(files[index].name);
54
- setEditingIndex(index);
55
- };
56
- const commitRename = () => {
57
- if (editingIndex === null) {
58
- return;
59
- }
60
- const name = draftName.trim();
61
- if (onRename && name && name !== files[editingIndex].name) {
62
- onRename(editingIndex, name);
63
- }
64
- setEditingIndex(null);
65
- };
66
-
67
- return (
68
- <>
69
- <ReqoreControlGroup wrap gapSize='small' verticalAlign='center'>
70
- {files.map((file, index) => {
71
- if (editingIndex === index) {
72
- return (
73
- <ReqoreInput
74
- key={`edit-${index}`}
75
- value={draftName}
76
- onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
77
- setDraftName(event.target.value)
78
- }
79
- onBlur={commitRename}
80
- onKeyDown={(event: React.KeyboardEvent) => {
81
- if (event.key === 'Enter') {
82
- commitRename();
83
- } else if (event.key === 'Escape') {
84
- setEditingIndex(null);
85
- }
86
- }}
87
- size='small'
88
- fixed
89
- autoFocus
90
- />
91
- );
92
- }
93
-
94
- const thumbnail = thumbnails[index];
95
- const imageIndex = thumbnail
96
- ? imageEntries.findIndex((entry) => entry.index === index)
97
- : -1;
98
- const actions =
99
- onRename && !disabled
100
- ? [
101
- {
102
- icon: 'Edit2Line' as const,
103
- tooltip: 'Rename',
104
- onClick: (event: React.MouseEvent<HTMLDivElement>) => {
105
- event.stopPropagation();
106
- startRename(index);
107
- },
108
- },
109
- ]
110
- : undefined;
111
- return (
112
- <ReqoreTag
113
- key={`${file.name}-${index}`}
114
- icon={thumbnail ? undefined : 'AttachmentLine'}
115
- leftIconProps={thumbnail ? { image: thumbnail } : undefined}
116
- label={file.name}
117
- size='small'
118
- tooltip={thumbnail ? 'Click to preview' : undefined}
119
- actions={actions}
120
- onClick={thumbnail ? () => setPreviewIndex(imageIndex) : undefined}
121
- onRemoveClick={disabled ? undefined : () => onRemove(index)}
122
- />
123
- );
124
- })}
125
- </ReqoreControlGroup>
126
- {previewIndex !== null && imageEntries[previewIndex] ? (
127
- <ImageLightbox
128
- images={imageEntries.map((entry) => ({
129
- attachment_id: String(entry.index),
130
- filename: entry.file.name,
131
- }))}
132
- index={previewIndex}
133
- onIndex={setPreviewIndex}
134
- onClose={() => setPreviewIndex(null)}
135
- fetchAttachmentUrl={async (id) =>
136
- imageEntries.find((entry) => String(entry.index) === id)?.url ?? ''
137
- }
138
- />
139
- ) : null}
140
- </>
141
- );
142
- };