@alliance-droid/status-feedback-system 3.0.0 → 4.0.0
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/README.md +69 -24
- package/dist/api.d.ts +38 -1
- package/dist/api.js +12 -0
- package/dist/components/DevFeedback.svelte +2 -1
- package/dist/components/board/FeedbackDetail.svelte +205 -171
- package/dist/components/board/FeedbackItem.svelte +50 -46
- package/dist/components/board/FeedbackSidebar.svelte +58 -35
- package/dist/components/board/FeedbackSidebar.svelte.d.ts +1 -1
- package/dist/components/board/FeedbackSubmitForm.svelte +239 -123
- package/dist/components/board/FeedbackSubmitForm.svelte.d.ts +0 -2
- package/dist/components/board/StatusBadgeMenu.svelte +264 -0
- package/dist/components/board/StatusBadgeMenu.svelte.d.ts +11 -0
- package/dist/components/board/state.svelte.d.ts +21 -3
- package/dist/components/board/state.svelte.js +49 -4
- package/dist/components/board/utils.d.ts +5 -2
- package/dist/components/board/utils.js +20 -6
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/types.d.ts +21 -1
- package/dist/types.js +14 -1
- package/package.json +1 -1
- package/dist/components/FeedbackBoard.svelte +0 -110
- package/dist/components/FeedbackBoard.svelte.d.ts +0 -18
|
@@ -0,0 +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 “{pendingTarget.display}”</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}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { BoardItem } from '../../api.js';
|
|
2
|
+
import type { FeedbackBoardState } from './state.svelte.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
board: FeedbackBoardState;
|
|
5
|
+
item: BoardItem;
|
|
6
|
+
/** Eagerly load transitions on mount (detail view). The list leaves this off. */
|
|
7
|
+
loadOnMount?: boolean;
|
|
8
|
+
}
|
|
9
|
+
declare const StatusBadgeMenu: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type StatusBadgeMenu = ReturnType<typeof StatusBadgeMenu>;
|
|
11
|
+
export default StatusBadgeMenu;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reactive state manager for
|
|
2
|
+
* Reactive state manager for the composable feedback board components.
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
5
|
* const board = createFeedbackBoard({ apiUrl, apiKey });
|
|
6
6
|
* <FeedbackSidebar {board} />
|
|
7
7
|
* <FeedbackList {board} />
|
|
8
8
|
*/
|
|
9
|
-
import type
|
|
10
|
-
import { type BoardItem, type BoardCategory, type StatusClient } from '../../api.js';
|
|
9
|
+
import { type FeedbackResponse } from '../../types.js';
|
|
10
|
+
import { type BoardItem, type BoardCategory, type BoardTransitions, type StatusClient } from '../../api.js';
|
|
11
11
|
export interface CategoryConfig {
|
|
12
12
|
label?: string;
|
|
13
13
|
icon?: string;
|
|
@@ -19,6 +19,13 @@ export interface FeedbackBoardOptions {
|
|
|
19
19
|
categoryConfig?: Record<string, CategoryConfig>;
|
|
20
20
|
/** Authenticated user email. Required for submitting feedback and replies. */
|
|
21
21
|
userEmail?: string;
|
|
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.
|
|
27
|
+
*/
|
|
28
|
+
getAuthToken?: () => string | Promise<string | null>;
|
|
22
29
|
/** Items per page. Default: 50 */
|
|
23
30
|
pageSize?: number;
|
|
24
31
|
}
|
|
@@ -49,6 +56,17 @@ export interface FeedbackBoardState {
|
|
|
49
56
|
pastedImage?: string | null;
|
|
50
57
|
}): Promise<void>;
|
|
51
58
|
submitResponse(itemId: string, message: string, pastedImage?: string | null): Promise<void>;
|
|
59
|
+
/** Whether a token provider was configured (status controls may be available). */
|
|
60
|
+
readonly canManageStatus: boolean;
|
|
61
|
+
/** Cached allowed transitions per item id, populated by loadTransitions. */
|
|
62
|
+
readonly transitions: Record<string, BoardTransitions>;
|
|
63
|
+
/**
|
|
64
|
+
* Fetch the status transitions the current user may make on an item. Returns
|
|
65
|
+
* null when there's no token or the user isn't privileged for this item.
|
|
66
|
+
*/
|
|
67
|
+
loadTransitions(itemId: string): Promise<BoardTransitions | null>;
|
|
68
|
+
/** Apply a status change to an item (privileged). Throws on failure. */
|
|
69
|
+
setStatus(itemId: string, status: string, reason?: string): Promise<void>;
|
|
52
70
|
getCategoryIcon(name: string): string;
|
|
53
71
|
getCategoryLabel(name: string): string;
|
|
54
72
|
getCategoryDescription(name: string): string;
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reactive state manager for
|
|
2
|
+
* Reactive state manager for the composable feedback board components.
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
5
|
* const board = createFeedbackBoard({ apiUrl, apiKey });
|
|
6
6
|
* <FeedbackSidebar {board} />
|
|
7
7
|
* <FeedbackList {board} />
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import { FEEDBACK_CATEGORY_ICONS } from '../../types.js';
|
|
10
|
+
import { createStatusClient, } from '../../api.js';
|
|
10
11
|
export function createFeedbackBoard(options) {
|
|
11
|
-
const { apiUrl, apiKey, categoryConfig = {}, userEmail, pageSize = 50 } = options;
|
|
12
|
+
const { apiUrl, apiKey, categoryConfig = {}, userEmail, getAuthToken, pageSize = 50 } = options;
|
|
12
13
|
const client = createStatusClient({ apiUrl, apiKey });
|
|
13
14
|
const resolvedEmail = userEmail?.trim() || null;
|
|
14
15
|
// Reactive state
|
|
@@ -23,6 +24,7 @@ export function createFeedbackBoard(options) {
|
|
|
23
24
|
let sortBy = $state('votes');
|
|
24
25
|
let votedItems = $state({});
|
|
25
26
|
let voteErrors = $state({});
|
|
27
|
+
let transitions = $state({});
|
|
26
28
|
// Response cache
|
|
27
29
|
const responseCache = {};
|
|
28
30
|
// Track in-flight votes to prevent duplicate requests
|
|
@@ -155,6 +157,43 @@ export function createFeedbackBoard(options) {
|
|
|
155
157
|
if (item)
|
|
156
158
|
item.responseCount += 1;
|
|
157
159
|
}
|
|
160
|
+
async function resolveToken() {
|
|
161
|
+
if (!getAuthToken)
|
|
162
|
+
return null;
|
|
163
|
+
try {
|
|
164
|
+
return (await getAuthToken()) ?? null;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function loadTransitions(itemId) {
|
|
171
|
+
const token = await resolveToken();
|
|
172
|
+
if (!token)
|
|
173
|
+
return null;
|
|
174
|
+
try {
|
|
175
|
+
const result = await client.getBoardTransitions(itemId, token);
|
|
176
|
+
transitions = { ...transitions, [itemId]: result };
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// 401/403 → not signed in or not privileged for this item: no control.
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function setStatus(itemId, status, reason) {
|
|
185
|
+
const token = await resolveToken();
|
|
186
|
+
if (!token)
|
|
187
|
+
throw new Error('Authentication required to set status');
|
|
188
|
+
const result = await client.updateBoardStatus(itemId, { status, reason }, token);
|
|
189
|
+
const item = items.find((i) => i.id === itemId);
|
|
190
|
+
if (item) {
|
|
191
|
+
item.status = result.status;
|
|
192
|
+
item.publicStatus = result.publicStatus;
|
|
193
|
+
}
|
|
194
|
+
// Allowed targets depend on the new status — refresh the cache.
|
|
195
|
+
await loadTransitions(itemId);
|
|
196
|
+
}
|
|
158
197
|
return {
|
|
159
198
|
get client() { return client; },
|
|
160
199
|
get categoryConfig() { return categoryConfig; },
|
|
@@ -180,8 +219,14 @@ export function createFeedbackBoard(options) {
|
|
|
180
219
|
getResponses,
|
|
181
220
|
submitFeedback,
|
|
182
221
|
submitResponse,
|
|
222
|
+
get canManageStatus() { return !!getAuthToken; },
|
|
223
|
+
get transitions() { return transitions; },
|
|
224
|
+
loadTransitions,
|
|
225
|
+
setStatus,
|
|
183
226
|
getCategoryIcon(name) {
|
|
184
|
-
return categoryConfig[name]?.icon ||
|
|
227
|
+
return (categoryConfig[name]?.icon ||
|
|
228
|
+
FEEDBACK_CATEGORY_ICONS[name.toLowerCase()] ||
|
|
229
|
+
'fa-solid fa-circle');
|
|
185
230
|
},
|
|
186
231
|
getCategoryLabel(name) {
|
|
187
232
|
return categoryConfig[name]?.label || name;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
/** Relative time formatting for feedback timestamps */
|
|
2
2
|
export declare function timeAgo(timestamp: number): string;
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import type { PublicStatus } from '../../types.js';
|
|
4
|
+
/** Human label for a public board status. */
|
|
5
|
+
export declare function publicStatusLabel(status: PublicStatus): string;
|
|
6
|
+
/** Map a public board status to a badge variant. */
|
|
7
|
+
export declare function getStatusBadgeVariant(status: PublicStatus): 'success' | 'neutral' | 'info' | 'warning';
|
|
@@ -12,11 +12,25 @@ export function timeAgo(timestamp) {
|
|
|
12
12
|
return `${days}d ago`;
|
|
13
13
|
return new Date(timestamp).toLocaleDateString();
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
const PUBLIC_STATUS_LABELS = {
|
|
16
|
+
open: 'Open',
|
|
17
|
+
planned: 'Planned',
|
|
18
|
+
in_progress: 'In progress',
|
|
19
|
+
done: 'Done',
|
|
20
|
+
declined: 'Declined'
|
|
21
|
+
};
|
|
22
|
+
const PUBLIC_STATUS_VARIANTS = {
|
|
23
|
+
open: 'info',
|
|
24
|
+
planned: 'info',
|
|
25
|
+
in_progress: 'warning',
|
|
26
|
+
done: 'success',
|
|
27
|
+
declined: 'neutral'
|
|
28
|
+
};
|
|
29
|
+
/** Human label for a public board status. */
|
|
30
|
+
export function publicStatusLabel(status) {
|
|
31
|
+
return PUBLIC_STATUS_LABELS[status] ?? status;
|
|
32
|
+
}
|
|
33
|
+
/** Map a public board status to a badge variant. */
|
|
16
34
|
export function getStatusBadgeVariant(status) {
|
|
17
|
-
|
|
18
|
-
return 'success';
|
|
19
|
-
if (status === 'resolved')
|
|
20
|
-
return 'neutral';
|
|
21
|
-
return 'info';
|
|
35
|
+
return PUBLIC_STATUS_VARIANTS[status] ?? 'neutral';
|
|
22
36
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,6 @@ export { default as StatusPage } from './components/StatusPage.svelte';
|
|
|
15
15
|
export { default as StatusBanner } from './components/StatusBanner.svelte';
|
|
16
16
|
export { default as FeedbackForm } from './components/FeedbackForm.svelte';
|
|
17
17
|
export { default as DevFeedback } from './components/DevFeedback.svelte';
|
|
18
|
-
export { default as FeedbackBoard } from './components/FeedbackBoard.svelte';
|
|
19
18
|
export { default as FeedbackButton } from './components/FeedbackButton.svelte';
|
|
20
19
|
export { createFeedbackBoard, FeedbackSidebar, FeedbackList, FeedbackItem, FeedbackDetail, FeedbackSubmitForm, } from './components/board/index.js';
|
|
21
20
|
export type { FeedbackBoardState, FeedbackBoardOptions, CategoryConfig } from './components/board/index.js';
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,6 @@ export { default as StatusPage } from './components/StatusPage.svelte';
|
|
|
16
16
|
export { default as StatusBanner } from './components/StatusBanner.svelte';
|
|
17
17
|
export { default as FeedbackForm } from './components/FeedbackForm.svelte';
|
|
18
18
|
export { default as DevFeedback } from './components/DevFeedback.svelte';
|
|
19
|
-
export { default as FeedbackBoard } from './components/FeedbackBoard.svelte';
|
|
20
19
|
export { default as FeedbackButton } from './components/FeedbackButton.svelte';
|
|
21
20
|
// ── Composable Board Components ──
|
|
22
21
|
export { createFeedbackBoard, FeedbackSidebar, FeedbackList, FeedbackItem, FeedbackDetail, FeedbackSubmitForm, } from './components/board/index.js';
|
package/dist/types.d.ts
CHANGED
|
@@ -4,7 +4,27 @@
|
|
|
4
4
|
export type SystemStatus = 'operational' | 'degraded' | 'outage' | 'maintenance';
|
|
5
5
|
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
|
|
6
6
|
export type IncidentSeverity = 'minor' | 'major' | 'critical';
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Raw backend lifecycle status of a feedback item (the admin app owns the full
|
|
9
|
+
* set, which evolves independently). Kept as a string so this package doesn't
|
|
10
|
+
* couple to the internal lifecycle — for display, use a board item's coarse
|
|
11
|
+
* `publicStatus` ({@link PublicStatus}) instead.
|
|
12
|
+
*/
|
|
13
|
+
export type FeedbackStatus = string;
|
|
14
|
+
/** Coarse, consumer-facing status buckets the board API returns as `publicStatus`. */
|
|
15
|
+
export type PublicStatus = 'open' | 'planned' | 'in_progress' | 'done' | 'declined';
|
|
16
|
+
/**
|
|
17
|
+
* The categories a user can file feedback under. The feedback system owns this
|
|
18
|
+
* list — consumers don't supply their own.
|
|
19
|
+
*/
|
|
20
|
+
export declare const FEEDBACK_CATEGORIES: readonly ["Bug", "Feature Request"];
|
|
21
|
+
export type FeedbackCategory = (typeof FEEDBACK_CATEGORIES)[number];
|
|
22
|
+
/**
|
|
23
|
+
* Default Font Awesome icon per category, keyed by the lower-cased category
|
|
24
|
+
* name so it matches regardless of how the value was stored (e.g. "Bug" vs
|
|
25
|
+
* "bug"). A consumer's `categoryConfig` icon takes precedence over these.
|
|
26
|
+
*/
|
|
27
|
+
export declare const FEEDBACK_CATEGORY_ICONS: Record<string, string>;
|
|
8
28
|
export interface Project {
|
|
9
29
|
id: string;
|
|
10
30
|
name: string;
|
package/dist/types.js
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared types for the status-feedback-system package
|
|
3
3
|
*/
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The categories a user can file feedback under. The feedback system owns this
|
|
6
|
+
* list — consumers don't supply their own.
|
|
7
|
+
*/
|
|
8
|
+
export const FEEDBACK_CATEGORIES = ['Bug', 'Feature Request'];
|
|
9
|
+
/**
|
|
10
|
+
* Default Font Awesome icon per category, keyed by the lower-cased category
|
|
11
|
+
* name so it matches regardless of how the value was stored (e.g. "Bug" vs
|
|
12
|
+
* "bug"). A consumer's `categoryConfig` icon takes precedence over these.
|
|
13
|
+
*/
|
|
14
|
+
export const FEEDBACK_CATEGORY_ICONS = {
|
|
15
|
+
bug: 'fa-solid fa-bug',
|
|
16
|
+
'feature request': 'fa-solid fa-lightbulb',
|
|
17
|
+
};
|
package/package.json
CHANGED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* All-in-one feedback board component.
|
|
4
|
-
* Composes the board sub-components (FeedbackSidebar, FeedbackList,
|
|
5
|
-
* FeedbackSubmitForm) with a shared FeedbackBoardState.
|
|
6
|
-
*/
|
|
7
|
-
import { untrack } from 'svelte';
|
|
8
|
-
import { Button } from '@alliance-droid/svelte-component-library';
|
|
9
|
-
import type { BoardItem } from '../api.js';
|
|
10
|
-
import { createFeedbackBoard, type CategoryConfig, type FeedbackBoardState } from './board/index.js';
|
|
11
|
-
import FeedbackSidebar from './board/FeedbackSidebar.svelte';
|
|
12
|
-
import FeedbackList from './board/FeedbackList.svelte';
|
|
13
|
-
import FeedbackDetail from './board/FeedbackDetail.svelte';
|
|
14
|
-
import FeedbackSubmitForm from './board/FeedbackSubmitForm.svelte';
|
|
15
|
-
|
|
16
|
-
interface Props {
|
|
17
|
-
apiUrl: string;
|
|
18
|
-
apiKey: string;
|
|
19
|
-
/** Authenticated user email. Required for submitting feedback and replies. */
|
|
20
|
-
userEmail?: string;
|
|
21
|
-
/** Category labels and icons — order determines sidebar order */
|
|
22
|
-
categoryConfig?: Record<string, CategoryConfig>;
|
|
23
|
-
/** Title shown at the top */
|
|
24
|
-
title?: string;
|
|
25
|
-
/** Show the submit form inline */
|
|
26
|
-
showSubmit?: boolean;
|
|
27
|
-
/** Available categories for the submit form */
|
|
28
|
-
submitCategories?: string[];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
let {
|
|
32
|
-
apiUrl,
|
|
33
|
-
apiKey,
|
|
34
|
-
userEmail,
|
|
35
|
-
categoryConfig = {},
|
|
36
|
-
title = 'Feedback Board',
|
|
37
|
-
showSubmit = true,
|
|
38
|
-
submitCategories = [],
|
|
39
|
-
}: Props = $props();
|
|
40
|
-
|
|
41
|
-
let board: FeedbackBoardState = $derived(
|
|
42
|
-
createFeedbackBoard({ apiUrl, apiKey, userEmail, categoryConfig })
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
let showForm = $state(false);
|
|
46
|
-
let selectedItem = $state<BoardItem | null>(null);
|
|
47
|
-
|
|
48
|
-
function selectItem(item: BoardItem) {
|
|
49
|
-
selectedItem = item;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function clearSelection() {
|
|
53
|
-
selectedItem = null;
|
|
54
|
-
}
|
|
55
|
-
</script>
|
|
56
|
-
|
|
57
|
-
<div class="flex gap-6 min-h-[400px]">
|
|
58
|
-
<!-- Sidebar -->
|
|
59
|
-
<aside class="w-56 flex-shrink-0">
|
|
60
|
-
<FeedbackSidebar {board} />
|
|
61
|
-
</aside>
|
|
62
|
-
|
|
63
|
-
<!-- Main -->
|
|
64
|
-
<main class="flex-1 min-w-0">
|
|
65
|
-
<div class="flex items-start justify-between mb-4">
|
|
66
|
-
<div>
|
|
67
|
-
{#if board.activeCategory}
|
|
68
|
-
<h2 class="text-xl font-bold flex items-center gap-2">
|
|
69
|
-
<i class={board.getCategoryIcon(board.activeCategory)}></i>
|
|
70
|
-
{board.getCategoryLabel(board.activeCategory)}
|
|
71
|
-
</h2>
|
|
72
|
-
{#if board.getCategoryDescription(board.activeCategory)}
|
|
73
|
-
<p class="text-sm text-foreground-secondary mt-1">{board.getCategoryDescription(board.activeCategory)}</p>
|
|
74
|
-
{/if}
|
|
75
|
-
{:else if title}
|
|
76
|
-
<h2 class="text-xl font-bold">{title}</h2>
|
|
77
|
-
{/if}
|
|
78
|
-
</div>
|
|
79
|
-
{#if showSubmit}
|
|
80
|
-
<Button variant="primary" onclick={() => (showForm = !showForm)}>
|
|
81
|
-
<i class="fa-solid fa-plus mr-1.5"></i> Submit
|
|
82
|
-
</Button>
|
|
83
|
-
{/if}
|
|
84
|
-
</div>
|
|
85
|
-
|
|
86
|
-
<!-- Submit form -->
|
|
87
|
-
{#if showForm}
|
|
88
|
-
<div class="mb-4">
|
|
89
|
-
<FeedbackSubmitForm {board} categories={submitCategories} />
|
|
90
|
-
</div>
|
|
91
|
-
{/if}
|
|
92
|
-
|
|
93
|
-
<!-- Items / Detail -->
|
|
94
|
-
{#if selectedItem}
|
|
95
|
-
<div class="mb-4">
|
|
96
|
-
<button
|
|
97
|
-
onclick={clearSelection}
|
|
98
|
-
class="text-sm text-foreground-secondary hover:text-foreground transition-colors mb-3"
|
|
99
|
-
>
|
|
100
|
-
<i class="fa-solid fa-arrow-left mr-1" aria-hidden="true"></i> Back to list
|
|
101
|
-
</button>
|
|
102
|
-
<div class="rounded-xl border border-border bg-surface p-5">
|
|
103
|
-
<FeedbackDetail {board} item={selectedItem} />
|
|
104
|
-
</div>
|
|
105
|
-
</div>
|
|
106
|
-
{:else}
|
|
107
|
-
<FeedbackList {board} onSelect={selectItem} />
|
|
108
|
-
{/if}
|
|
109
|
-
</main>
|
|
110
|
-
</div>
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { type CategoryConfig } from './board/index.js';
|
|
2
|
-
interface Props {
|
|
3
|
-
apiUrl: string;
|
|
4
|
-
apiKey: string;
|
|
5
|
-
/** Authenticated user email. Required for submitting feedback and replies. */
|
|
6
|
-
userEmail?: string;
|
|
7
|
-
/** Category labels and icons — order determines sidebar order */
|
|
8
|
-
categoryConfig?: Record<string, CategoryConfig>;
|
|
9
|
-
/** Title shown at the top */
|
|
10
|
-
title?: string;
|
|
11
|
-
/** Show the submit form inline */
|
|
12
|
-
showSubmit?: boolean;
|
|
13
|
-
/** Available categories for the submit form */
|
|
14
|
-
submitCategories?: string[];
|
|
15
|
-
}
|
|
16
|
-
declare const FeedbackBoard: import("svelte").Component<Props, {}, "">;
|
|
17
|
-
type FeedbackBoard = ReturnType<typeof FeedbackBoard>;
|
|
18
|
-
export default FeedbackBoard;
|