@alliance-droid/status-feedback-system 4.2.3 → 4.3.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/dist/api.d.ts CHANGED
@@ -47,11 +47,16 @@ export interface BoardItem {
47
47
  adminResponse: string | null;
48
48
  responseCount: number;
49
49
  createdAt: number;
50
+ /** Server-authorized consumer-board status control for this item. */
51
+ canChangeStatus?: boolean;
52
+ /** This item is awaiting a product decision or reply from a board manager. */
53
+ needsInput?: boolean;
50
54
  }
51
55
  export interface BoardData {
52
56
  items: BoardItem[];
53
57
  total: number;
54
58
  categories: BoardCategory[];
59
+ canManageStatus?: boolean;
55
60
  }
56
61
  /** A status a privileged user may move an item to, and whether it needs a reason. */
57
62
  export interface BoardTransition {
@@ -78,6 +83,8 @@ export interface BoardStatusUpdate {
78
83
  id: string;
79
84
  status: string;
80
85
  publicStatus: PublicStatus;
86
+ canChangeStatus?: boolean;
87
+ needsInput?: boolean;
81
88
  }
82
89
  export interface StatusClient {
83
90
  /** Get current system statuses */
@@ -103,11 +110,11 @@ export interface StatusClient {
103
110
  /** Get public feedback board */
104
111
  getBoard(options?: {
105
112
  category?: string;
106
- status?: 'open' | 'resolved' | 'all';
113
+ status?: 'open' | 'resolved' | 'all' | 'needs_input';
107
114
  sort?: 'votes' | 'newest';
108
115
  limit?: number;
109
116
  offset?: number;
110
- }): Promise<BoardData>;
117
+ }, token?: string | null): Promise<BoardData>;
111
118
  /**
112
119
  * Status transitions the token's user may make on a board item. `token` must
113
120
  * be an Alliance OIDC **access token** whose audience covers
package/dist/api.js CHANGED
@@ -75,7 +75,7 @@ export function createStatusClient(config) {
75
75
  body: JSON.stringify(data),
76
76
  });
77
77
  },
78
- async getBoard(options) {
78
+ async getBoard(options, token) {
79
79
  const params = new URLSearchParams();
80
80
  if (options?.category)
81
81
  params.set('category', options.category);
@@ -88,7 +88,9 @@ export function createStatusClient(config) {
88
88
  if (options?.offset)
89
89
  params.set('offset', String(options.offset));
90
90
  const qs = params.toString();
91
- return request(`/api/board${qs ? `?${qs}` : ''}`);
91
+ return request(`/api/board${qs ? `?${qs}` : ''}`, token
92
+ ? { headers: { Authorization: `Bearer ${token}` } }
93
+ : undefined);
92
94
  },
93
95
  async getBoardTransitions(feedbackId, token) {
94
96
  return request(`/api/board/feedback/${feedbackId}/transitions`, {
@@ -20,10 +20,10 @@
20
20
  getElementHtml,
21
21
  } from './dev/environment.js';
22
22
 
23
- interface Props {
24
- apiUrl: string;
25
- /** Omit when apiUrl points at a same-origin proxy. */
26
- apiKey?: string;
23
+ interface Props {
24
+ apiUrl: string;
25
+ /** Omit when apiUrl points at a same-origin proxy. */
26
+ apiKey?: string;
27
27
  /**
28
28
  * The sole gate for the widget — wire it to your role/permission check
29
29
  * (e.g. `enabled={userHasFeedbackRole}`). When false, nothing renders and
@@ -52,9 +52,9 @@
52
52
  renderTokenEndpoint?: string;
53
53
  }
54
54
 
55
- let {
56
- apiUrl,
57
- apiKey = '',
55
+ let {
56
+ apiUrl,
57
+ apiKey = '',
58
58
  enabled,
59
59
  maxConsoleErrors = 20,
60
60
  showOnboarding = true,
@@ -1,17 +1,17 @@
1
1
  <script lang="ts">
2
- import FeedbackForm from './FeedbackForm.svelte';
3
-
4
- interface Props {
5
- /** Omit when apiUrl points at a same-origin proxy. */
6
- apiKey?: string;
7
- apiUrl: string;
2
+ import FeedbackForm from './FeedbackForm.svelte';
3
+
4
+ interface Props {
5
+ /** Omit when apiUrl points at a same-origin proxy. */
6
+ apiKey?: string;
7
+ apiUrl: string;
8
8
  categories?: string[];
9
9
  userEmail?: string;
10
10
  position?: 'bottom-right' | 'bottom-left';
11
11
  }
12
12
 
13
- let {
14
- apiKey = '',
13
+ let {
14
+ apiKey = '',
15
15
  apiUrl,
16
16
  categories,
17
17
  userEmail,
@@ -1,19 +1,19 @@
1
1
  <script lang="ts">
2
2
  import { untrack } from 'svelte';
3
- import { createStatusClient, type StatusClient } from '../api.js';
4
-
5
- interface Props {
6
- /** Omit when apiUrl points at a same-origin proxy. */
7
- apiKey?: string;
8
- apiUrl: string;
3
+ import { createStatusClient, type StatusClient } from '../api.js';
4
+
5
+ interface Props {
6
+ /** Omit when apiUrl points at a same-origin proxy. */
7
+ apiKey?: string;
8
+ apiUrl: string;
9
9
  categories?: string[];
10
10
  userEmail?: string;
11
11
  onSuccess?: () => void;
12
12
  onError?: (error: string) => void;
13
13
  }
14
14
 
15
- let {
16
- apiKey = '',
15
+ let {
16
+ apiKey = '',
17
17
  apiUrl,
18
18
  categories = ['Bug', 'Feature Request', 'General'],
19
19
  userEmail,
@@ -10,35 +10,35 @@
10
10
  systems?: System[];
11
11
  /** API URL for self-fetching mode */
12
12
  apiUrl?: string;
13
- /** API key for direct admin calls. Omit when using a same-origin proxy. */
14
- apiKey?: string;
13
+ /** API key for direct admin calls. Omit when using a same-origin proxy. */
14
+ apiKey?: string;
15
15
  /** Link to full status page */
16
16
  statusUrl?: string;
17
17
  /** Auto-refresh interval in seconds. 0 = disabled. Default: 60 */
18
18
  refreshInterval?: number;
19
19
  }
20
20
 
21
- let { systems: systemsProp, apiUrl, apiKey, statusUrl, refreshInterval = 60 }: Props = $props();
22
-
23
- let systems = $derived(systemsProp ?? []);
24
- let loading = $derived(!systemsProp && !!apiUrl);
25
- let fetchFailed = $state(false);
26
-
27
- let usesApi = $derived(!systemsProp && !!apiUrl);
21
+ let { systems: systemsProp, apiUrl, apiKey, statusUrl, refreshInterval = 60 }: Props = $props();
22
+
23
+ let systems = $derived(systemsProp ?? []);
24
+ let loading = $derived(!systemsProp && !!apiUrl);
25
+ let fetchFailed = $state(false);
26
+
27
+ let usesApi = $derived(!systemsProp && !!apiUrl);
28
28
 
29
29
  let cachedClient: StatusClient | null = null;
30
- let cachedApiUrl = '';
31
- let cachedApiKey = '';
32
-
33
- function getClient(): StatusClient {
34
- const nextApiKey = apiKey ?? '';
35
- if (!cachedClient || cachedApiUrl !== apiUrl || cachedApiKey !== nextApiKey) {
36
- cachedClient = createStatusClient({ apiUrl: apiUrl!, apiKey: nextApiKey });
37
- cachedApiUrl = apiUrl!;
38
- cachedApiKey = nextApiKey;
39
- }
40
- return cachedClient;
41
- }
30
+ let cachedApiUrl = '';
31
+ let cachedApiKey = '';
32
+
33
+ function getClient(): StatusClient {
34
+ const nextApiKey = apiKey ?? '';
35
+ if (!cachedClient || cachedApiUrl !== apiUrl || cachedApiKey !== nextApiKey) {
36
+ cachedClient = createStatusClient({ apiUrl: apiUrl!, apiKey: nextApiKey });
37
+ cachedApiUrl = apiUrl!;
38
+ cachedApiKey = nextApiKey;
39
+ }
40
+ return cachedClient;
41
+ }
42
42
 
43
43
  async function fetchData() {
44
44
  if (!usesApi) return;
@@ -12,8 +12,8 @@
12
12
  projectName: string;
13
13
  /** API URL for fetching data (self-fetching mode) */
14
14
  apiUrl?: string;
15
- /** API key for direct admin calls. Omit when using a same-origin proxy. */
16
- apiKey?: string;
15
+ /** API key for direct admin calls. Omit when using a same-origin proxy. */
16
+ apiKey?: string;
17
17
  /** Systems data (pass-through mode — overrides API fetching) */
18
18
  systems?: System[];
19
19
  /** Incidents data (pass-through mode) */
@@ -44,22 +44,22 @@
44
44
  let error = $state('');
45
45
 
46
46
  // ── Self-fetching mode ──
47
- const usesApi = $derived(!!apiUrl && !systemsProp);
48
- let refreshTimer: ReturnType<typeof setInterval> | null = null;
47
+ const usesApi = $derived(!!apiUrl && !systemsProp);
48
+ let refreshTimer: ReturnType<typeof setInterval> | null = null;
49
49
 
50
50
  let cachedClient: StatusClient | null = null;
51
- let cachedApiUrl = '';
52
- let cachedApiKey = '';
53
-
54
- function getClient(): StatusClient {
55
- const nextApiKey = apiKey ?? '';
56
- if (!cachedClient || cachedApiUrl !== apiUrl || cachedApiKey !== nextApiKey) {
57
- cachedClient = createStatusClient({ apiUrl: apiUrl!, apiKey: nextApiKey });
58
- cachedApiUrl = apiUrl!;
59
- cachedApiKey = nextApiKey;
60
- }
61
- return cachedClient;
62
- }
51
+ let cachedApiUrl = '';
52
+ let cachedApiKey = '';
53
+
54
+ function getClient(): StatusClient {
55
+ const nextApiKey = apiKey ?? '';
56
+ if (!cachedClient || cachedApiUrl !== apiUrl || cachedApiKey !== nextApiKey) {
57
+ cachedClient = createStatusClient({ apiUrl: apiUrl!, apiKey: nextApiKey });
58
+ cachedApiUrl = apiUrl!;
59
+ cachedApiKey = nextApiKey;
60
+ }
61
+ return cachedClient;
62
+ }
63
63
 
64
64
  async function fetchData() {
65
65
  if (!usesApi) return;
@@ -1,6 +1,5 @@
1
1
  <script lang="ts">
2
- import { onMount } from 'svelte';
3
- import { Spinner, Button, Textarea, Divider } from '@alliance-droid/svelte-component-library';
2
+ import { Spinner, Button, Textarea, Divider } from '@alliance-droid/svelte-component-library';
4
3
  import type { BoardItem } from '../../api.js';
5
4
  import type { FeedbackResponse } from '../../types.js';
6
5
  import type { FeedbackBoardState } from './state.svelte.js';
@@ -102,10 +101,27 @@
102
101
 
103
102
  let isAuthenticated = $derived(!!board.userEmail);
104
103
 
105
- onMount(async () => {
106
- responses = await board.getResponses(item.id);
107
- loadingResponses = false;
108
- });
104
+ $effect(() => {
105
+ const itemId = item.id;
106
+ let cancelled = false;
107
+
108
+ // Consumers commonly keep the detail rail mounted and replace its item prop.
109
+ // Clear the old thread immediately so it is never shown under the new item.
110
+ responses = [];
111
+ loadingResponses = true;
112
+
113
+ void board.getResponses(itemId).then((nextResponses) => {
114
+ // A slower request for the previous selection must not overwrite the
115
+ // currently selected item's responses.
116
+ if (cancelled) return;
117
+ responses = nextResponses;
118
+ loadingResponses = false;
119
+ });
120
+
121
+ return () => {
122
+ cancelled = true;
123
+ };
124
+ });
109
125
 
110
126
  async function handleSubmitReply() {
111
127
  if (!replyMessage.trim() || !isAuthenticated) return;
@@ -3,7 +3,8 @@
3
3
  import type { BoardItem } from '../../api.js';
4
4
  import type { FeedbackBoardState } from './state.svelte.js';
5
5
  import { timeAgo } from './utils.js';
6
- import StatusBadgeMenu from './StatusBadgeMenu.svelte';
6
+ import StatusBadgeMenu from './StatusBadgeMenu.svelte';
7
+ import { canChangeItemStatus } from './capabilities.js';
7
8
 
8
9
  interface Props {
9
10
  board: FeedbackBoardState;
@@ -13,11 +14,13 @@
13
14
 
14
15
  let { board, item, onclick }: Props = $props();
15
16
 
16
- let isClickable = $derived(!!onclick);
17
+ let isClickable = $derived(!!onclick);
18
+ let canChangeStatus = $derived(canChangeItemStatus(board, item));
17
19
  </script>
18
20
 
19
21
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
20
- <article
22
+ <article
23
+ class:sf-editable={canChangeStatus}
21
24
  class="rounded-xl border border-border bg-surface p-4 transition-all {isClickable ? 'cursor-pointer hover:border-border-strong hover:bg-surface-hover' : ''}"
22
25
  role={isClickable ? 'button' : undefined}
23
26
  tabindex={isClickable ? 0 : undefined}
@@ -75,7 +78,12 @@
75
78
  <StatusBadgeMenu {board} {item} />
76
79
  </div>
77
80
 
78
- <!-- Meta -->
81
+ <!-- Meta -->
82
+ {#if canChangeStatus}
83
+ <p class="sf-action-hint">
84
+ {item.needsInput ? 'Needs your input' : 'You can change this status'}
85
+ </p>
86
+ {/if}
79
87
  <div class="mt-3 flex items-center gap-3 text-xs text-foreground-tertiary">
80
88
  {#if item.email}
81
89
  <span class="inline-flex min-w-0 flex-1 items-center gap-1.5">
@@ -103,7 +111,20 @@
103
111
  </div>
104
112
  </article>
105
113
 
106
- <style>
114
+ <style>
115
+ .sf-editable {
116
+ border-color: #a78bfa;
117
+ border-inline-start: 4px solid #8b5cf6;
118
+ }
119
+ .sf-action-hint {
120
+ color: #6d28d9;
121
+ background: #f3e8ff;
122
+ border-radius: 0.25rem;
123
+ padding: 0.15rem 0.4rem;
124
+ margin-top: 0.75rem;
125
+ font-size: 0.75rem;
126
+ width: fit-content;
127
+ }
107
128
  .line-clamp-1,
108
129
  .line-clamp-2 {
109
130
  display: -webkit-box;
@@ -32,8 +32,8 @@
32
32
  {:else if board.items.length === 0}
33
33
  <div class="text-center py-12 text-foreground-tertiary">
34
34
  <i class="fa-solid fa-inbox text-3xl"></i>
35
- <p class="mt-2">No feedback yet</p>
36
- <p class="text-sm">Be the first to submit!</p>
35
+ <p class="mt-2">{board.statusFilter === 'needs_input' ? 'No feedback needs your input' : 'No feedback yet'}</p>
36
+ <p class="text-sm">{board.statusFilter === 'needs_input' ? 'You’re all caught up. Choose another status to see more feedback.' : 'Be the first to submit!'}</p>
37
37
  </div>
38
38
  {:else}
39
39
  <div class="flex flex-col gap-2">
@@ -8,11 +8,12 @@
8
8
 
9
9
  let { board }: Props = $props();
10
10
 
11
- const statusOptions = [
11
+ const statusOptions = $derived([
12
12
  { value: "open", label: "Open" },
13
13
  { value: "resolved", label: "Done" },
14
- { value: "all", label: "All" },
15
- ];
14
+ { value: "all", label: "All" },
15
+ ...(board.canManageStatus ? [{ value: "needs_input", label: "Needs my input" }] : []),
16
+ ]);
16
17
 
17
18
  const sortOptions = [
18
19
  { value: "votes", label: "Most Votes" },
@@ -60,7 +61,10 @@
60
61
 
61
62
  <Divider class="my-2" />
62
63
 
63
- <div class="sf-filter-group">
64
+ <div class="sf-filter-group">
65
+ {#if board.canManageStatus}
66
+ <p class="sf-editable-legend"><span aria-hidden="true"></span>Purple marks statuses you can change.</p>
67
+ {/if}
64
68
  <Select
65
69
  label="Status"
66
70
  class="rounded-lg border-0 bg-surface-hover"
@@ -76,7 +80,9 @@
76
80
  </div>
77
81
  </aside>
78
82
 
79
- <style>
83
+ <style>
84
+ .sf-editable-legend { font-size: 0.75rem; display: flex; gap: 0.5rem; align-items: center; }
85
+ .sf-editable-legend span { width: 0.6rem; height: 0.6rem; background: #8b5cf6; border-radius: 50%; flex-shrink: 0; }
80
86
  /* Spacing/padding is bundled in the component (not Tailwind utilities) so it
81
87
  applies regardless of whether the consuming app's Tailwind generates these
82
88
  classes. gap → space between the Status and Sort By selects; the 1.25rem
@@ -2,8 +2,8 @@
2
2
  /**
3
3
  * Status badge that doubles as a status-change control for privileged users.
4
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
5
+ * Items without an authorized board decision have a read-only badge.
6
+ * Editable items have a purple button: clicking it
7
7
  * loads the transitions they may make and shows them in a menu; picking one
8
8
  * applies it (prompting for a reason first when the transition requires one).
9
9
  *
@@ -13,7 +13,7 @@
13
13
  * library's Select uses.
14
14
  *
15
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)
16
+ * status without opening the item. Transitions refresh on demand (on each open)
17
17
  * unless `loadOnMount` is set — the list stays lazy so it doesn't fire a
18
18
  * request per row.
19
19
  */
@@ -21,7 +21,8 @@
21
21
  import { onMount } from 'svelte';
22
22
  import type { BoardItem, BoardTransition } from '../../api.js';
23
23
  import type { FeedbackBoardState } from './state.svelte.js';
24
- import { getStatusBadgeVariant, internalStatusLabel } from './utils.js';
24
+ import { getStatusBadgeVariant, internalStatusLabel } from './utils.js';
25
+ import { canChangeItemStatus } from './capabilities.js';
25
26
 
26
27
  interface Props {
27
28
  board: FeedbackBoardState;
@@ -44,7 +45,8 @@
44
45
  let menuEl: HTMLDivElement | undefined = $state();
45
46
 
46
47
  // 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 txns = $derived(board.transitions[item.id]?.current.status === item.status ? board.transitions[item.id] : null);
49
+ let canChange = $derived(canChangeItemStatus(board, item));
48
50
  // Everyone sees the item's real granular status — `item.status` is present on
49
51
  // every board item (the board endpoint returns it), so all rows render their
50
52
  // actual status on first paint, no privilege or transitions fetch required.
@@ -131,7 +133,7 @@
131
133
  reason = '';
132
134
  // Open immediately so the spinner is visible while transitions load.
133
135
  menuOpen = true;
134
- if (!txns) await reload();
136
+ await reload();
135
137
  }
136
138
 
137
139
  async function reload() {
@@ -185,18 +187,20 @@
185
187
  }
186
188
  </script>
187
189
 
188
- {#if board.canManageStatus}
190
+ {#if canChange}
189
191
  <button
190
192
  bind:this={triggerEl}
191
193
  type="button"
192
- 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"
193
- onclick={toggleMenu}
194
+ class="sf-status-editable inline-flex items-center gap-1.5 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60"
195
+ onclick={toggleMenu}
196
+ onkeydown={(e) => e.stopPropagation()}
194
197
  aria-haspopup="listbox"
195
198
  aria-expanded={menuOpen}
196
199
  disabled={applying}
197
- title="Change status"
198
- >
199
- <Badge variant={getStatusBadgeVariant(item.publicStatus)} size="sm">{badgeLabel}</Badge>
200
+ title="You can change this status"
201
+ aria-label="Change status: {badgeLabel}"
202
+ >
203
+ <span>{badgeLabel}</span>
200
204
  <i class="fa-solid fa-chevron-down text-[0.625rem] text-foreground-tertiary" aria-hidden="true"></i>
201
205
  </button>
202
206
 
@@ -233,7 +237,7 @@
233
237
  </Button>
234
238
  </div>
235
239
  </div>
236
- {:else if txns && txns.targets.length > 0}
240
+ {:else if txns && txns.targets.length > 0 && !loadFailed}
237
241
  {#each txns.targets as target (target.status)}
238
242
  <button
239
243
  type="button"
@@ -268,4 +272,18 @@
268
272
  <Badge variant={getStatusBadgeVariant(item.publicStatus)} size="sm">
269
273
  {badgeLabel}
270
274
  </Badge>
271
- {/if}
275
+ {/if}
276
+
277
+ <style>
278
+ .sf-status-editable {
279
+ flex-shrink: 0;
280
+ white-space: nowrap;
281
+ color: #6d28d9;
282
+ background: #f3e8ff;
283
+ border: 1px solid #a78bfa;
284
+ padding: 0.2rem 0.5rem;
285
+ font-size: 0.75rem;
286
+ font-weight: 600;
287
+ }
288
+ .sf-status-editable:hover { background: #e9d5ff; }
289
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { BoardItem } from '../../api.js';
2
+ import type { FeedbackBoardState } from './state.svelte.js';
3
+ /** Cached transitions, when current, refine the capabilities returned with the list. */
4
+ export declare function canChangeItemStatus(board: FeedbackBoardState, item: BoardItem): boolean;
@@ -0,0 +1,10 @@
1
+ /** Cached transitions, when current, refine the capabilities returned with the list. */
2
+ export function canChangeItemStatus(board, item) {
3
+ if (!board.canManageStatus)
4
+ return false;
5
+ const transitions = board.transitions[item.id];
6
+ if (transitions?.current.status === item.status) {
7
+ return transitions.canManage && transitions.targets.length > 0;
8
+ }
9
+ return item.canChangeStatus === true;
10
+ }
@@ -24,10 +24,10 @@ export interface FeedbackBoardOptions {
24
24
  * Returns the current user's Alliance OIDC **access token** — whose audience
25
25
  * covers `status-feedback-admin` (an id token is rejected by the backend) — or
26
26
  * null if they aren't signed in / not privileged. When provided, privileged
27
- * users (Admin / ProductOwner) can set feedback status from the board. Called
27
+ * users (Admin / ProductOwner) can accept, reopen, or decline feedback. Called
28
28
  * on demand so the host controls token refresh.
29
29
  */
30
- getAuthToken?: () => string | Promise<string | null>;
30
+ getAuthToken?: () => string | null | Promise<string | null>;
31
31
  /** Items per page. Default: 50 */
32
32
  pageSize?: number;
33
33
  }
@@ -63,7 +63,7 @@ export interface FeedbackBoardState {
63
63
  /**
64
64
  * Whether the current user may manage status — resolved against the backend
65
65
  * (valid token AND a qualifying role + project access), not merely whether a
66
- * token getter was provided. Starts `false` and flips `true` once confirmed,
66
+ * token getter was provided. Resolved on each board read, including empty pages,
67
67
  * so UI can gate the status control by actual privilege.
68
68
  */
69
69
  readonly canManageStatus: boolean;