@alliance-droid/status-feedback-system 4.0.0 → 4.0.1

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/api.d.ts CHANGED
@@ -107,12 +107,16 @@ export interface StatusClient {
107
107
  offset?: number;
108
108
  }): Promise<BoardData>;
109
109
  /**
110
- * Status transitions the token's user may make on a board item. Requires an
111
- * Alliance id token; the backend returns an empty `targets` list for users
112
- * without a qualifying role.
110
+ * Status transitions the token's user may make on a board item. `token` must
111
+ * be an Alliance OIDC **access token** whose audience covers
112
+ * `status-feedback-admin` (an id token is rejected by the backend). The
113
+ * backend returns an empty `targets` list for users without a qualifying role.
113
114
  */
114
115
  getBoardTransitions(feedbackId: string, token: string): Promise<BoardTransitions>;
115
- /** Set a board item's status (privileged). Requires an Alliance id token. */
116
+ /**
117
+ * Set a board item's status (privileged). `token` must be an Alliance OIDC
118
+ * **access token** scoped to `status-feedback-admin` (not an id token).
119
+ */
116
120
  updateBoardStatus(feedbackId: string, data: {
117
121
  status: string;
118
122
  reason?: string;
@@ -64,7 +64,6 @@
64
64
  // Dev-mode warning when userEmail is empty
65
65
  $effect(() => {
66
66
  if (!userEmail && typeof console !== 'undefined') {
67
- // eslint-disable-next-line no-restricted-globals -- safe typeof guard for universal code
68
67
  const proc = typeof globalThis !== 'undefined' ? (globalThis as Record<string, unknown>).process as { env?: Record<string, string> } | undefined : undefined;
69
68
  const env = proc?.env?.NODE_ENV || 'development';
70
69
  if (env !== 'production') {
@@ -44,6 +44,12 @@
44
44
 
45
45
  {#if board.hasMore}
46
46
  <div class="mt-4 text-center">
47
+ {#if board.loadMoreError}
48
+ <p class="mb-2 text-sm text-red-400" role="alert">
49
+ <i class="fa-solid fa-exclamation-circle mr-1" aria-hidden="true"></i>
50
+ {board.loadMoreError}
51
+ </p>
52
+ {/if}
47
53
  <Button
48
54
  variant="neutral"
49
55
  style="outline"
@@ -51,7 +57,7 @@
51
57
  loading={board.loadingMore}
52
58
  onclick={() => board.loadMore()}
53
59
  >
54
- Load More ({board.items.length} of {board.total})
60
+ {board.loadMoreError ? 'Retry' : `Load More (${board.items.length} of ${board.total})`}
55
61
  </Button>
56
62
  </div>
57
63
  {/if}
@@ -1,239 +1,239 @@
1
- <script lang="ts">
2
- import { Button, Input, Textarea, Select } from '@alliance-droid/svelte-component-library';
3
- import { FEEDBACK_CATEGORIES } from '../../types.js';
4
- import type { FeedbackBoardState } from './state.svelte.js';
5
-
6
- interface Props {
7
- board: FeedbackBoardState;
8
- }
9
-
10
- let { board }: Props = $props();
11
-
12
- let title = $state('');
13
- let message = $state('');
14
- let category = $state('');
15
- let submitting = $state(false);
16
- let success = $state(false);
17
- let error = $state('');
18
- let pastedImage = $state<string | null>(null);
19
- let pastedFilename = $state('');
20
- let fileInputEl: HTMLInputElement | undefined = $state();
21
- let dragActive = $state(false);
22
- let textareaWrapperEl: HTMLDivElement | undefined = $state();
23
- let textareaEl: HTMLTextAreaElement | undefined = $state();
24
-
25
- const MAX_BYTES = 5 * 1024 * 1024;
26
- const ALLOWED_MIME = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
27
-
28
- $effect(() => {
29
- if (textareaWrapperEl) {
30
- textareaEl = textareaWrapperEl.querySelector('textarea') ?? undefined;
31
- }
32
- });
33
-
34
- $effect(() => {
35
- if (!textareaEl) return;
36
- const handler = (e: ClipboardEvent) => handlePaste(e);
37
- textareaEl.addEventListener('paste', handler);
38
- return () => textareaEl?.removeEventListener('paste', handler);
39
- });
40
-
41
- let isAuthenticated = $derived(!!board.userEmail);
42
-
43
- // Labels are the canonical system category names — not the consumer's
44
- // `categoryConfig` (which only customizes the sidebar).
45
- let categoryOptions = FEEDBACK_CATEGORIES.map((c) => ({ value: c, label: c }));
46
-
47
- /** Validate and load an image file (shared by paste, file-pick, and drop). */
48
- function processFile(file: File) {
49
- if (!ALLOWED_MIME.includes(file.type)) {
50
- error = 'Unsupported image type — PNG, JPEG, GIF, or WebP only';
51
- return;
52
- }
53
- if (file.size > MAX_BYTES) {
54
- error = 'Image too large — max 5 MB';
55
- return;
56
- }
57
- const reader = new FileReader();
58
- reader.onload = () => {
59
- pastedImage = reader.result as string;
60
- const ext = file.type.split('/')[1] || 'png';
61
- const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
62
- pastedFilename = file.name || `screenshot-${stamp}.${ext}`;
63
- error = '';
64
- };
65
- reader.readAsDataURL(file);
66
- }
67
-
68
- function handlePaste(e: ClipboardEvent) {
69
- const clipItems = Array.from(e.clipboardData?.items ?? []);
70
- const imageItem = clipItems.find((i) => i.kind === 'file' && i.type.startsWith('image/'));
71
- if (!imageItem) return;
72
- const file = imageItem.getAsFile();
73
- if (!file) return;
74
- e.preventDefault();
75
- processFile(file);
76
- }
77
-
78
- function handleFileSelect(e: Event) {
79
- const input = e.currentTarget as HTMLInputElement;
80
- const file = input.files?.[0];
81
- if (file) processFile(file);
82
- input.value = '';
83
- }
84
-
85
- function handleDrop(e: DragEvent) {
86
- e.preventDefault();
87
- dragActive = false;
88
- const file = e.dataTransfer?.files?.[0];
89
- if (file) processFile(file);
90
- }
91
-
92
- function formatSize(dataUrl: string): string {
93
- const comma = dataUrl.indexOf(',');
94
- if (comma === -1) return '';
95
- const bytes = Math.round((dataUrl.length - comma - 1) * 0.75);
96
- if (bytes < 1024) return `${bytes} B`;
97
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
98
- return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
99
- }
100
-
101
- async function handleSubmit() {
102
- if (!message.trim() || !category || !isAuthenticated) return;
103
- submitting = true;
104
- error = '';
105
- try {
106
- await board.submitFeedback({
107
- title: title.trim() || undefined,
108
- message: message.trim(),
109
- category,
110
- pastedImage,
111
- });
112
- success = true;
113
- title = '';
114
- message = '';
115
- category = '';
116
- pastedImage = null;
117
- pastedFilename = '';
118
- setTimeout(() => { success = false; }, 2000);
119
- } catch (err) {
120
- error = err instanceof Error ? err.message : 'Failed to submit';
121
- } finally {
122
- submitting = false;
123
- }
124
- }
125
- </script>
126
-
127
- <div>
128
- {#if !isAuthenticated}
129
- <div class="py-3 text-center text-foreground-tertiary">
130
- <i class="fa-solid fa-lock mr-1" aria-hidden="true"></i>
131
- <span class="text-sm">Sign in to submit feedback</span>
132
- </div>
133
- {:else if success}
134
- <div class="py-2 text-center font-semibold text-green-400">
135
- <i class="fa-solid fa-check-circle mr-1"></i> Submitted!
136
- </div>
137
- {:else}
138
- <div class="space-y-2">
139
- <!-- Composer card: title + description + attachment + toolbar -->
140
- <div
141
- class="overflow-hidden rounded-xl border bg-background shadow-sm transition-all {dragActive
142
- ? 'border-primary ring-2 ring-primary/30'
143
- : 'border-border focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20'}"
144
- role="group"
145
- ondragover={(e) => { e.preventDefault(); dragActive = true; }}
146
- ondragleave={() => (dragActive = false)}
147
- ondrop={handleDrop}
148
- >
149
- <div class="border-b border-border">
150
- <Input
151
- class="rounded-none border-0 bg-transparent px-4 py-3 text-sm font-medium hover:border-transparent focus:border-transparent"
152
- bind:value={title}
153
- placeholder="Title (optional)"
154
- />
155
- </div>
156
-
157
- <div bind:this={textareaWrapperEl}>
158
- <Textarea
159
- class="resize-none rounded-none border-0 bg-transparent px-4 py-3 text-sm hover:border-transparent focus:border-transparent"
160
- value={message}
161
- oninput={(e) => (message = (e.currentTarget as HTMLTextAreaElement).value)}
162
- placeholder="Describe your feedback…"
163
- rows={4}
164
- />
165
- </div>
166
-
167
- {#if pastedImage}
168
- <div class="flex items-center gap-3 border-t border-border px-3 py-2">
169
- <img
170
- src={pastedImage}
171
- alt={pastedFilename}
172
- class="h-11 w-11 flex-shrink-0 rounded border border-border object-cover"
173
- />
174
- <div class="min-w-0 flex-1">
175
- <p class="truncate text-xs font-medium text-foreground">{pastedFilename}</p>
176
- <p class="text-[11px] text-foreground-tertiary">{formatSize(pastedImage)}</p>
177
- </div>
178
- <button
179
- type="button"
180
- class="flex-shrink-0 rounded p-1.5 text-foreground-tertiary transition-colors hover:bg-surface-hover hover:text-foreground"
181
- onclick={() => { pastedImage = null; pastedFilename = ''; }}
182
- aria-label="Remove image"
183
- >
184
- <i class="fa-solid fa-xmark" aria-hidden="true"></i>
185
- </button>
186
- </div>
187
- {/if}
188
-
189
- <!-- Toolbar -->
190
- <div class="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-2.5">
191
- <div class="flex items-center gap-2">
192
- <div class="min-w-[150px]">
193
- <Select
194
- class="border-0 bg-transparent"
195
- size="sm"
196
- placeholder="Category…"
197
- options={categoryOptions}
198
- required
199
- bind:value={category}
200
- />
201
- </div>
202
- <button
203
- type="button"
204
- class="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs font-medium text-foreground-secondary transition-colors hover:border-border-strong hover:text-foreground"
205
- onclick={() => fileInputEl?.click()}
206
- >
207
- <i class="fa-solid fa-image" aria-hidden="true"></i>
208
- Attach screenshot
209
- </button>
210
- </div>
211
- <Button
212
- variant="primary"
213
- size="sm"
214
- disabled={submitting || !message.trim() || !category}
215
- loading={submitting}
216
- onclick={handleSubmit}
217
- >
218
- Submit
219
- </Button>
220
- </div>
221
-
222
- <input
223
- bind:this={fileInputEl}
224
- type="file"
225
- accept="image/png,image/jpeg,image/gif,image/webp"
226
- class="hidden"
227
- onchange={handleFileSelect}
228
- />
229
- </div>
230
-
231
- {#if error}
232
- <p class="text-sm text-red-400">
233
- <i class="fa-solid fa-exclamation-circle mr-1" aria-hidden="true"></i>
234
- {error}
235
- </p>
236
- {/if}
237
- </div>
238
- {/if}
239
- </div>
1
+ <script lang="ts">
2
+ import { Button, Input, Textarea, Select } from '@alliance-droid/svelte-component-library';
3
+ import { FEEDBACK_CATEGORIES } from '../../types.js';
4
+ import type { FeedbackBoardState } from './state.svelte.js';
5
+
6
+ interface Props {
7
+ board: FeedbackBoardState;
8
+ }
9
+
10
+ let { board }: Props = $props();
11
+
12
+ let title = $state('');
13
+ let message = $state('');
14
+ let category = $state('');
15
+ let submitting = $state(false);
16
+ let success = $state(false);
17
+ let error = $state('');
18
+ let pastedImage = $state<string | null>(null);
19
+ let pastedFilename = $state('');
20
+ let fileInputEl: HTMLInputElement | undefined = $state();
21
+ let dragActive = $state(false);
22
+ let textareaWrapperEl: HTMLDivElement | undefined = $state();
23
+ let textareaEl: HTMLTextAreaElement | undefined = $state();
24
+
25
+ const MAX_BYTES = 5 * 1024 * 1024;
26
+ const ALLOWED_MIME = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
27
+
28
+ $effect(() => {
29
+ if (textareaWrapperEl) {
30
+ textareaEl = textareaWrapperEl.querySelector('textarea') ?? undefined;
31
+ }
32
+ });
33
+
34
+ $effect(() => {
35
+ if (!textareaEl) return;
36
+ const handler = (e: ClipboardEvent) => handlePaste(e);
37
+ textareaEl.addEventListener('paste', handler);
38
+ return () => textareaEl?.removeEventListener('paste', handler);
39
+ });
40
+
41
+ let isAuthenticated = $derived(!!board.userEmail);
42
+
43
+ // Labels are the canonical system category names — not the consumer's
44
+ // `categoryConfig` (which only customizes the sidebar).
45
+ let categoryOptions = FEEDBACK_CATEGORIES.map((c) => ({ value: c, label: c }));
46
+
47
+ /** Validate and load an image file (shared by paste, file-pick, and drop). */
48
+ function processFile(file: File) {
49
+ if (!ALLOWED_MIME.includes(file.type)) {
50
+ error = 'Unsupported image type — PNG, JPEG, GIF, or WebP only';
51
+ return;
52
+ }
53
+ if (file.size > MAX_BYTES) {
54
+ error = 'Image too large — max 5 MB';
55
+ return;
56
+ }
57
+ const reader = new FileReader();
58
+ reader.onload = () => {
59
+ pastedImage = reader.result as string;
60
+ const ext = file.type.split('/')[1] || 'png';
61
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
62
+ pastedFilename = file.name || `screenshot-${stamp}.${ext}`;
63
+ error = '';
64
+ };
65
+ reader.readAsDataURL(file);
66
+ }
67
+
68
+ function handlePaste(e: ClipboardEvent) {
69
+ const clipItems = Array.from(e.clipboardData?.items ?? []);
70
+ const imageItem = clipItems.find((i) => i.kind === 'file' && i.type.startsWith('image/'));
71
+ if (!imageItem) return;
72
+ const file = imageItem.getAsFile();
73
+ if (!file) return;
74
+ e.preventDefault();
75
+ processFile(file);
76
+ }
77
+
78
+ function handleFileSelect(e: Event) {
79
+ const input = e.currentTarget as HTMLInputElement;
80
+ const file = input.files?.[0];
81
+ if (file) processFile(file);
82
+ input.value = '';
83
+ }
84
+
85
+ function handleDrop(e: DragEvent) {
86
+ e.preventDefault();
87
+ dragActive = false;
88
+ const file = e.dataTransfer?.files?.[0];
89
+ if (file) processFile(file);
90
+ }
91
+
92
+ function formatSize(dataUrl: string): string {
93
+ const comma = dataUrl.indexOf(',');
94
+ if (comma === -1) return '';
95
+ const bytes = Math.round((dataUrl.length - comma - 1) * 0.75);
96
+ if (bytes < 1024) return `${bytes} B`;
97
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
98
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
99
+ }
100
+
101
+ async function handleSubmit() {
102
+ if (!message.trim() || !category || !isAuthenticated) return;
103
+ submitting = true;
104
+ error = '';
105
+ try {
106
+ await board.submitFeedback({
107
+ title: title.trim() || undefined,
108
+ message: message.trim(),
109
+ category,
110
+ pastedImage,
111
+ });
112
+ success = true;
113
+ title = '';
114
+ message = '';
115
+ category = '';
116
+ pastedImage = null;
117
+ pastedFilename = '';
118
+ setTimeout(() => { success = false; }, 2000);
119
+ } catch (err) {
120
+ error = err instanceof Error ? err.message : 'Failed to submit';
121
+ } finally {
122
+ submitting = false;
123
+ }
124
+ }
125
+ </script>
126
+
127
+ <div>
128
+ {#if !isAuthenticated}
129
+ <div class="py-3 text-center text-foreground-tertiary">
130
+ <i class="fa-solid fa-lock mr-1" aria-hidden="true"></i>
131
+ <span class="text-sm">Sign in to submit feedback</span>
132
+ </div>
133
+ {:else if success}
134
+ <div class="py-2 text-center font-semibold text-green-400">
135
+ <i class="fa-solid fa-check-circle mr-1"></i> Submitted!
136
+ </div>
137
+ {:else}
138
+ <div class="space-y-2">
139
+ <!-- Composer card: title + description + attachment + toolbar -->
140
+ <div
141
+ class="overflow-hidden rounded-xl border bg-background shadow-sm transition-all {dragActive
142
+ ? 'border-primary ring-2 ring-primary/30'
143
+ : 'border-border focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20'}"
144
+ role="group"
145
+ ondragover={(e) => { e.preventDefault(); dragActive = true; }}
146
+ ondragleave={() => (dragActive = false)}
147
+ ondrop={handleDrop}
148
+ >
149
+ <div class="border-b border-border">
150
+ <Input
151
+ class="rounded-none border-0 bg-transparent px-4 py-3 text-sm font-medium hover:border-transparent focus:border-transparent"
152
+ bind:value={title}
153
+ placeholder="Title (optional)"
154
+ />
155
+ </div>
156
+
157
+ <div bind:this={textareaWrapperEl}>
158
+ <Textarea
159
+ class="resize-none rounded-none border-0 bg-transparent px-4 py-3 text-sm hover:border-transparent focus:border-transparent"
160
+ value={message}
161
+ oninput={(e) => (message = (e.currentTarget as HTMLTextAreaElement).value)}
162
+ placeholder="Describe your feedback…"
163
+ rows={4}
164
+ />
165
+ </div>
166
+
167
+ {#if pastedImage}
168
+ <div class="flex items-center gap-3 border-t border-border px-3 py-2">
169
+ <img
170
+ src={pastedImage}
171
+ alt={pastedFilename}
172
+ class="h-11 w-11 flex-shrink-0 rounded border border-border object-cover"
173
+ />
174
+ <div class="min-w-0 flex-1">
175
+ <p class="truncate text-xs font-medium text-foreground">{pastedFilename}</p>
176
+ <p class="text-[11px] text-foreground-tertiary">{formatSize(pastedImage)}</p>
177
+ </div>
178
+ <button
179
+ type="button"
180
+ class="flex-shrink-0 rounded p-1.5 text-foreground-tertiary transition-colors hover:bg-surface-hover hover:text-foreground"
181
+ onclick={() => { pastedImage = null; pastedFilename = ''; }}
182
+ aria-label="Remove image"
183
+ >
184
+ <i class="fa-solid fa-xmark" aria-hidden="true"></i>
185
+ </button>
186
+ </div>
187
+ {/if}
188
+
189
+ <!-- Toolbar -->
190
+ <div class="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-2.5">
191
+ <div class="flex items-center gap-2">
192
+ <div class="min-w-[150px]">
193
+ <Select
194
+ class="border-0 bg-transparent"
195
+ size="sm"
196
+ placeholder="Category…"
197
+ options={categoryOptions}
198
+ required
199
+ bind:value={category}
200
+ />
201
+ </div>
202
+ <button
203
+ type="button"
204
+ class="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs font-medium text-foreground-secondary transition-colors hover:border-border-strong hover:text-foreground"
205
+ onclick={() => fileInputEl?.click()}
206
+ >
207
+ <i class="fa-solid fa-image" aria-hidden="true"></i>
208
+ Attach screenshot
209
+ </button>
210
+ </div>
211
+ <Button
212
+ variant="primary"
213
+ size="sm"
214
+ disabled={submitting || !message.trim() || !category}
215
+ loading={submitting}
216
+ onclick={handleSubmit}
217
+ >
218
+ Submit
219
+ </Button>
220
+ </div>
221
+
222
+ <input
223
+ bind:this={fileInputEl}
224
+ type="file"
225
+ accept="image/png,image/jpeg,image/gif,image/webp"
226
+ class="hidden"
227
+ onchange={handleFileSelect}
228
+ />
229
+ </div>
230
+
231
+ {#if error}
232
+ <p class="text-sm text-red-400">
233
+ <i class="fa-solid fa-exclamation-circle mr-1" aria-hidden="true"></i>
234
+ {error}
235
+ </p>
236
+ {/if}
237
+ </div>
238
+ {/if}
239
+ </div>
@@ -1,264 +1,264 @@
1
- <script lang="ts">
2
- /**
3
- * Status badge that doubles as a status-change control for privileged users.
4
- *
5
- * For a viewer without a token (board.canManageStatus === false) it's just a
6
- * read-only badge. For a privileged user the badge is a button: clicking it
7
- * loads the transitions they may make and shows them in a menu; picking one
8
- * applies it (prompting for a reason first when the transition requires one).
9
- *
10
- * The menu is teleported to <body> (the `portal` action) and positioned with
11
- * fixed coordinates from the trigger, so it never gets clipped by an overflow
12
- * or transformed ancestor (e.g. a sliding side rail). Same approach the
13
- * library's Select uses.
14
- *
15
- * Used in both the detail panel and the list row, so a ProductOwner can change
16
- * status without opening the item. Transitions load on demand (on first open)
17
- * unless `loadOnMount` is set — the list stays lazy so it doesn't fire a
18
- * request per row.
19
- */
20
- import { Badge, Button, Textarea, Spinner } from '@alliance-droid/svelte-component-library';
21
- import { onMount } from 'svelte';
22
- import type { BoardItem, BoardTransition } from '../../api.js';
23
- import type { FeedbackBoardState } from './state.svelte.js';
24
- import { getStatusBadgeVariant, publicStatusLabel } from './utils.js';
25
-
26
- interface Props {
27
- board: FeedbackBoardState;
28
- item: BoardItem;
29
- /** Eagerly load transitions on mount (detail view). The list leaves this off. */
30
- loadOnMount?: boolean;
31
- }
32
-
33
- let { board, item, loadOnMount = false }: Props = $props();
34
-
35
- let menuOpen = $state(false);
36
- let pendingTarget = $state<BoardTransition | null>(null);
37
- let reason = $state('');
38
- let applying = $state(false);
39
- let loading = $state(false);
40
- /** True when the last load attempt failed (token rejected / network) vs. genuinely returned no targets. */
41
- let loadFailed = $state(false);
42
- let error = $state('');
43
- let triggerEl: HTMLButtonElement | undefined = $state();
44
- let menuEl: HTMLDivElement | undefined = $state();
45
-
46
- // Read from the board's shared cache so detail + list reuse the same fetch.
47
- let txns = $derived(board.transitions[item.id] ?? null);
48
- let badgeLabel = $derived(txns?.current.display ?? publicStatusLabel(item.publicStatus));
49
- // Changes whenever the menu's content (and thus its size) changes — drives a
50
- // reposition so the teleported menu stays anchored after it loads/expands.
51
- let posKey = $derived(`${menuOpen}:${loading}:${pendingTarget?.status ?? ''}:${txns?.targets.length ?? 0}`);
52
-
53
- onMount(() => {
54
- if (loadOnMount && board.canManageStatus) void board.loadTransitions(item.id);
55
- });
56
-
57
- /** Teleport the menu to <body> and keep it positioned under the trigger.
58
- * The second arg (`posKey`) is unused directly — changing it just triggers
59
- * the action's `update()` so the menu re-positions when its content resizes. */
60
- function portal(node: HTMLElement, _posKey?: string) {
61
- document.body.appendChild(node);
62
-
63
- function position() {
64
- if (!triggerEl) return;
65
- const rect = triggerEl.getBoundingClientRect();
66
- node.style.position = 'fixed';
67
- node.style.top = `${rect.bottom + 6}px`;
68
- node.style.zIndex = '9999';
69
- // Left-align to the trigger, then clamp into the viewport.
70
- let left = rect.left;
71
- const width = node.offsetWidth;
72
- if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8;
73
- if (left < 8) left = 8;
74
- node.style.left = `${left}px`;
75
- }
76
-
77
- position();
78
- requestAnimationFrame(position);
79
-
80
- const onScroll = () => position();
81
- window.addEventListener('scroll', onScroll, true);
82
- window.addEventListener('resize', onScroll);
83
-
84
- return {
85
- update() {
86
- position();
87
- requestAnimationFrame(position);
88
- },
89
- destroy() {
90
- window.removeEventListener('scroll', onScroll, true);
91
- window.removeEventListener('resize', onScroll);
92
- node.remove();
93
- },
94
- };
95
- }
96
-
97
- // Close on outside click / Escape while open.
98
- $effect(() => {
99
- if (!menuOpen) return;
100
- const onDocClick = (e: MouseEvent) => {
101
- const target = e.target as Node;
102
- if (triggerEl?.contains(target) || menuEl?.contains(target)) return;
103
- closeMenu();
104
- };
105
- const onKey = (e: KeyboardEvent) => {
106
- if (e.key === 'Escape') closeMenu();
107
- };
108
- document.addEventListener('click', onDocClick, true);
109
- document.addEventListener('keydown', onKey, true);
110
- return () => {
111
- document.removeEventListener('click', onDocClick, true);
112
- document.removeEventListener('keydown', onKey, true);
113
- };
114
- });
115
-
116
- async function toggleMenu(e: MouseEvent) {
117
- e.stopPropagation();
118
- if (menuOpen) {
119
- closeMenu();
120
- return;
121
- }
122
- error = '';
123
- pendingTarget = null;
124
- reason = '';
125
- // Open immediately so the spinner is visible while transitions load.
126
- menuOpen = true;
127
- if (!txns) await reload();
128
- }
129
-
130
- async function reload() {
131
- loading = true;
132
- loadFailed = false;
133
- try {
134
- // null = couldn't load (no token / rejected / network), not "no transitions".
135
- loadFailed = (await board.loadTransitions(item.id)) === null;
136
- } finally {
137
- loading = false;
138
- }
139
- }
140
-
141
- function closeMenu() {
142
- menuOpen = false;
143
- pendingTarget = null;
144
- reason = '';
145
- error = '';
146
- }
147
-
148
- async function chooseStatus(target: BoardTransition) {
149
- error = '';
150
- if (target.requiresReason) {
151
- pendingTarget = target;
152
- reason = '';
153
- return;
154
- }
155
- await applyStatus(target);
156
- }
157
-
158
- async function applyStatus(target: BoardTransition, withReason?: string) {
159
- applying = true;
160
- error = '';
161
- try {
162
- await board.setStatus(item.id, target.status, withReason);
163
- closeMenu();
164
- } catch (err) {
165
- error = err instanceof Error ? err.message : 'Failed to update status';
166
- } finally {
167
- applying = false;
168
- }
169
- }
170
-
171
- function confirmReason() {
172
- if (!pendingTarget) return;
173
- if (!reason.trim()) {
174
- error = 'A reason is required for this change';
175
- return;
176
- }
177
- applyStatus(pendingTarget, reason.trim());
178
- }
179
- </script>
180
-
181
- {#if board.canManageStatus}
182
- <button
183
- bind:this={triggerEl}
184
- type="button"
185
- class="inline-flex items-center gap-1.5 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60"
186
- onclick={toggleMenu}
187
- aria-haspopup="listbox"
188
- aria-expanded={menuOpen}
189
- disabled={applying}
190
- title="Change status"
191
- >
192
- <Badge variant={getStatusBadgeVariant(item.publicStatus)} size="sm">{badgeLabel}</Badge>
193
- <i class="fa-solid fa-chevron-down text-[0.625rem] text-foreground-tertiary" aria-hidden="true"></i>
194
- </button>
195
-
196
- {#if menuOpen}
197
- <!-- Teleported to <body>; posKey re-positions it when its size changes. -->
198
- <div
199
- bind:this={menuEl}
200
- use:portal={posKey}
201
- class="max-h-[60vh] w-max min-w-[10rem] overflow-y-auto overflow-x-hidden rounded-lg border border-border bg-surface py-1 shadow-lg"
202
- role="listbox"
203
- >
204
- {#if loading}
205
- <div class="flex items-center gap-2 px-3 py-3 text-sm text-foreground-tertiary">
206
- <Spinner /> Loading…
207
- </div>
208
- {:else if pendingTarget}
209
- <div class="w-64 space-y-2 p-3">
210
- <p class="text-xs font-medium text-foreground">Reason for &ldquo;{pendingTarget.display}&rdquo;</p>
211
- <Textarea
212
- value={reason}
213
- oninput={(e) => (reason = (e.currentTarget as HTMLTextAreaElement).value)}
214
- placeholder="Why are you making this change?"
215
- rows={2}
216
- />
217
- {#if error}
218
- <p class="text-xs text-red-400">{error}</p>
219
- {/if}
220
- <div class="flex justify-end gap-2">
221
- <Button variant="neutral" style="ghost" size="sm" onclick={() => { pendingTarget = null; reason = ''; error = ''; }}>
222
- Back
223
- </Button>
224
- <Button variant="primary" size="sm" loading={applying} disabled={applying} onclick={confirmReason}>
225
- Apply
226
- </Button>
227
- </div>
228
- </div>
229
- {:else if txns && txns.targets.length > 0}
230
- {#each txns.targets as target (target.status)}
231
- <button
232
- type="button"
233
- role="option"
234
- aria-selected="false"
235
- class="flex w-full items-center justify-between gap-3 whitespace-nowrap px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-surface-hover disabled:opacity-60"
236
- onclick={() => chooseStatus(target)}
237
- disabled={applying}
238
- >
239
- <span>{target.display}</span>
240
- {#if target.requiresReason}
241
- <i class="fa-solid fa-pen text-[0.625rem] text-foreground-tertiary" title="Requires a reason" aria-hidden="true"></i>
242
- {/if}
243
- </button>
244
- {/each}
245
- {:else if loadFailed}
246
- <div class="w-56 space-y-2 p-3 text-sm">
247
- <p class="text-foreground-secondary">
248
- Couldn't load status options — your session may have expired.
249
- </p>
250
- <Button variant="neutral" style="ghost" size="sm" onclick={reload}>Try again</Button>
251
- </div>
252
- {:else}
253
- <p class="whitespace-nowrap px-3 py-3 text-sm text-foreground-tertiary">No status changes available.</p>
254
- {/if}
255
- {#if error && !pendingTarget && !loading}
256
- <p class="px-3 pb-2 text-xs text-red-400">{error}</p>
257
- {/if}
258
- </div>
259
- {/if}
260
- {:else}
261
- <Badge variant={getStatusBadgeVariant(item.publicStatus)} size="sm">
262
- {publicStatusLabel(item.publicStatus)}
263
- </Badge>
264
- {/if}
1
+ <script lang="ts">
2
+ /**
3
+ * Status badge that doubles as a status-change control for privileged users.
4
+ *
5
+ * For a viewer without a token (board.canManageStatus === false) it's just a
6
+ * read-only badge. For a privileged user the badge is a button: clicking it
7
+ * loads the transitions they may make and shows them in a menu; picking one
8
+ * applies it (prompting for a reason first when the transition requires one).
9
+ *
10
+ * The menu is teleported to <body> (the `portal` action) and positioned with
11
+ * fixed coordinates from the trigger, so it never gets clipped by an overflow
12
+ * or transformed ancestor (e.g. a sliding side rail). Same approach the
13
+ * library's Select uses.
14
+ *
15
+ * Used in both the detail panel and the list row, so a ProductOwner can change
16
+ * status without opening the item. Transitions load on demand (on first open)
17
+ * unless `loadOnMount` is set — the list stays lazy so it doesn't fire a
18
+ * request per row.
19
+ */
20
+ import { Badge, Button, Textarea, Spinner } from '@alliance-droid/svelte-component-library';
21
+ import { onMount } from 'svelte';
22
+ import type { BoardItem, BoardTransition } from '../../api.js';
23
+ import type { FeedbackBoardState } from './state.svelte.js';
24
+ import { getStatusBadgeVariant, publicStatusLabel } from './utils.js';
25
+
26
+ interface Props {
27
+ board: FeedbackBoardState;
28
+ item: BoardItem;
29
+ /** Eagerly load transitions on mount (detail view). The list leaves this off. */
30
+ loadOnMount?: boolean;
31
+ }
32
+
33
+ let { board, item, loadOnMount = false }: Props = $props();
34
+
35
+ let menuOpen = $state(false);
36
+ let pendingTarget = $state<BoardTransition | null>(null);
37
+ let reason = $state('');
38
+ let applying = $state(false);
39
+ let loading = $state(false);
40
+ /** True when the last load attempt failed (token rejected / network) vs. genuinely returned no targets. */
41
+ let loadFailed = $state(false);
42
+ let error = $state('');
43
+ let triggerEl: HTMLButtonElement | undefined = $state();
44
+ let menuEl: HTMLDivElement | undefined = $state();
45
+
46
+ // Read from the board's shared cache so detail + list reuse the same fetch.
47
+ let txns = $derived(board.transitions[item.id] ?? null);
48
+ let badgeLabel = $derived(txns?.current.display ?? publicStatusLabel(item.publicStatus));
49
+ // Changes whenever the menu's content (and thus its size) changes — drives a
50
+ // reposition so the teleported menu stays anchored after it loads/expands.
51
+ let posKey = $derived(`${menuOpen}:${loading}:${pendingTarget?.status ?? ''}:${txns?.targets.length ?? 0}`);
52
+
53
+ onMount(() => {
54
+ if (loadOnMount && board.canManageStatus) void board.loadTransitions(item.id);
55
+ });
56
+
57
+ /** Teleport the menu to <body> and keep it positioned under the trigger.
58
+ * The second arg (`posKey`) is unused directly — changing it just triggers
59
+ * the action's `update()` so the menu re-positions when its content resizes. */
60
+ function portal(node: HTMLElement, _posKey?: string) {
61
+ document.body.appendChild(node);
62
+
63
+ function position() {
64
+ if (!triggerEl) return;
65
+ const rect = triggerEl.getBoundingClientRect();
66
+ node.style.position = 'fixed';
67
+ node.style.top = `${rect.bottom + 6}px`;
68
+ node.style.zIndex = '9999';
69
+ // Left-align to the trigger, then clamp into the viewport.
70
+ let left = rect.left;
71
+ const width = node.offsetWidth;
72
+ if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8;
73
+ if (left < 8) left = 8;
74
+ node.style.left = `${left}px`;
75
+ }
76
+
77
+ position();
78
+ requestAnimationFrame(position);
79
+
80
+ const onScroll = () => position();
81
+ window.addEventListener('scroll', onScroll, true);
82
+ window.addEventListener('resize', onScroll);
83
+
84
+ return {
85
+ update() {
86
+ position();
87
+ requestAnimationFrame(position);
88
+ },
89
+ destroy() {
90
+ window.removeEventListener('scroll', onScroll, true);
91
+ window.removeEventListener('resize', onScroll);
92
+ node.remove();
93
+ },
94
+ };
95
+ }
96
+
97
+ // Close on outside click / Escape while open.
98
+ $effect(() => {
99
+ if (!menuOpen) return;
100
+ const onDocClick = (e: MouseEvent) => {
101
+ const target = e.target as Node;
102
+ if (triggerEl?.contains(target) || menuEl?.contains(target)) return;
103
+ closeMenu();
104
+ };
105
+ const onKey = (e: KeyboardEvent) => {
106
+ if (e.key === 'Escape') closeMenu();
107
+ };
108
+ document.addEventListener('click', onDocClick, true);
109
+ document.addEventListener('keydown', onKey, true);
110
+ return () => {
111
+ document.removeEventListener('click', onDocClick, true);
112
+ document.removeEventListener('keydown', onKey, true);
113
+ };
114
+ });
115
+
116
+ async function toggleMenu(e: MouseEvent) {
117
+ e.stopPropagation();
118
+ if (menuOpen) {
119
+ closeMenu();
120
+ return;
121
+ }
122
+ error = '';
123
+ pendingTarget = null;
124
+ reason = '';
125
+ // Open immediately so the spinner is visible while transitions load.
126
+ menuOpen = true;
127
+ if (!txns) await reload();
128
+ }
129
+
130
+ async function reload() {
131
+ loading = true;
132
+ loadFailed = false;
133
+ try {
134
+ // null = couldn't load (no token / rejected / network), not "no transitions".
135
+ loadFailed = (await board.loadTransitions(item.id)) === null;
136
+ } finally {
137
+ loading = false;
138
+ }
139
+ }
140
+
141
+ function closeMenu() {
142
+ menuOpen = false;
143
+ pendingTarget = null;
144
+ reason = '';
145
+ error = '';
146
+ }
147
+
148
+ async function chooseStatus(target: BoardTransition) {
149
+ error = '';
150
+ if (target.requiresReason) {
151
+ pendingTarget = target;
152
+ reason = '';
153
+ return;
154
+ }
155
+ await applyStatus(target);
156
+ }
157
+
158
+ async function applyStatus(target: BoardTransition, withReason?: string) {
159
+ applying = true;
160
+ error = '';
161
+ try {
162
+ await board.setStatus(item.id, target.status, withReason);
163
+ closeMenu();
164
+ } catch (err) {
165
+ error = err instanceof Error ? err.message : 'Failed to update status';
166
+ } finally {
167
+ applying = false;
168
+ }
169
+ }
170
+
171
+ function confirmReason() {
172
+ if (!pendingTarget) return;
173
+ if (!reason.trim()) {
174
+ error = 'A reason is required for this change';
175
+ return;
176
+ }
177
+ applyStatus(pendingTarget, reason.trim());
178
+ }
179
+ </script>
180
+
181
+ {#if board.canManageStatus}
182
+ <button
183
+ bind:this={triggerEl}
184
+ type="button"
185
+ class="inline-flex items-center gap-1.5 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60"
186
+ onclick={toggleMenu}
187
+ aria-haspopup="listbox"
188
+ aria-expanded={menuOpen}
189
+ disabled={applying}
190
+ title="Change status"
191
+ >
192
+ <Badge variant={getStatusBadgeVariant(item.publicStatus)} size="sm">{badgeLabel}</Badge>
193
+ <i class="fa-solid fa-chevron-down text-[0.625rem] text-foreground-tertiary" aria-hidden="true"></i>
194
+ </button>
195
+
196
+ {#if menuOpen}
197
+ <!-- Teleported to <body>; posKey re-positions it when its size changes. -->
198
+ <div
199
+ bind:this={menuEl}
200
+ use:portal={posKey}
201
+ class="max-h-[60vh] w-max min-w-[10rem] overflow-y-auto overflow-x-hidden rounded-lg border border-border bg-surface py-1 shadow-lg"
202
+ role="listbox"
203
+ >
204
+ {#if loading}
205
+ <div class="flex items-center gap-2 px-3 py-3 text-sm text-foreground-tertiary">
206
+ <Spinner /> Loading…
207
+ </div>
208
+ {:else if pendingTarget}
209
+ <div class="w-64 space-y-2 p-3">
210
+ <p class="text-xs font-medium text-foreground">Reason for &ldquo;{pendingTarget.display}&rdquo;</p>
211
+ <Textarea
212
+ value={reason}
213
+ oninput={(e) => (reason = (e.currentTarget as HTMLTextAreaElement).value)}
214
+ placeholder="Why are you making this change?"
215
+ rows={2}
216
+ />
217
+ {#if error}
218
+ <p class="text-xs text-red-400">{error}</p>
219
+ {/if}
220
+ <div class="flex justify-end gap-2">
221
+ <Button variant="neutral" style="ghost" size="sm" onclick={() => { pendingTarget = null; reason = ''; error = ''; }}>
222
+ Back
223
+ </Button>
224
+ <Button variant="primary" size="sm" loading={applying} disabled={applying} onclick={confirmReason}>
225
+ Apply
226
+ </Button>
227
+ </div>
228
+ </div>
229
+ {:else if txns && txns.targets.length > 0}
230
+ {#each txns.targets as target (target.status)}
231
+ <button
232
+ type="button"
233
+ role="option"
234
+ aria-selected="false"
235
+ class="flex w-full items-center justify-between gap-3 whitespace-nowrap px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-surface-hover disabled:opacity-60"
236
+ onclick={() => chooseStatus(target)}
237
+ disabled={applying}
238
+ >
239
+ <span>{target.display}</span>
240
+ {#if target.requiresReason}
241
+ <i class="fa-solid fa-pen text-[0.625rem] text-foreground-tertiary" title="Requires a reason" aria-hidden="true"></i>
242
+ {/if}
243
+ </button>
244
+ {/each}
245
+ {:else if loadFailed}
246
+ <div class="w-56 space-y-2 p-3 text-sm">
247
+ <p class="text-foreground-secondary">
248
+ Couldn't load status options — your session may have expired.
249
+ </p>
250
+ <Button variant="neutral" style="ghost" size="sm" onclick={reload}>Try again</Button>
251
+ </div>
252
+ {:else}
253
+ <p class="whitespace-nowrap px-3 py-3 text-sm text-foreground-tertiary">No status changes available.</p>
254
+ {/if}
255
+ {#if error && !pendingTarget && !loading}
256
+ <p class="px-3 pb-2 text-xs text-red-400">{error}</p>
257
+ {/if}
258
+ </div>
259
+ {/if}
260
+ {:else}
261
+ <Badge variant={getStatusBadgeVariant(item.publicStatus)} size="sm">
262
+ {publicStatusLabel(item.publicStatus)}
263
+ </Badge>
264
+ {/if}
@@ -20,10 +20,11 @@ export interface FeedbackBoardOptions {
20
20
  /** Authenticated user email. Required for submitting feedback and replies. */
21
21
  userEmail?: string;
22
22
  /**
23
- * Returns the current user's Alliance OIDC id token (or null if they aren't
24
- * signed in / not privileged). When provided, privileged users (Admin /
25
- * ProductOwner) can set feedback status from the board. Called on demand so
26
- * the host controls token refresh.
23
+ * Returns the current user's Alliance OIDC **access token** — whose audience
24
+ * covers `status-feedback-admin` (an id token is rejected by the backend) — or
25
+ * null if they aren't signed in / not privileged. When provided, privileged
26
+ * users (Admin / ProductOwner) can set feedback status from the board. Called
27
+ * on demand so the host controls token refresh.
27
28
  */
28
29
  getAuthToken?: () => string | Promise<string | null>;
29
30
  /** Items per page. Default: 50 */
@@ -39,6 +40,8 @@ export interface FeedbackBoardState {
39
40
  readonly loading: boolean;
40
41
  readonly loadingMore: boolean;
41
42
  readonly error: string;
43
+ /** Error from the most recent `loadMore()`, or '' — cleared on the next attempt. */
44
+ readonly loadMoreError: string;
42
45
  readonly hasMore: boolean;
43
46
  activeCategory: string | null;
44
47
  statusFilter: string;
@@ -24,10 +24,13 @@ export function createFeedbackBoard(options) {
24
24
  let sortBy = $state('votes');
25
25
  let votedItems = $state({});
26
26
  let voteErrors = $state({});
27
+ let loadMoreError = $state('');
27
28
  let transitions = $state({});
28
29
  // Response cache
29
30
  const responseCache = {};
30
- // Track in-flight votes to prevent duplicate requests
31
+ // Track in-flight votes to prevent duplicate requests. A plain Set is correct
32
+ // here — an internal dedup guard, not reactive UI state.
33
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
31
34
  const pendingVotes = new Set();
32
35
  async function load() {
33
36
  loading = true;
@@ -55,6 +58,7 @@ export function createFeedbackBoard(options) {
55
58
  if (loadingMore || items.length >= total)
56
59
  return;
57
60
  loadingMore = true;
61
+ loadMoreError = '';
58
62
  try {
59
63
  const data = await client.getBoard({
60
64
  category: activeCategory || undefined,
@@ -66,8 +70,8 @@ export function createFeedbackBoard(options) {
66
70
  items = [...items, ...data.items];
67
71
  total = data.total;
68
72
  }
69
- catch {
70
- // Silently fail — user can retry
73
+ catch (e) {
74
+ loadMoreError = e instanceof Error ? e.message : 'Failed to load more';
71
75
  }
72
76
  finally {
73
77
  loadingMore = false;
@@ -87,7 +91,8 @@ export function createFeedbackBoard(options) {
87
91
  const prev = votedItems[itemId];
88
92
  // Clear any prior error for this item
89
93
  if (voteErrors[itemId]) {
90
- const { [itemId]: _, ...rest } = voteErrors;
94
+ const rest = { ...voteErrors };
95
+ delete rest[itemId];
91
96
  voteErrors = rest;
92
97
  }
93
98
  try {
@@ -107,8 +112,9 @@ export function createFeedbackBoard(options) {
107
112
  voteErrors = { ...voteErrors, [itemId]: 'Vote failed' };
108
113
  // Auto-dismiss after 3 seconds
109
114
  setTimeout(() => {
110
- const { [itemId]: _, ...rest } = voteErrors;
111
- voteErrors = rest;
115
+ const next = { ...voteErrors };
116
+ delete next[itemId];
117
+ voteErrors = next;
112
118
  }, 3000);
113
119
  }
114
120
  finally {
@@ -204,6 +210,7 @@ export function createFeedbackBoard(options) {
204
210
  get loading() { return loading; },
205
211
  get loadingMore() { return loadingMore; },
206
212
  get error() { return error; },
213
+ get loadMoreError() { return loadMoreError; },
207
214
  get hasMore() { return items.length < total; },
208
215
  get activeCategory() { return activeCategory; },
209
216
  set activeCategory(v) { activeCategory = v; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alliance-droid/status-feedback-system",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "type": "module",
5
5
  "description": "Drop-in system status pages and user feedback forms for SvelteKit apps",
6
6
  "svelte": "./dist/index.js",