anton-bakker-feedback 0.1.0 → 0.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/README.md +78 -0
- package/dist/react/admin/FeedbackDetailPage.d.ts +12 -0
- package/dist/react/admin/FeedbackDetailPage.d.ts.map +1 -0
- package/dist/react/admin/FeedbackListPage.d.ts +20 -0
- package/dist/react/admin/FeedbackListPage.d.ts.map +1 -0
- package/dist/react/admin/FeedbackUpdatePage.d.ts +12 -0
- package/dist/react/admin/FeedbackUpdatePage.d.ts.map +1 -0
- package/dist/react/admin/__tests__/FeedbackDetailPage.test.d.ts +2 -0
- package/dist/react/admin/__tests__/FeedbackDetailPage.test.d.ts.map +1 -0
- package/dist/react/admin/__tests__/FeedbackListPage.test.d.ts +2 -0
- package/dist/react/admin/__tests__/FeedbackListPage.test.d.ts.map +1 -0
- package/dist/react/admin/__tests__/FeedbackUpdatePage.test.d.ts +2 -0
- package/dist/react/admin/__tests__/FeedbackUpdatePage.test.d.ts.map +1 -0
- package/dist/react/admin/hooks/useStatusTransition.d.ts +18 -0
- package/dist/react/admin/hooks/useStatusTransition.d.ts.map +1 -0
- package/dist/react/admin/index.d.ts +7 -0
- package/dist/react/admin/index.d.ts.map +1 -0
- package/dist/react/admin/index.js +406 -0
- package/dist/react/admin/index.js.map +1 -0
- package/dist/react/admin/types.d.ts +44 -0
- package/dist/react/admin/types.d.ts.map +1 -0
- package/dist/react.js +6 -6
- package/dist/react.js.map +1 -1
- package/package.json +14 -1
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# anton-bakker-feedback
|
|
2
|
+
|
|
3
|
+
Zero-dependency feedback collection package with LLM-powered dialogue intake, auto-capture, classification, and observability.
|
|
4
|
+
|
|
5
|
+
## Exports
|
|
6
|
+
|
|
7
|
+
| Entry point | Purpose |
|
|
8
|
+
|-------------|---------|
|
|
9
|
+
| `anton-bakker-feedback` | Core: types, capture, transport, config, i18n, observability |
|
|
10
|
+
| `anton-bakker-feedback/react` | Widget: FeedbackProvider, FeedbackPanel, FeedbackButton, etc. |
|
|
11
|
+
| `anton-bakker-feedback/react/admin` | Admin pages: List, Detail, Update |
|
|
12
|
+
| `anton-bakker-feedback/server` | Server handlers: DynamoDB + Bedrock factories |
|
|
13
|
+
| `anton-bakker-feedback/manifest` | Package manifest for plugin discovery |
|
|
14
|
+
|
|
15
|
+
## Admin Pages
|
|
16
|
+
|
|
17
|
+
The `./react/admin` entry point provides three headless page-level components for managing submitted feedback. Components are unstyled — pass `className` props to integrate with Tailwind CSS or any other CSS framework.
|
|
18
|
+
|
|
19
|
+
### Import
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import {
|
|
23
|
+
FeedbackListPage,
|
|
24
|
+
FeedbackDetailPage,
|
|
25
|
+
FeedbackUpdatePage,
|
|
26
|
+
} from 'anton-bakker-feedback/react/admin';
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Admin pages are tree-shakeable. Apps that import only from `./react` (the widget) do not bundle admin page code.
|
|
30
|
+
|
|
31
|
+
### Props contract
|
|
32
|
+
|
|
33
|
+
All three pages use **Inversion of Control** — you provide the data and navigation functions; the component provides the UI structure.
|
|
34
|
+
|
|
35
|
+
| Prop | Type | Used by | Description |
|
|
36
|
+
|------|------|---------|-------------|
|
|
37
|
+
| `items` | `StoredFeedbackItem[]` | List | Feedback items to display |
|
|
38
|
+
| `loading` | `boolean` | List | Show loading state |
|
|
39
|
+
| `fetchItem` | `(id: string) => Promise<StoredFeedbackItem>` | Detail, Update | Load a single item |
|
|
40
|
+
| `updateItem` | `(id: string, patch: Partial<StoredFeedbackItem>) => Promise<void>` | Update | Persist changes |
|
|
41
|
+
| `onNavigate` | `(path: string) => void` | All | Consumer-owned routing |
|
|
42
|
+
| `currentUser` | `{ userId: string; name?: string }` | All (optional) | For assignee display |
|
|
43
|
+
| `className` | `string` | All (optional) | Root element class |
|
|
44
|
+
| `feedbackId` | `string` | Detail, Update | Item to load/edit |
|
|
45
|
+
|
|
46
|
+
### Minimal example
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
import { FeedbackListPage } from 'anton-bakker-feedback/react/admin';
|
|
50
|
+
|
|
51
|
+
export function AdminFeedbackRoute({ items, loading }) {
|
|
52
|
+
return (
|
|
53
|
+
<FeedbackListPage
|
|
54
|
+
items={items}
|
|
55
|
+
loading={loading}
|
|
56
|
+
onNavigate={(path) => router.push(path)}
|
|
57
|
+
className="my-admin-list"
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Error handling
|
|
64
|
+
|
|
65
|
+
Components handle loading and empty-state UI internally. Data fetch errors should be caught in your `fetchItem`/`updateItem` implementations and surfaced via your consumer-level error boundary.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm install
|
|
71
|
+
npm run build # Build all entry points
|
|
72
|
+
npx vitest run # Run tests
|
|
73
|
+
npx tsc --noEmit # Type check
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { FeedbackDetailPageProps } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* FeedbackDetailPage.tsx
|
|
4
|
+
* Location: src/react/admin/FeedbackDetailPage.tsx
|
|
5
|
+
*
|
|
6
|
+
* Admin detail page for viewing a single feedback item.
|
|
7
|
+
* Uses DetailPage + DetailSection + DetailRow from anton-bakker-tailwind-components.
|
|
8
|
+
*/
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
declare const FeedbackDetailPage: React.ForwardRefExoticComponent<FeedbackDetailPageProps & React.RefAttributes<HTMLDivElement>>;
|
|
11
|
+
export { FeedbackDetailPage };
|
|
12
|
+
//# sourceMappingURL=FeedbackDetailPage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FeedbackDetailPage.d.ts","sourceRoot":"","sources":["../../../src/react/admin/FeedbackDetailPage.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAG/B,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAG1D,QAAA,MAAM,kBAAkB,gGAoGvB,CAAC;AAGF,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { FeedbackStatus, FeedbackType } from '../../core/types.js';
|
|
2
|
+
import { FeedbackListPageProps } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* FeedbackListPage.tsx
|
|
5
|
+
* Location: src/react/admin/FeedbackListPage.tsx
|
|
6
|
+
*
|
|
7
|
+
* Admin list page for browsing and filtering feedback items.
|
|
8
|
+
* Thin wrapper around ListPage from anton-bakker-tailwind-components
|
|
9
|
+
* for visual consistency with sagrosoft20.
|
|
10
|
+
*/
|
|
11
|
+
import * as React from 'react';
|
|
12
|
+
declare function StatusBadge({ status }: {
|
|
13
|
+
status: FeedbackStatus;
|
|
14
|
+
}): React.JSX.Element;
|
|
15
|
+
declare function TypeBadge({ type }: {
|
|
16
|
+
type: FeedbackType;
|
|
17
|
+
}): React.JSX.Element;
|
|
18
|
+
declare const FeedbackListPage: React.ForwardRefExoticComponent<FeedbackListPageProps & React.RefAttributes<HTMLDivElement>>;
|
|
19
|
+
export { FeedbackListPage, StatusBadge, TypeBadge };
|
|
20
|
+
//# sourceMappingURL=FeedbackListPage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FeedbackListPage.d.ts","sourceRoot":"","sources":["../../../src/react/admin/FeedbackListPage.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAG/B,OAAO,KAAK,EAAsB,cAAc,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAC5F,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAiBxD,iBAAS,WAAW,CAAC,EAAE,MAAM,EAAE,EAAE;IAAE,MAAM,EAAE,cAAc,CAAA;CAAE,qBAE1D;AAED,iBAAS,SAAS,CAAC,EAAE,IAAI,EAAE,EAAE;IAAE,IAAI,EAAE,YAAY,CAAA;CAAE,qBAElD;AAED,QAAA,MAAM,gBAAgB,8FA0FrB,CAAC;AAGF,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { FeedbackUpdatePageProps } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* FeedbackUpdatePage.tsx
|
|
4
|
+
* Location: src/react/admin/FeedbackUpdatePage.tsx
|
|
5
|
+
*
|
|
6
|
+
* Admin update page for editing mutable fields of a feedback item.
|
|
7
|
+
* Uses UpdatePage + EnumField + TextField + TextareaField from anton-bakker-tailwind-components.
|
|
8
|
+
*/
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
declare const FeedbackUpdatePage: React.ForwardRefExoticComponent<FeedbackUpdatePageProps & React.RefAttributes<HTMLDivElement>>;
|
|
11
|
+
export { FeedbackUpdatePage };
|
|
12
|
+
//# sourceMappingURL=FeedbackUpdatePage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FeedbackUpdatePage.d.ts","sourceRoot":"","sources":["../../../src/react/admin/FeedbackUpdatePage.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAG/B,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAiC1D,QAAA,MAAM,kBAAkB,gGAiMvB,CAAC;AAGF,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FeedbackDetailPage.test.d.ts","sourceRoot":"","sources":["../../../../src/react/admin/__tests__/FeedbackDetailPage.test.tsx"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FeedbackListPage.test.d.ts","sourceRoot":"","sources":["../../../../src/react/admin/__tests__/FeedbackListPage.test.tsx"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FeedbackUpdatePage.test.d.ts","sourceRoot":"","sources":["../../../../src/react/admin/__tests__/FeedbackUpdatePage.test.tsx"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { FeedbackStatus } from '../../../core/types.js';
|
|
2
|
+
|
|
3
|
+
export interface UseStatusTransitionResult {
|
|
4
|
+
/** Statuses reachable from the current status (current status excluded) */
|
|
5
|
+
allowedTargets: FeedbackStatus[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Returns the allowed target statuses from the given current status.
|
|
9
|
+
*
|
|
10
|
+
* @param currentStatus - The status the item currently has
|
|
11
|
+
* @returns Object with `allowedTargets` — statuses the item can transition to
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const { allowedTargets } = useStatusTransition('open');
|
|
15
|
+
* // allowedTargets → ['approved', 'deferred', 'wontfix']
|
|
16
|
+
*/
|
|
17
|
+
export declare function useStatusTransition(currentStatus: FeedbackStatus): UseStatusTransitionResult;
|
|
18
|
+
//# sourceMappingURL=useStatusTransition.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useStatusTransition.d.ts","sourceRoot":"","sources":["../../../../src/react/admin/hooks/useStatusTransition.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAc7D,MAAM,WAAW,yBAAyB;IACxC,2EAA2E;IAC3E,cAAc,EAAE,cAAc,EAAE,CAAC;CAClC;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,cAAc,GAAG,yBAAyB,CAG5F"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { FeedbackListPage, StatusBadge, TypeBadge } from './FeedbackListPage.js';
|
|
2
|
+
export { FeedbackDetailPage } from './FeedbackDetailPage.js';
|
|
3
|
+
export { FeedbackUpdatePage } from './FeedbackUpdatePage.js';
|
|
4
|
+
export { useStatusTransition } from './hooks/useStatusTransition.js';
|
|
5
|
+
export type { AdminPageProps, FeedbackListPageProps, FeedbackDetailPageProps, FeedbackUpdatePageProps, } from './types.js';
|
|
6
|
+
export type { StoredFeedbackItem, FeedbackStatus, FeedbackType, FeedbackScope, FeedbackSize, FeedbackEnvironment, Reporter, DialogueEntry, CorrectionEntry, FeedbackContext, } from '../../core/types.js';
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/admin/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACjF,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAG7D,OAAO,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAC;AAGrE,YAAY,EACV,cAAc,EACd,qBAAqB,EACrB,uBAAuB,EACvB,uBAAuB,GACxB,MAAM,YAAY,CAAC;AAGpB,YAAY,EACV,kBAAkB,EAClB,cAAc,EACd,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,mBAAmB,EACnB,QAAQ,EACR,aAAa,EACb,eAAe,EACf,eAAe,GAChB,MAAM,qBAAqB,CAAC"}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { jsx, jsxs, Fragment } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { ListPage, Badge, DetailPage, Button, DetailSection, DetailRow, UpdatePage, EnumField, TextField, TextareaField, SaveFooter } from "anton-bakker-tailwind-components";
|
|
4
|
+
const STATUS_VARIANT = {
|
|
5
|
+
open: "warning",
|
|
6
|
+
approved: "default",
|
|
7
|
+
deferred: "secondary",
|
|
8
|
+
resolved: "success",
|
|
9
|
+
wontfix: "destructive"
|
|
10
|
+
};
|
|
11
|
+
const TYPE_VARIANT = {
|
|
12
|
+
bug: "destructive",
|
|
13
|
+
feature: "default",
|
|
14
|
+
ux: "warning",
|
|
15
|
+
other: "secondary"
|
|
16
|
+
};
|
|
17
|
+
function StatusBadge({ status }) {
|
|
18
|
+
return /* @__PURE__ */ jsx(Badge, { variant: STATUS_VARIANT[status] ?? "secondary", children: status });
|
|
19
|
+
}
|
|
20
|
+
function TypeBadge({ type }) {
|
|
21
|
+
return /* @__PURE__ */ jsx(Badge, { variant: TYPE_VARIANT[type] ?? "secondary", children: type });
|
|
22
|
+
}
|
|
23
|
+
const FeedbackListPage = React.forwardRef(
|
|
24
|
+
({ items, loading = false, onNavigate, className, emptyStateMessage, currentUser, ...rest }, ref) => {
|
|
25
|
+
const [search, setSearch] = React.useState("");
|
|
26
|
+
const [statusFilter, setStatusFilter] = React.useState("");
|
|
27
|
+
const [typeFilter, setTypeFilter] = React.useState("");
|
|
28
|
+
const filteredItems = React.useMemo(() => {
|
|
29
|
+
let result = items;
|
|
30
|
+
if (statusFilter) result = result.filter((i) => i.status === statusFilter);
|
|
31
|
+
if (typeFilter) result = result.filter((i) => i.type === typeFilter);
|
|
32
|
+
if (search) {
|
|
33
|
+
const q = search.toLowerCase();
|
|
34
|
+
result = result.filter((i) => i.content?.toLowerCase().includes(q) || i.id.toLowerCase().includes(q));
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}, [items, statusFilter, typeFilter, search]);
|
|
38
|
+
const columns = React.useMemo(() => [
|
|
39
|
+
{ header: "Content", accessor: (row) => row.content?.slice(0, 60) || row.id, sortable: true },
|
|
40
|
+
{ header: "Type", accessor: (row) => /* @__PURE__ */ jsx(TypeBadge, { type: row.type }), sortable: true },
|
|
41
|
+
{ header: "Status", accessor: (row) => /* @__PURE__ */ jsx(StatusBadge, { status: row.status }), sortable: true },
|
|
42
|
+
{ header: "Priority", accessor: "priority", sortable: true },
|
|
43
|
+
{ header: "Reporters", accessor: "reporterCount", sortable: true },
|
|
44
|
+
{
|
|
45
|
+
header: "Date",
|
|
46
|
+
accessor: (row) => row.submittedAt ? new Date(row.submittedAt).toLocaleDateString() : "—",
|
|
47
|
+
sortable: true
|
|
48
|
+
}
|
|
49
|
+
], []);
|
|
50
|
+
const filterBar = statusFilter || typeFilter ? {
|
|
51
|
+
filters: [
|
|
52
|
+
...statusFilter ? [{ field: "status", label: `Status: ${statusFilter}`, value: statusFilter }] : [],
|
|
53
|
+
...typeFilter ? [{ field: "type", label: `Type: ${typeFilter}`, value: typeFilter }] : []
|
|
54
|
+
],
|
|
55
|
+
onFilterRemove: (field) => {
|
|
56
|
+
if (field === "status") setStatusFilter("");
|
|
57
|
+
if (field === "type") setTypeFilter("");
|
|
58
|
+
},
|
|
59
|
+
onFiltersClear: () => {
|
|
60
|
+
setStatusFilter("");
|
|
61
|
+
setTypeFilter("");
|
|
62
|
+
}
|
|
63
|
+
} : void 0;
|
|
64
|
+
const headerActions = /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
65
|
+
/* @__PURE__ */ jsxs(
|
|
66
|
+
"select",
|
|
67
|
+
{
|
|
68
|
+
value: statusFilter,
|
|
69
|
+
onChange: (e) => setStatusFilter(e.target.value),
|
|
70
|
+
className: "h-8 rounded-md border border-border bg-transparent px-2 text-sm",
|
|
71
|
+
"aria-label": "Filter by status",
|
|
72
|
+
children: [
|
|
73
|
+
/* @__PURE__ */ jsx("option", { value: "", children: "All statuses" }),
|
|
74
|
+
/* @__PURE__ */ jsx("option", { value: "open", children: "Open" }),
|
|
75
|
+
/* @__PURE__ */ jsx("option", { value: "approved", children: "Approved" }),
|
|
76
|
+
/* @__PURE__ */ jsx("option", { value: "deferred", children: "Deferred" }),
|
|
77
|
+
/* @__PURE__ */ jsx("option", { value: "resolved", children: "Resolved" }),
|
|
78
|
+
/* @__PURE__ */ jsx("option", { value: "wontfix", children: "Won't fix" })
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
),
|
|
82
|
+
/* @__PURE__ */ jsxs(
|
|
83
|
+
"select",
|
|
84
|
+
{
|
|
85
|
+
value: typeFilter,
|
|
86
|
+
onChange: (e) => setTypeFilter(e.target.value),
|
|
87
|
+
className: "h-8 rounded-md border border-border bg-transparent px-2 text-sm",
|
|
88
|
+
"aria-label": "Filter by type",
|
|
89
|
+
children: [
|
|
90
|
+
/* @__PURE__ */ jsx("option", { value: "", children: "All types" }),
|
|
91
|
+
/* @__PURE__ */ jsx("option", { value: "bug", children: "Bug" }),
|
|
92
|
+
/* @__PURE__ */ jsx("option", { value: "feature", children: "Feature" }),
|
|
93
|
+
/* @__PURE__ */ jsx("option", { value: "ux", children: "UX" }),
|
|
94
|
+
/* @__PURE__ */ jsx("option", { value: "other", children: "Other" })
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
] });
|
|
99
|
+
const listProps = {
|
|
100
|
+
title: "Feedback",
|
|
101
|
+
columns,
|
|
102
|
+
items: filteredItems,
|
|
103
|
+
loading,
|
|
104
|
+
onRowClick: (row) => onNavigate(`/feedback/${row.id}`),
|
|
105
|
+
search,
|
|
106
|
+
onSearchChange: setSearch,
|
|
107
|
+
searchPlaceholder: "Search feedback...",
|
|
108
|
+
headerActions,
|
|
109
|
+
testId: "feedback-list-page",
|
|
110
|
+
...filterBar ? { filterBar } : {},
|
|
111
|
+
...emptyStateMessage ? { emptyState: emptyStateMessage } : {},
|
|
112
|
+
...className ? { className } : {}
|
|
113
|
+
};
|
|
114
|
+
return /* @__PURE__ */ jsx(ListPage, { ref, ...listProps });
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
FeedbackListPage.displayName = "FeedbackListPage";
|
|
118
|
+
const FeedbackDetailPage = React.forwardRef(
|
|
119
|
+
({ feedbackId, fetchItem, onNavigate, currentUser, className }, ref) => {
|
|
120
|
+
const [item, setItem] = React.useState(null);
|
|
121
|
+
const [loading, setLoading] = React.useState(true);
|
|
122
|
+
const [error, setError] = React.useState(null);
|
|
123
|
+
React.useEffect(() => {
|
|
124
|
+
let cancelled = false;
|
|
125
|
+
setLoading(true);
|
|
126
|
+
setError(null);
|
|
127
|
+
fetchItem(feedbackId).then((data) => {
|
|
128
|
+
if (!cancelled) setItem(data);
|
|
129
|
+
}).catch((err) => {
|
|
130
|
+
if (!cancelled) setError(err?.message ?? "Failed to load item");
|
|
131
|
+
}).finally(() => {
|
|
132
|
+
if (!cancelled) setLoading(false);
|
|
133
|
+
});
|
|
134
|
+
return () => {
|
|
135
|
+
cancelled = true;
|
|
136
|
+
};
|
|
137
|
+
}, [feedbackId, fetchItem]);
|
|
138
|
+
if (error) {
|
|
139
|
+
return /* @__PURE__ */ jsx("div", { ref, className, children: /* @__PURE__ */ jsx(DetailPage, { title: "Feedback", headerActions: /* @__PURE__ */ jsx(Button, { variant: "outline", onClick: () => onNavigate("/feedback"), children: "Back to list" }), children: /* @__PURE__ */ jsx("div", { className: "p-6 text-destructive", children: error }) }) });
|
|
140
|
+
}
|
|
141
|
+
const headerActions = /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
142
|
+
/* @__PURE__ */ jsx(Button, { variant: "outline", onClick: () => onNavigate("/feedback"), children: "Back to list" }),
|
|
143
|
+
/* @__PURE__ */ jsx(Button, { onClick: () => onNavigate(`/feedback/${feedbackId}/edit`), children: "Edit" })
|
|
144
|
+
] });
|
|
145
|
+
return /* @__PURE__ */ jsx("div", { ref, className, children: /* @__PURE__ */ jsx(
|
|
146
|
+
DetailPage,
|
|
147
|
+
{
|
|
148
|
+
title: item?.content?.slice(0, 50) || `Feedback ${feedbackId.slice(0, 12)}…`,
|
|
149
|
+
headerActions,
|
|
150
|
+
loading,
|
|
151
|
+
testId: "feedback-detail-page",
|
|
152
|
+
children: item && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
153
|
+
/* @__PURE__ */ jsxs(DetailSection, { title: "Overview", children: [
|
|
154
|
+
/* @__PURE__ */ jsx(DetailRow, { label: "Status", children: /* @__PURE__ */ jsx(StatusBadge, { status: item.status }) }),
|
|
155
|
+
/* @__PURE__ */ jsx(DetailRow, { label: "Type", children: /* @__PURE__ */ jsx(TypeBadge, { type: item.type }) }),
|
|
156
|
+
/* @__PURE__ */ jsx(DetailRow, { label: "Priority", children: item.priority }),
|
|
157
|
+
/* @__PURE__ */ jsx(DetailRow, { label: "Environment", children: item.environment || "—" }),
|
|
158
|
+
/* @__PURE__ */ jsx(DetailRow, { label: "Submitted", children: item.submittedAt ? new Date(item.submittedAt).toLocaleString() : "—" }),
|
|
159
|
+
/* @__PURE__ */ jsx(DetailRow, { label: "Reporters", children: item.reporterCount ?? 1 }),
|
|
160
|
+
item.assignedTo && /* @__PURE__ */ jsx(DetailRow, { label: "Assigned to", children: item.assignedTo }),
|
|
161
|
+
item.scope && /* @__PURE__ */ jsx(DetailRow, { label: "Scope", children: /* @__PURE__ */ jsx(Badge, { variant: "outline", children: item.scope }) }),
|
|
162
|
+
item.size && /* @__PURE__ */ jsx(DetailRow, { label: "Size", children: /* @__PURE__ */ jsx(Badge, { variant: "outline", children: item.size }) })
|
|
163
|
+
] }),
|
|
164
|
+
/* @__PURE__ */ jsx(DetailSection, { title: "Content", children: /* @__PURE__ */ jsx(DetailRow, { label: "Description", children: /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap", children: item.content }) }) }),
|
|
165
|
+
item.dialogueHistory && item.dialogueHistory.length > 0 && /* @__PURE__ */ jsx(DetailSection, { title: "Dialogue History", collapsible: true, defaultOpen: false, children: item.dialogueHistory.map((entry, i) => /* @__PURE__ */ jsxs(DetailRow, { label: entry.role === "user" ? "User" : "Assistant", children: [
|
|
166
|
+
/* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap text-sm", children: entry.content }),
|
|
167
|
+
entry.timestamp && /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: new Date(entry.timestamp).toLocaleString() })
|
|
168
|
+
] }, i)) }),
|
|
169
|
+
item.context && /* @__PURE__ */ jsxs(DetailSection, { title: "Browser Context", collapsible: true, defaultOpen: false, children: [
|
|
170
|
+
item.context.url && /* @__PURE__ */ jsx(DetailRow, { label: "URL", children: item.context.url }),
|
|
171
|
+
item.context.userAgent && /* @__PURE__ */ jsx(DetailRow, { label: "User Agent", children: item.context.userAgent }),
|
|
172
|
+
item.context.viewport && /* @__PURE__ */ jsx(DetailRow, { label: "Viewport", children: `${item.context.viewport.width}×${item.context.viewport.height}` })
|
|
173
|
+
] }),
|
|
174
|
+
item.tags && item.tags.length > 0 && /* @__PURE__ */ jsx(DetailSection, { title: "Tags", children: /* @__PURE__ */ jsx(DetailRow, { label: "Tags", children: /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1", children: item.tags.map((tag) => /* @__PURE__ */ jsx(Badge, { variant: "outline", children: tag }, tag)) }) }) })
|
|
175
|
+
] })
|
|
176
|
+
}
|
|
177
|
+
) });
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
FeedbackDetailPage.displayName = "FeedbackDetailPage";
|
|
181
|
+
const ALLOWED_TRANSITIONS = {
|
|
182
|
+
open: ["approved", "deferred", "wontfix"],
|
|
183
|
+
approved: ["open", "resolved", "deferred"],
|
|
184
|
+
deferred: ["open", "approved", "wontfix"],
|
|
185
|
+
resolved: ["open", "approved"],
|
|
186
|
+
wontfix: ["open", "deferred"]
|
|
187
|
+
};
|
|
188
|
+
function useStatusTransition(currentStatus) {
|
|
189
|
+
const allowedTargets = ALLOWED_TRANSITIONS[currentStatus] ?? [];
|
|
190
|
+
return { allowedTargets };
|
|
191
|
+
}
|
|
192
|
+
const STATUS_OPTIONS = [
|
|
193
|
+
{ value: "open", label: "Open" },
|
|
194
|
+
{ value: "approved", label: "Approved" },
|
|
195
|
+
{ value: "deferred", label: "Deferred" },
|
|
196
|
+
{ value: "resolved", label: "Resolved" },
|
|
197
|
+
{ value: "wontfix", label: "Won't Fix" }
|
|
198
|
+
];
|
|
199
|
+
const TYPE_OPTIONS = [
|
|
200
|
+
{ value: "bug", label: "Bug" },
|
|
201
|
+
{ value: "feature", label: "Feature" },
|
|
202
|
+
{ value: "ux", label: "UX Issue" },
|
|
203
|
+
{ value: "other", label: "Other" }
|
|
204
|
+
];
|
|
205
|
+
const SCOPE_OPTIONS = [
|
|
206
|
+
{ value: "", label: "— not set —" },
|
|
207
|
+
{ value: "component", label: "Component" },
|
|
208
|
+
{ value: "page", label: "Page" },
|
|
209
|
+
{ value: "workflow", label: "Workflow" }
|
|
210
|
+
];
|
|
211
|
+
const SIZE_OPTIONS = [
|
|
212
|
+
{ value: "", label: "— not set —" },
|
|
213
|
+
{ value: "trivial", label: "Trivial" },
|
|
214
|
+
{ value: "small", label: "Small" },
|
|
215
|
+
{ value: "medium", label: "Medium" },
|
|
216
|
+
{ value: "large", label: "Large" }
|
|
217
|
+
];
|
|
218
|
+
const FeedbackUpdatePage = React.forwardRef(
|
|
219
|
+
({ feedbackId, fetchItem, updateItem, onNavigate, currentUser, className }, ref) => {
|
|
220
|
+
const [item, setItem] = React.useState(null);
|
|
221
|
+
const [loading, setLoading] = React.useState(true);
|
|
222
|
+
const [saving, setSaving] = React.useState(false);
|
|
223
|
+
const [status, setStatus] = React.useState("open");
|
|
224
|
+
const [type, setType] = React.useState("bug");
|
|
225
|
+
const [scope, setScope] = React.useState("");
|
|
226
|
+
const [size, setSize] = React.useState("");
|
|
227
|
+
const [assignedTo, setAssignedTo] = React.useState("");
|
|
228
|
+
const [tags, setTags] = React.useState("");
|
|
229
|
+
const [correctionNote, setCorrectionNote] = React.useState("");
|
|
230
|
+
React.useEffect(() => {
|
|
231
|
+
let cancelled = false;
|
|
232
|
+
setLoading(true);
|
|
233
|
+
fetchItem(feedbackId).then((data) => {
|
|
234
|
+
if (cancelled) return;
|
|
235
|
+
setItem(data);
|
|
236
|
+
setStatus(data.status);
|
|
237
|
+
setType(data.type);
|
|
238
|
+
setScope(data.scope ?? "");
|
|
239
|
+
setSize(data.size ?? "");
|
|
240
|
+
setAssignedTo(data.assignedTo ?? "");
|
|
241
|
+
setTags(data.tags?.join(", ") ?? "");
|
|
242
|
+
}).finally(() => {
|
|
243
|
+
if (!cancelled) setLoading(false);
|
|
244
|
+
});
|
|
245
|
+
return () => {
|
|
246
|
+
cancelled = true;
|
|
247
|
+
};
|
|
248
|
+
}, [feedbackId, fetchItem]);
|
|
249
|
+
const { allowedTargets } = useStatusTransition(status);
|
|
250
|
+
const statusOptions = STATUS_OPTIONS.filter(
|
|
251
|
+
(opt) => opt.value === status || allowedTargets.includes(opt.value)
|
|
252
|
+
);
|
|
253
|
+
const classificationChanged = item && (type !== item.type || scope !== (item.scope ?? "") || size !== (item.size ?? ""));
|
|
254
|
+
const isDirty = item && (status !== item.status || type !== item.type || scope !== (item.scope ?? "") || size !== (item.size ?? "") || assignedTo !== (item.assignedTo ?? "") || tags !== (item.tags?.join(", ") ?? ""));
|
|
255
|
+
const handleSave = async () => {
|
|
256
|
+
if (!item) return;
|
|
257
|
+
setSaving(true);
|
|
258
|
+
try {
|
|
259
|
+
const patch = { lastModifiedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
260
|
+
if (status !== item.status) patch.status = status;
|
|
261
|
+
if (type !== item.type) patch.type = type;
|
|
262
|
+
if (scope !== (item.scope ?? "")) patch.scope = scope || void 0;
|
|
263
|
+
if (size !== (item.size ?? "")) patch.size = size || void 0;
|
|
264
|
+
if (assignedTo !== (item.assignedTo ?? "")) patch.assignedTo = assignedTo || void 0;
|
|
265
|
+
const newTags = tags.split(",").map((t) => t.trim()).filter(Boolean);
|
|
266
|
+
if (JSON.stringify(newTags) !== JSON.stringify(item.tags ?? [])) patch.tags = newTags;
|
|
267
|
+
if (status === "resolved" && item.status !== "resolved") {
|
|
268
|
+
patch.resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
269
|
+
patch.resolvedBy = currentUser?.userId ?? "unknown";
|
|
270
|
+
}
|
|
271
|
+
if (classificationChanged && correctionNote) {
|
|
272
|
+
patch.corrections = [
|
|
273
|
+
...item.corrections ?? [],
|
|
274
|
+
{
|
|
275
|
+
field: [type !== item.type && "type", scope !== (item.scope ?? "") && "scope", size !== (item.size ?? "") && "size"].filter(Boolean).join(", "),
|
|
276
|
+
before: { type: item.type, scope: item.scope, size: item.size },
|
|
277
|
+
after: { type, scope: scope || void 0, size: size || void 0 },
|
|
278
|
+
correctedBy: currentUser?.userId ?? "unknown",
|
|
279
|
+
correctedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
280
|
+
reason: correctionNote
|
|
281
|
+
}
|
|
282
|
+
];
|
|
283
|
+
}
|
|
284
|
+
await updateItem(feedbackId, patch);
|
|
285
|
+
onNavigate(`/feedback/${feedbackId}`);
|
|
286
|
+
} finally {
|
|
287
|
+
setSaving(false);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
const headerActions = /* @__PURE__ */ jsx(Button, { variant: "outline", onClick: () => onNavigate(`/feedback/${feedbackId}`), children: "Cancel" });
|
|
291
|
+
return /* @__PURE__ */ jsx("div", { ref, className, children: /* @__PURE__ */ jsx(
|
|
292
|
+
UpdatePage,
|
|
293
|
+
{
|
|
294
|
+
title: `Edit: ${item?.content?.slice(0, 40) || feedbackId.slice(0, 12)}…`,
|
|
295
|
+
headerActions,
|
|
296
|
+
dataLoading: loading,
|
|
297
|
+
actions: /* @__PURE__ */ jsxs(SaveFooter, { dirty: !!isDirty, saving, children: [
|
|
298
|
+
/* @__PURE__ */ jsx(Button, { variant: "outline", onClick: () => onNavigate(`/feedback/${feedbackId}`), children: "Cancel" }),
|
|
299
|
+
/* @__PURE__ */ jsx(
|
|
300
|
+
Button,
|
|
301
|
+
{
|
|
302
|
+
onClick: handleSave,
|
|
303
|
+
disabled: saving || !isDirty || !!classificationChanged && !correctionNote,
|
|
304
|
+
children: saving ? "Saving…" : "Save changes"
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
] }),
|
|
308
|
+
testId: "feedback-update-page",
|
|
309
|
+
children: item && /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
|
|
310
|
+
/* @__PURE__ */ jsxs("div", { className: "rounded-lg border border-border p-4 bg-muted/30", children: [
|
|
311
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground mb-2", children: "Original submission" }),
|
|
312
|
+
/* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap text-sm", children: item.content }),
|
|
313
|
+
/* @__PURE__ */ jsxs("div", { className: "flex gap-3 mt-3 text-xs text-muted-foreground", children: [
|
|
314
|
+
/* @__PURE__ */ jsx("span", { children: item.submittedAt ? new Date(item.submittedAt).toLocaleString() : "" }),
|
|
315
|
+
/* @__PURE__ */ jsx("span", { children: item.environment }),
|
|
316
|
+
/* @__PURE__ */ jsxs("span", { children: [
|
|
317
|
+
item.reporterCount ?? 1,
|
|
318
|
+
" reporter(s)"
|
|
319
|
+
] })
|
|
320
|
+
] })
|
|
321
|
+
] }),
|
|
322
|
+
/* @__PURE__ */ jsxs("div", { className: "grid grid-cols-1 md:grid-cols-2 gap-4", children: [
|
|
323
|
+
/* @__PURE__ */ jsx(
|
|
324
|
+
EnumField,
|
|
325
|
+
{
|
|
326
|
+
label: "Status",
|
|
327
|
+
options: statusOptions,
|
|
328
|
+
value: status,
|
|
329
|
+
onChange: (v) => setStatus(v)
|
|
330
|
+
}
|
|
331
|
+
),
|
|
332
|
+
/* @__PURE__ */ jsx(
|
|
333
|
+
EnumField,
|
|
334
|
+
{
|
|
335
|
+
label: "Type",
|
|
336
|
+
options: TYPE_OPTIONS,
|
|
337
|
+
value: type,
|
|
338
|
+
onChange: (v) => setType(v)
|
|
339
|
+
}
|
|
340
|
+
),
|
|
341
|
+
/* @__PURE__ */ jsx(
|
|
342
|
+
EnumField,
|
|
343
|
+
{
|
|
344
|
+
label: "Scope",
|
|
345
|
+
options: SCOPE_OPTIONS,
|
|
346
|
+
value: scope,
|
|
347
|
+
onChange: setScope
|
|
348
|
+
}
|
|
349
|
+
),
|
|
350
|
+
/* @__PURE__ */ jsx(
|
|
351
|
+
EnumField,
|
|
352
|
+
{
|
|
353
|
+
label: "Size",
|
|
354
|
+
options: SIZE_OPTIONS,
|
|
355
|
+
value: size,
|
|
356
|
+
onChange: setSize
|
|
357
|
+
}
|
|
358
|
+
),
|
|
359
|
+
/* @__PURE__ */ jsx(
|
|
360
|
+
TextField,
|
|
361
|
+
{
|
|
362
|
+
label: "Assigned to",
|
|
363
|
+
value: assignedTo,
|
|
364
|
+
onChange: (e) => setAssignedTo(e.target.value),
|
|
365
|
+
placeholder: currentUser?.name ?? currentUser?.userId ?? "",
|
|
366
|
+
...currentUser ? { description: `You: ${currentUser.name ?? currentUser.userId}` } : {}
|
|
367
|
+
}
|
|
368
|
+
),
|
|
369
|
+
/* @__PURE__ */ jsx(
|
|
370
|
+
TextField,
|
|
371
|
+
{
|
|
372
|
+
label: "Tags",
|
|
373
|
+
value: tags,
|
|
374
|
+
onChange: (e) => setTags(e.target.value),
|
|
375
|
+
placeholder: "Comma-separated tags",
|
|
376
|
+
description: "Separate multiple tags with commas"
|
|
377
|
+
}
|
|
378
|
+
)
|
|
379
|
+
] }),
|
|
380
|
+
classificationChanged && /* @__PURE__ */ jsx(
|
|
381
|
+
TextareaField,
|
|
382
|
+
{
|
|
383
|
+
label: "Correction note",
|
|
384
|
+
value: correctionNote,
|
|
385
|
+
onChange: (e) => setCorrectionNote(e.target.value),
|
|
386
|
+
required: true,
|
|
387
|
+
...!correctionNote ? { error: "Required when changing type, scope, or size" } : {},
|
|
388
|
+
placeholder: "Explain why this classification is being changed..."
|
|
389
|
+
}
|
|
390
|
+
),
|
|
391
|
+
item.tags && item.tags.length > 0 && /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1", children: item.tags.map((tag) => /* @__PURE__ */ jsx(Badge, { variant: "outline", children: tag }, tag)) })
|
|
392
|
+
] })
|
|
393
|
+
}
|
|
394
|
+
) });
|
|
395
|
+
}
|
|
396
|
+
);
|
|
397
|
+
FeedbackUpdatePage.displayName = "FeedbackUpdatePage";
|
|
398
|
+
export {
|
|
399
|
+
FeedbackDetailPage,
|
|
400
|
+
FeedbackListPage,
|
|
401
|
+
FeedbackUpdatePage,
|
|
402
|
+
StatusBadge,
|
|
403
|
+
TypeBadge,
|
|
404
|
+
useStatusTransition
|
|
405
|
+
};
|
|
406
|
+
//# sourceMappingURL=index.js.map
|