convex-feedback-ui 0.1.9 → 0.2.0-beta.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 +19 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/native/expo/FeedbackRoutes.d.ts.map +1 -1
- package/dist/native/expo/FeedbackRoutes.js +12 -6
- package/dist/native/expo/FeedbackRoutes.js.map +1 -1
- package/dist/native/expo/FeedbackScreen.d.ts +1 -1
- package/dist/native/expo/FeedbackScreen.d.ts.map +1 -1
- package/dist/native/expo/FeedbackScreen.js +2 -2
- package/dist/native/expo/FeedbackScreen.js.map +1 -1
- package/dist/native/expo/FeedbackStack.d.ts.map +1 -1
- package/dist/native/expo/FeedbackStack.js +9 -4
- package/dist/native/expo/FeedbackStack.js.map +1 -1
- package/dist/native/expo/FeedbackStackLayout.d.ts +1 -1
- package/dist/native/expo/FeedbackStackLayout.d.ts.map +1 -1
- package/dist/native/expo/FeedbackStackLayout.js +2 -2
- package/dist/native/expo/FeedbackStackLayout.js.map +1 -1
- package/dist/native/expo/index.d.ts +1 -0
- package/dist/native/expo/index.d.ts.map +1 -1
- package/dist/native/expo/index.js +1 -0
- package/dist/native/expo/index.js.map +1 -1
- package/dist/native/native/FeedbackScreen.d.ts +1 -1
- package/dist/native/native/FeedbackScreen.d.ts.map +1 -1
- package/dist/native/native/FeedbackScreen.js +2 -2
- package/dist/native/native/FeedbackScreen.js.map +1 -1
- package/dist/native/native/index.d.ts +1 -0
- package/dist/native/native/index.d.ts.map +1 -1
- package/dist/native/native/index.js +1 -0
- package/dist/native/native/index.js.map +1 -1
- package/dist/native/shared/helpers.d.ts +6 -1
- package/dist/native/shared/helpers.d.ts.map +1 -1
- package/dist/native/shared/helpers.js +33 -0
- package/dist/native/shared/helpers.js.map +1 -1
- package/dist/native/shared/ui/BodyContent.d.ts.map +1 -1
- package/dist/native/shared/ui/BodyContent.js +8 -2
- package/dist/native/shared/ui/BodyContent.js.map +1 -1
- package/dist/native/shared/ui/Button.d.ts +2 -1
- package/dist/native/shared/ui/Button.d.ts.map +1 -1
- package/dist/native/shared/ui/Button.js +3 -2
- package/dist/native/shared/ui/Button.js.map +1 -1
- package/dist/native/shared/ui/CommentBranch.d.ts.map +1 -1
- package/dist/native/shared/ui/CommentBranch.js +33 -11
- package/dist/native/shared/ui/CommentBranch.js.map +1 -1
- package/dist/native/shared/ui/EntryCard.d.ts.map +1 -1
- package/dist/native/shared/ui/EntryCard.js +13 -1
- package/dist/native/shared/ui/EntryCard.js.map +1 -1
- package/dist/native/shared/ui/EntryDetail.d.ts.map +1 -1
- package/dist/native/shared/ui/EntryDetail.js +23 -5
- package/dist/native/shared/ui/EntryDetail.js.map +1 -1
- package/dist/native/shared/ui/FeedbackScreenHeader.d.ts.map +1 -1
- package/dist/native/shared/ui/FeedbackScreenHeader.js +7 -2
- package/dist/native/shared/ui/FeedbackScreenHeader.js.map +1 -1
- package/dist/native/shared/ui/FeedbackScreenList.d.ts.map +1 -1
- package/dist/native/shared/ui/FeedbackScreenList.js +16 -8
- package/dist/native/shared/ui/FeedbackScreenList.js.map +1 -1
- package/dist/native/shared/ui/NewEntry.d.ts.map +1 -1
- package/dist/native/shared/ui/NewEntry.js +13 -11
- package/dist/native/shared/ui/NewEntry.js.map +1 -1
- package/dist/native/shared/ui/RoadmapScreen.d.ts +3 -0
- package/dist/native/shared/ui/RoadmapScreen.d.ts.map +1 -0
- package/dist/native/shared/ui/RoadmapScreen.js +73 -0
- package/dist/native/shared/ui/RoadmapScreen.js.map +1 -0
- package/dist/react/action.d.ts +14 -0
- package/dist/react/action.d.ts.map +1 -0
- package/dist/react/action.js +38 -0
- package/dist/react/action.js.map +1 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/roadmap.d.ts +3 -0
- package/dist/react/roadmap.d.ts.map +1 -0
- package/dist/react/roadmap.js +59 -0
- package/dist/react/roadmap.js.map +1 -0
- package/dist/react/screen.d.ts +1 -1
- package/dist/react/screen.d.ts.map +1 -1
- package/dist/react/screen.js +99 -42
- package/dist/react/screen.js.map +1 -1
- package/dist/react/styles.css +42 -0
- package/dist/shared/context/FeedbackBodyProvider.d.ts +1 -1
- package/dist/shared/context/FeedbackBodyProvider.d.ts.map +1 -1
- package/dist/shared/context/FeedbackBodyProvider.js +8 -2
- package/dist/shared/context/FeedbackBodyProvider.js.map +1 -1
- package/dist/shared/helpers.d.ts +6 -0
- package/dist/shared/helpers.d.ts.map +1 -1
- package/dist/shared/helpers.js +12 -0
- package/dist/shared/helpers.js.map +1 -1
- package/dist/shared/types/index.d.ts +1 -0
- package/dist/shared/types/index.d.ts.map +1 -1
- package/dist/shared/types/messages.d.ts +1 -1
- package/dist/shared/types/messages.d.ts.map +1 -1
- package/dist/shared/types/roadmap.d.ts +17 -0
- package/dist/shared/types/roadmap.d.ts.map +1 -0
- package/dist/shared/types/roadmap.js +2 -0
- package/dist/shared/types/roadmap.js.map +1 -0
- package/dist/shared/types/screen.d.ts +10 -1
- package/dist/shared/types/screen.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/native/expo/FeedbackRoutes.tsx +20 -5
- package/src/native/expo/FeedbackScreen.tsx +2 -0
- package/src/native/expo/FeedbackStack.tsx +17 -3
- package/src/native/expo/FeedbackStackLayout.tsx +2 -0
- package/src/native/expo/README.md +4 -0
- package/src/native/expo/index.ts +1 -0
- package/src/native/native/FeedbackScreen.tsx +2 -0
- package/src/native/native/README.md +4 -0
- package/src/native/native/index.ts +1 -0
- package/src/native/shared/helpers.ts +38 -1
- package/src/native/shared/ui/BodyContent.tsx +15 -2
- package/src/native/shared/ui/Button.tsx +4 -0
- package/src/native/shared/ui/CommentBranch.tsx +58 -15
- package/src/native/shared/ui/EntryCard.tsx +21 -3
- package/src/native/shared/ui/EntryDetail.tsx +42 -8
- package/src/native/shared/ui/FeedbackScreenHeader.tsx +9 -1
- package/src/native/shared/ui/FeedbackScreenList.tsx +28 -13
- package/src/native/shared/ui/NewEntry.tsx +25 -12
- package/src/native/shared/ui/RoadmapScreen.tsx +345 -0
- package/src/react/README.md +1 -1
- package/src/react/action.tsx +56 -0
- package/src/react/index.ts +1 -0
- package/src/react/roadmap.tsx +303 -0
- package/src/react/screen.tsx +199 -75
- package/src/react/styles.css +42 -0
- package/src/shared/context/FeedbackBodyProvider.tsx +9 -1
- package/src/shared/helpers.ts +16 -0
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/messages.ts +1 -1
- package/src/shared/types/roadmap.ts +25 -0
- package/src/shared/types/screen.ts +12 -1
|
@@ -3,18 +3,26 @@ import { ActivityIndicator, Text, View } from "react-native";
|
|
|
3
3
|
|
|
4
4
|
import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider";
|
|
5
5
|
import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
|
|
6
|
+
import { allowAuthenticatedAction } from "../../../shared/helpers.js";
|
|
6
7
|
import type { FeedbackScreenEntryDetailProps } from "../../../shared/types";
|
|
7
8
|
import { Button } from "./Button";
|
|
8
9
|
import { CommentBranch } from "./CommentBranch";
|
|
9
10
|
import { FeedbackEntry, FeedbackForm } from "./primitives";
|
|
10
11
|
import { MetadataModal } from "./MetadataModal";
|
|
12
|
+
import { useNativeAction } from "../helpers.js";
|
|
11
13
|
|
|
12
14
|
export function EntryDetail({
|
|
13
15
|
entryId,
|
|
14
16
|
onBack,
|
|
15
17
|
hideBackButton,
|
|
16
18
|
}: FeedbackScreenEntryDetailProps) {
|
|
17
|
-
const {
|
|
19
|
+
const {
|
|
20
|
+
hooks,
|
|
21
|
+
commentSort,
|
|
22
|
+
transformComments,
|
|
23
|
+
isAuthenticated,
|
|
24
|
+
onUnauthenticated,
|
|
25
|
+
} = useFeedbackBody();
|
|
18
26
|
|
|
19
27
|
const { messages, theme } = useFeedbackUi();
|
|
20
28
|
const entry = hooks.useEntry(entryId);
|
|
@@ -23,6 +31,21 @@ export function EntryDetail({
|
|
|
23
31
|
const createComment = hooks.useCreateComment();
|
|
24
32
|
const [body, setBody] = useState("");
|
|
25
33
|
const [showMetadata, setShowMetadata] = useState(false);
|
|
34
|
+
const upvoteAction = useNativeAction();
|
|
35
|
+
const commentAction = useNativeAction();
|
|
36
|
+
|
|
37
|
+
const toggleUpvote = (desiredState: boolean) => {
|
|
38
|
+
if (
|
|
39
|
+
!allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ||
|
|
40
|
+
upvoteAction.pending
|
|
41
|
+
) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
void upvoteAction.run(
|
|
45
|
+
() => setUpvote({ entryId, desiredState }),
|
|
46
|
+
"Could not update vote",
|
|
47
|
+
);
|
|
48
|
+
};
|
|
26
49
|
const visible = useMemo(
|
|
27
50
|
() => transformComments?.(comments.results) ?? comments.results,
|
|
28
51
|
[comments.results, transformComments],
|
|
@@ -57,9 +80,8 @@ export function EntryDetail({
|
|
|
57
80
|
)}
|
|
58
81
|
<FeedbackEntry.Root entry={entry}>
|
|
59
82
|
<FeedbackEntry.Upvote
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
83
|
+
disabled={isAuthenticated === undefined || upvoteAction.pending}
|
|
84
|
+
onToggle={toggleUpvote}
|
|
63
85
|
/>
|
|
64
86
|
<FeedbackEntry.Content style={{ gap: 5 }}>
|
|
65
87
|
<FeedbackEntry.Status />
|
|
@@ -92,10 +114,20 @@ export function EntryDetail({
|
|
|
92
114
|
placeholder={messages.comments.placeholder}
|
|
93
115
|
/>
|
|
94
116
|
<FeedbackForm.Submit
|
|
95
|
-
|
|
117
|
+
disabled={isAuthenticated === undefined || commentAction.pending}
|
|
118
|
+
submitting={commentAction.pending}
|
|
119
|
+
onPress={() => {
|
|
96
120
|
if (body.trim().length === 0) return;
|
|
97
|
-
|
|
98
|
-
|
|
121
|
+
if (
|
|
122
|
+
!allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ||
|
|
123
|
+
commentAction.pending
|
|
124
|
+
) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
void commentAction.run(async () => {
|
|
128
|
+
await createComment({ entryId, body });
|
|
129
|
+
setBody("");
|
|
130
|
+
}, "Could not add comment");
|
|
99
131
|
}}
|
|
100
132
|
>
|
|
101
133
|
{messages.comments.submit}
|
|
@@ -106,9 +138,11 @@ export function EntryDetail({
|
|
|
106
138
|
<CommentBranch key={comment.id} comment={comment} entryId={entryId} />
|
|
107
139
|
))}
|
|
108
140
|
</View>
|
|
109
|
-
{comments.status === "CanLoadMore"
|
|
141
|
+
{(comments.status === "CanLoadMore" ||
|
|
142
|
+
comments.status === "LoadingMore") && (
|
|
110
143
|
<Button
|
|
111
144
|
label={messages.comments.loadMore}
|
|
145
|
+
disabled={comments.status === "LoadingMore"}
|
|
112
146
|
onPress={() => comments.loadMore(hooks.pageSizes.comments)}
|
|
113
147
|
/>
|
|
114
148
|
)}
|
|
@@ -3,6 +3,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
|
3
3
|
import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider";
|
|
4
4
|
import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
|
|
5
5
|
import { createEntryLabel, entryStatusChoices } from "../../../shared/helpers";
|
|
6
|
+
import { allowAuthenticatedAction } from "../../../shared/helpers";
|
|
6
7
|
import { ChoiceChips, FeedbackBoard } from "./primitives";
|
|
7
8
|
|
|
8
9
|
export function FeedbackScreenHeader() {
|
|
@@ -14,6 +15,8 @@ export function FeedbackScreenHeader() {
|
|
|
14
15
|
enabledKinds,
|
|
15
16
|
statusFilter,
|
|
16
17
|
setStatusFilter,
|
|
18
|
+
isAuthenticated,
|
|
19
|
+
onUnauthenticated,
|
|
17
20
|
} = useFeedbackBody();
|
|
18
21
|
const { messages, theme } = useFeedbackUi();
|
|
19
22
|
const { top } = useSafeAreaInsets();
|
|
@@ -36,7 +39,12 @@ export function FeedbackScreenHeader() {
|
|
|
36
39
|
onValueChange={setStatusFilter}
|
|
37
40
|
/>
|
|
38
41
|
<Pressable
|
|
39
|
-
|
|
42
|
+
disabled={isAuthenticated === undefined}
|
|
43
|
+
onPress={() => {
|
|
44
|
+
if (allowAuthenticatedAction(isAuthenticated, onUnauthenticated)) {
|
|
45
|
+
setShowForm(true);
|
|
46
|
+
}
|
|
47
|
+
}}
|
|
40
48
|
style={{
|
|
41
49
|
alignSelf: "flex-start",
|
|
42
50
|
paddingVertical: 9,
|
|
@@ -2,7 +2,10 @@ import { ActivityIndicator, Text } from "react-native";
|
|
|
2
2
|
|
|
3
3
|
import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider.js";
|
|
4
4
|
import { useFeedbackUi } from "../../../shared/context/FeedbackProvider.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
allowAuthenticatedAction,
|
|
7
|
+
createEntryLabel,
|
|
8
|
+
} from "../../../shared/helpers.js";
|
|
6
9
|
import type { FeedbackScreenListProps } from "../../../shared/types/index.js";
|
|
7
10
|
import { Button } from "./Button.js";
|
|
8
11
|
import { EntryCard } from "./EntryCard.js";
|
|
@@ -18,15 +21,17 @@ export function FeedbackScreenList({
|
|
|
18
21
|
const {
|
|
19
22
|
hooks,
|
|
20
23
|
entrySort,
|
|
21
|
-
isSearching,
|
|
22
24
|
enabledKinds,
|
|
23
25
|
statusFilter,
|
|
24
26
|
selectedEntryId,
|
|
27
|
+
query,
|
|
25
28
|
debouncedQuery,
|
|
26
29
|
setSelectedEntryId,
|
|
27
30
|
setShowForm,
|
|
28
31
|
emptyState,
|
|
29
32
|
loading: loadingIndicator,
|
|
33
|
+
isAuthenticated,
|
|
34
|
+
onUnauthenticated,
|
|
30
35
|
} = useFeedbackBody();
|
|
31
36
|
const { messages, theme } = useFeedbackUi();
|
|
32
37
|
|
|
@@ -41,11 +46,18 @@ export function FeedbackScreenList({
|
|
|
41
46
|
statusFilter,
|
|
42
47
|
});
|
|
43
48
|
|
|
44
|
-
const
|
|
45
|
-
const
|
|
49
|
+
const normalizedQuery = query.trim();
|
|
50
|
+
const normalizedDebouncedQuery = debouncedQuery.trim();
|
|
51
|
+
const searching = normalizedQuery.length > 0;
|
|
52
|
+
const entries = searching ? search : list.results;
|
|
53
|
+
const createEntry = () => {
|
|
54
|
+
if (allowAuthenticatedAction(isAuthenticated, onUnauthenticated)) {
|
|
55
|
+
(onCreateEntry ?? (() => setShowForm(true)))();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
46
58
|
|
|
47
|
-
const loading =
|
|
48
|
-
? search === undefined
|
|
59
|
+
const loading = searching
|
|
60
|
+
? normalizedQuery !== normalizedDebouncedQuery || search === undefined
|
|
49
61
|
: list.status === "LoadingFirstPage";
|
|
50
62
|
|
|
51
63
|
return (
|
|
@@ -68,7 +80,7 @@ export function FeedbackScreenList({
|
|
|
68
80
|
<FeedbackBoard.State>
|
|
69
81
|
{emptyState}
|
|
70
82
|
<Text style={{ color: theme.colors.mutedText, textAlign: "center" }}>
|
|
71
|
-
{
|
|
83
|
+
{searching
|
|
72
84
|
? messages.board.noSearchResults
|
|
73
85
|
: messages.board.noEntries}
|
|
74
86
|
</Text>
|
|
@@ -76,6 +88,7 @@ export function FeedbackScreenList({
|
|
|
76
88
|
label={createEntryLabel(enabledKinds, messages)}
|
|
77
89
|
onPress={createEntry}
|
|
78
90
|
variant="primary"
|
|
91
|
+
disabled={isAuthenticated === undefined}
|
|
79
92
|
/>
|
|
80
93
|
</FeedbackBoard.State>
|
|
81
94
|
) : (
|
|
@@ -91,12 +104,14 @@ export function FeedbackScreenList({
|
|
|
91
104
|
</FeedbackBoard.List>
|
|
92
105
|
)}
|
|
93
106
|
|
|
94
|
-
{!
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
107
|
+
{!searching &&
|
|
108
|
+
(list.status === "CanLoadMore" || list.status === "LoadingMore") && (
|
|
109
|
+
<Button
|
|
110
|
+
label={messages.board.loadMore}
|
|
111
|
+
disabled={list.status === "LoadingMore"}
|
|
112
|
+
onPress={() => list.loadMore(hooks.pageSizes.entries)}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
100
115
|
</>
|
|
101
116
|
);
|
|
102
117
|
}
|
|
@@ -12,10 +12,12 @@ import {
|
|
|
12
12
|
|
|
13
13
|
import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider";
|
|
14
14
|
import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
|
|
15
|
+
import { allowAuthenticatedAction } from "../../../shared/helpers.js";
|
|
15
16
|
import type { FeedbackScreenEntryModalProps } from "../../../shared/types";
|
|
16
17
|
import { collectEntryMetadata } from "../../../shared/metadata";
|
|
17
18
|
import { EntryDetail } from "./EntryDetail";
|
|
18
19
|
import { ChoiceChips, FeedbackForm } from "./primitives";
|
|
20
|
+
import { useNativeAction } from "../helpers.js";
|
|
19
21
|
|
|
20
22
|
export function CreateEntryModal({
|
|
21
23
|
onRequestClose,
|
|
@@ -167,11 +169,17 @@ export function CreateEntryForm({
|
|
|
167
169
|
onBodyChange: (body: string) => void;
|
|
168
170
|
onOpenSuggestion: (id: string) => void;
|
|
169
171
|
}) {
|
|
170
|
-
const {
|
|
171
|
-
|
|
172
|
+
const {
|
|
173
|
+
hooks,
|
|
174
|
+
enabledKinds,
|
|
175
|
+
collectMetadata,
|
|
176
|
+
collectStandardMetadata,
|
|
177
|
+
isAuthenticated,
|
|
178
|
+
onUnauthenticated,
|
|
179
|
+
} = useFeedbackBody();
|
|
172
180
|
const { messages, theme } = useFeedbackUi();
|
|
173
|
-
const [submitting, setSubmitting] = useState(false);
|
|
174
181
|
const create = hooks.useCreateEntry();
|
|
182
|
+
const action = useNativeAction();
|
|
175
183
|
const similar = hooks.useSimilarEntries({ title, body, kind, limit: 3 });
|
|
176
184
|
|
|
177
185
|
const suggestions = useMemo(() => {
|
|
@@ -180,13 +188,16 @@ export function CreateEntryForm({
|
|
|
180
188
|
}, [similar]);
|
|
181
189
|
|
|
182
190
|
const submit = async () => {
|
|
183
|
-
if (
|
|
191
|
+
if (
|
|
192
|
+
action.pending ||
|
|
193
|
+
title.trim().length === 0 ||
|
|
194
|
+
body.trim().length === 0
|
|
195
|
+
) {
|
|
184
196
|
return;
|
|
185
197
|
}
|
|
198
|
+
if (!allowAuthenticatedAction(isAuthenticated, onUnauthenticated)) return;
|
|
186
199
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
try {
|
|
200
|
+
await action.run(async () => {
|
|
190
201
|
const metadata = await collectEntryMetadata(
|
|
191
202
|
collectMetadata,
|
|
192
203
|
kind,
|
|
@@ -200,14 +211,12 @@ export function CreateEntryForm({
|
|
|
200
211
|
...(metadata === undefined ? {} : { metadata }),
|
|
201
212
|
}),
|
|
202
213
|
);
|
|
203
|
-
}
|
|
204
|
-
setSubmitting(false);
|
|
205
|
-
}
|
|
214
|
+
}, "Could not submit feedback");
|
|
206
215
|
};
|
|
207
216
|
|
|
208
217
|
const requestSubmit = () => {
|
|
209
218
|
if (
|
|
210
|
-
|
|
219
|
+
action.pending ||
|
|
211
220
|
similar === undefined ||
|
|
212
221
|
title.trim().length === 0 ||
|
|
213
222
|
body.trim().length === 0
|
|
@@ -365,7 +374,11 @@ export function CreateEntryForm({
|
|
|
365
374
|
))}
|
|
366
375
|
</View>
|
|
367
376
|
) : null}
|
|
368
|
-
<FeedbackForm.Submit
|
|
377
|
+
<FeedbackForm.Submit
|
|
378
|
+
submitting={action.pending}
|
|
379
|
+
disabled={isAuthenticated === undefined || action.pending}
|
|
380
|
+
onPress={requestSubmit}
|
|
381
|
+
/>
|
|
369
382
|
</FeedbackForm.Root>
|
|
370
383
|
);
|
|
371
384
|
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FeedbackEntry as FeedbackEntryData,
|
|
3
|
+
RoadmapItem,
|
|
4
|
+
} from "convex-feedback";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { Pressable, ScrollView, Text, View } from "react-native";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
FeedbackProvider,
|
|
10
|
+
useFeedbackUi,
|
|
11
|
+
} from "../../../shared/context/FeedbackProvider.js";
|
|
12
|
+
import { allowAuthenticatedAction } from "../../../shared/helpers.js";
|
|
13
|
+
import type { RoadmapScreenProps } from "../../../shared/types/index.js";
|
|
14
|
+
import { useNativeAction } from "../helpers.js";
|
|
15
|
+
import { Button } from "./Button.js";
|
|
16
|
+
import { FeedbackBoard, FeedbackEntry } from "./primitives.js";
|
|
17
|
+
|
|
18
|
+
export function RoadmapScreen({
|
|
19
|
+
hooks,
|
|
20
|
+
messages,
|
|
21
|
+
theme,
|
|
22
|
+
unstyled,
|
|
23
|
+
onEntryOpen,
|
|
24
|
+
onUnauthenticated,
|
|
25
|
+
pageSize,
|
|
26
|
+
entryPageSize,
|
|
27
|
+
...colors
|
|
28
|
+
}: RoadmapScreenProps) {
|
|
29
|
+
return (
|
|
30
|
+
<FeedbackProvider messages={messages} theme={theme} unstyled={unstyled}>
|
|
31
|
+
<RoadmapScreenInner
|
|
32
|
+
hooks={hooks}
|
|
33
|
+
onEntryOpen={onEntryOpen}
|
|
34
|
+
onUnauthenticated={onUnauthenticated}
|
|
35
|
+
pageSize={pageSize ?? hooks.pageSizes.roadmap}
|
|
36
|
+
entryPageSize={entryPageSize ?? hooks.pageSizes.entries}
|
|
37
|
+
{...colors}
|
|
38
|
+
/>
|
|
39
|
+
</FeedbackProvider>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function RoadmapScreenInner({
|
|
44
|
+
hooks,
|
|
45
|
+
onEntryOpen,
|
|
46
|
+
onUnauthenticated,
|
|
47
|
+
pageSize,
|
|
48
|
+
entryPageSize,
|
|
49
|
+
...colors
|
|
50
|
+
}: RoadmapScreenProps & {
|
|
51
|
+
pageSize: number;
|
|
52
|
+
entryPageSize: number;
|
|
53
|
+
}) {
|
|
54
|
+
const { messages, theme } = useFeedbackUi();
|
|
55
|
+
const [query, setQuery] = useState("");
|
|
56
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
57
|
+
const roadmap = hooks.useRoadmap();
|
|
58
|
+
const search = hooks.useSearchRoadmap(query);
|
|
59
|
+
const searching = query.trim().length > 0;
|
|
60
|
+
const items = searching ? (search ?? []) : roadmap.results;
|
|
61
|
+
const selected = items.find((item) => item.id === selectedId) ?? null;
|
|
62
|
+
|
|
63
|
+
if (selected !== null) {
|
|
64
|
+
return (
|
|
65
|
+
<FeedbackBoard.Root {...colors}>
|
|
66
|
+
<ScrollView
|
|
67
|
+
contentContainerStyle={{ gap: theme.spacing, padding: theme.spacing }}
|
|
68
|
+
>
|
|
69
|
+
<RoadmapDetail
|
|
70
|
+
item={selected}
|
|
71
|
+
hooks={hooks}
|
|
72
|
+
entryPageSize={entryPageSize}
|
|
73
|
+
onEntryOpen={onEntryOpen}
|
|
74
|
+
onUnauthenticated={onUnauthenticated}
|
|
75
|
+
onBack={() => setSelectedId(null)}
|
|
76
|
+
/>
|
|
77
|
+
</ScrollView>
|
|
78
|
+
</FeedbackBoard.Root>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const loading = searching
|
|
83
|
+
? search === undefined
|
|
84
|
+
: roadmap.status === "LoadingFirstPage";
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<FeedbackBoard.Root {...colors}>
|
|
88
|
+
<FeedbackBoard.Header
|
|
89
|
+
style={{ gap: theme.spacing, padding: theme.spacing }}
|
|
90
|
+
>
|
|
91
|
+
<FeedbackBoard.Title>Roadmap</FeedbackBoard.Title>
|
|
92
|
+
<Text style={{ color: theme.colors.mutedText }}>
|
|
93
|
+
See what is planned, in progress, and shipped.
|
|
94
|
+
</Text>
|
|
95
|
+
<FeedbackBoard.Search
|
|
96
|
+
value={query}
|
|
97
|
+
onValueChange={setQuery}
|
|
98
|
+
placeholder="Search roadmap"
|
|
99
|
+
/>
|
|
100
|
+
</FeedbackBoard.Header>
|
|
101
|
+
{loading ? (
|
|
102
|
+
<FeedbackBoard.State>
|
|
103
|
+
<Text style={{ color: theme.colors.mutedText }}>
|
|
104
|
+
{messages.board.loading}
|
|
105
|
+
</Text>
|
|
106
|
+
</FeedbackBoard.State>
|
|
107
|
+
) : items.length === 0 ? (
|
|
108
|
+
<FeedbackBoard.State>
|
|
109
|
+
<Text style={{ color: theme.colors.mutedText }}>
|
|
110
|
+
{searching ? "No roadmap items found." : "No roadmap items yet."}
|
|
111
|
+
</Text>
|
|
112
|
+
</FeedbackBoard.State>
|
|
113
|
+
) : (
|
|
114
|
+
<FeedbackBoard.List contentContainerStyle={{ padding: theme.spacing }}>
|
|
115
|
+
{items.map((item) => (
|
|
116
|
+
<RoadmapCard
|
|
117
|
+
key={item.id}
|
|
118
|
+
item={item}
|
|
119
|
+
onOpen={() => setSelectedId(item.id)}
|
|
120
|
+
/>
|
|
121
|
+
))}
|
|
122
|
+
</FeedbackBoard.List>
|
|
123
|
+
)}
|
|
124
|
+
{!searching &&
|
|
125
|
+
(roadmap.status === "CanLoadMore" ||
|
|
126
|
+
roadmap.status === "LoadingMore") && (
|
|
127
|
+
<Button
|
|
128
|
+
label={messages.board.loadMore}
|
|
129
|
+
disabled={roadmap.status === "LoadingMore"}
|
|
130
|
+
onPress={() => roadmap.loadMore(pageSize)}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
</FeedbackBoard.Root>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function RoadmapCard({
|
|
138
|
+
item,
|
|
139
|
+
onOpen,
|
|
140
|
+
}: {
|
|
141
|
+
item: RoadmapItem;
|
|
142
|
+
onOpen: () => void;
|
|
143
|
+
}) {
|
|
144
|
+
const { theme } = useFeedbackUi();
|
|
145
|
+
return (
|
|
146
|
+
<Pressable onPress={onOpen} style={{ gap: 5, padding: 12 }}>
|
|
147
|
+
<View
|
|
148
|
+
style={{
|
|
149
|
+
flexDirection: "row",
|
|
150
|
+
justifyContent: "space-between",
|
|
151
|
+
gap: 8,
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
<Text
|
|
155
|
+
style={{
|
|
156
|
+
color: theme.colors.mutedText,
|
|
157
|
+
fontSize: 12,
|
|
158
|
+
fontWeight: "600",
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
{item.status.replaceAll("_", " ")}
|
|
162
|
+
</Text>
|
|
163
|
+
<Text style={{ color: theme.colors.mutedText, fontSize: 12 }}>
|
|
164
|
+
{item.feedbackCount} linked
|
|
165
|
+
</Text>
|
|
166
|
+
</View>
|
|
167
|
+
<Text
|
|
168
|
+
style={{ color: theme.colors.text, fontSize: 16, fontWeight: "700" }}
|
|
169
|
+
>
|
|
170
|
+
{item.title}
|
|
171
|
+
</Text>
|
|
172
|
+
{item.description && (
|
|
173
|
+
<Text style={{ color: theme.colors.mutedText, lineHeight: 20 }}>
|
|
174
|
+
{item.description}
|
|
175
|
+
</Text>
|
|
176
|
+
)}
|
|
177
|
+
</Pressable>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function RoadmapDetail({
|
|
182
|
+
item,
|
|
183
|
+
hooks,
|
|
184
|
+
entryPageSize,
|
|
185
|
+
onEntryOpen,
|
|
186
|
+
onUnauthenticated,
|
|
187
|
+
onBack,
|
|
188
|
+
}: {
|
|
189
|
+
item: RoadmapItem;
|
|
190
|
+
hooks: RoadmapScreenProps["hooks"];
|
|
191
|
+
entryPageSize: number;
|
|
192
|
+
onEntryOpen: RoadmapScreenProps["onEntryOpen"];
|
|
193
|
+
onUnauthenticated: RoadmapScreenProps["onUnauthenticated"];
|
|
194
|
+
onBack: () => void;
|
|
195
|
+
}) {
|
|
196
|
+
const { messages, theme } = useFeedbackUi();
|
|
197
|
+
const feedback = hooks.useRoadmapFeedback(item.id);
|
|
198
|
+
return (
|
|
199
|
+
<View style={{ gap: theme.spacing }}>
|
|
200
|
+
<Button label={messages.entry.back} onPress={onBack} />
|
|
201
|
+
<View style={{ gap: 6 }}>
|
|
202
|
+
<Text
|
|
203
|
+
style={{
|
|
204
|
+
color: theme.colors.mutedText,
|
|
205
|
+
fontSize: 12,
|
|
206
|
+
fontWeight: "600",
|
|
207
|
+
}}
|
|
208
|
+
>
|
|
209
|
+
{item.status.replaceAll("_", " ")} · Position {item.position}
|
|
210
|
+
</Text>
|
|
211
|
+
<Text
|
|
212
|
+
style={{ color: theme.colors.text, fontSize: 23, fontWeight: "700" }}
|
|
213
|
+
>
|
|
214
|
+
{item.title}
|
|
215
|
+
</Text>
|
|
216
|
+
<Text style={{ color: theme.colors.mutedText, lineHeight: 21 }}>
|
|
217
|
+
{item.description ?? "No description."}
|
|
218
|
+
</Text>
|
|
219
|
+
</View>
|
|
220
|
+
<View style={{ gap: 4 }}>
|
|
221
|
+
<RoadmapField label="ID" value={item.id} />
|
|
222
|
+
<RoadmapField
|
|
223
|
+
label="Linked feedback"
|
|
224
|
+
value={String(item.feedbackCount)}
|
|
225
|
+
/>
|
|
226
|
+
<RoadmapField
|
|
227
|
+
label="Created"
|
|
228
|
+
value={new Date(item.createdAt).toLocaleString()}
|
|
229
|
+
/>
|
|
230
|
+
<RoadmapField
|
|
231
|
+
label="Updated"
|
|
232
|
+
value={new Date(item.updatedAt).toLocaleString()}
|
|
233
|
+
/>
|
|
234
|
+
<RoadmapField
|
|
235
|
+
label="Document created"
|
|
236
|
+
value={new Date(item.creationTime).toLocaleString()}
|
|
237
|
+
/>
|
|
238
|
+
</View>
|
|
239
|
+
<Text
|
|
240
|
+
style={{ color: theme.colors.text, fontSize: 18, fontWeight: "700" }}
|
|
241
|
+
>
|
|
242
|
+
Attached feedback
|
|
243
|
+
</Text>
|
|
244
|
+
{feedback.status === "LoadingFirstPage" ? (
|
|
245
|
+
<Text style={{ color: theme.colors.mutedText }}>
|
|
246
|
+
{messages.board.loading}
|
|
247
|
+
</Text>
|
|
248
|
+
) : feedback.results.length === 0 ? (
|
|
249
|
+
<Text style={{ color: theme.colors.mutedText }}>
|
|
250
|
+
No feedback attached.
|
|
251
|
+
</Text>
|
|
252
|
+
) : (
|
|
253
|
+
<View style={{ gap: 8 }}>
|
|
254
|
+
{feedback.results.map((entry) => (
|
|
255
|
+
<RoadmapEntry
|
|
256
|
+
key={entry.id}
|
|
257
|
+
entry={entry}
|
|
258
|
+
hooks={hooks}
|
|
259
|
+
onOpen={onEntryOpen}
|
|
260
|
+
onUnauthenticated={onUnauthenticated}
|
|
261
|
+
/>
|
|
262
|
+
))}
|
|
263
|
+
</View>
|
|
264
|
+
)}
|
|
265
|
+
{(feedback.status === "CanLoadMore" ||
|
|
266
|
+
feedback.status === "LoadingMore") && (
|
|
267
|
+
<Button
|
|
268
|
+
label={messages.comments.loadMore}
|
|
269
|
+
disabled={feedback.status === "LoadingMore"}
|
|
270
|
+
onPress={() => feedback.loadMore(entryPageSize)}
|
|
271
|
+
/>
|
|
272
|
+
)}
|
|
273
|
+
</View>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function RoadmapField({ label, value }: { label: string; value: string }) {
|
|
278
|
+
const { theme } = useFeedbackUi();
|
|
279
|
+
return (
|
|
280
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
281
|
+
<Text style={{ color: theme.colors.mutedText, fontWeight: "600" }}>
|
|
282
|
+
{label}:
|
|
283
|
+
</Text>
|
|
284
|
+
<Text style={{ flex: 1, color: theme.colors.text }}>{value}</Text>
|
|
285
|
+
</View>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function RoadmapEntry({
|
|
290
|
+
entry,
|
|
291
|
+
hooks,
|
|
292
|
+
onOpen,
|
|
293
|
+
onUnauthenticated,
|
|
294
|
+
}: {
|
|
295
|
+
entry: FeedbackEntryData;
|
|
296
|
+
hooks: RoadmapScreenProps["hooks"];
|
|
297
|
+
onOpen: RoadmapScreenProps["onEntryOpen"];
|
|
298
|
+
onUnauthenticated: RoadmapScreenProps["onUnauthenticated"];
|
|
299
|
+
}) {
|
|
300
|
+
const { isAuthenticated } = useFeedbackAuth(hooks);
|
|
301
|
+
const setUpvote = hooks.useSetEntryUpvote();
|
|
302
|
+
const action = useNativeAction();
|
|
303
|
+
const toggle = (desiredState: boolean) => {
|
|
304
|
+
if (
|
|
305
|
+
!allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ||
|
|
306
|
+
action.pending
|
|
307
|
+
) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
void action.run(
|
|
311
|
+
() => setUpvote({ entryId: entry.id, desiredState }),
|
|
312
|
+
"Could not update vote",
|
|
313
|
+
);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<FeedbackEntry.Root entry={entry}>
|
|
318
|
+
<FeedbackEntry.Upvote
|
|
319
|
+
disabled={isAuthenticated === undefined || action.pending}
|
|
320
|
+
onToggle={toggle}
|
|
321
|
+
/>
|
|
322
|
+
<Pressable
|
|
323
|
+
onPress={onOpen === undefined ? undefined : () => onOpen(entry.id)}
|
|
324
|
+
accessibilityRole={onOpen === undefined ? undefined : "button"}
|
|
325
|
+
style={{ flex: 1, minWidth: 0 }}
|
|
326
|
+
>
|
|
327
|
+
<FeedbackEntry.Content>
|
|
328
|
+
<View style={{ gap: 5 }}>
|
|
329
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
330
|
+
<FeedbackEntry.Kind />
|
|
331
|
+
<FeedbackEntry.Status />
|
|
332
|
+
</View>
|
|
333
|
+
<FeedbackEntry.Title />
|
|
334
|
+
<FeedbackEntry.Body />
|
|
335
|
+
<FeedbackEntry.CommentCount />
|
|
336
|
+
</View>
|
|
337
|
+
</FeedbackEntry.Content>
|
|
338
|
+
</Pressable>
|
|
339
|
+
</FeedbackEntry.Root>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function useFeedbackAuth(hooks: RoadmapScreenProps["hooks"]) {
|
|
344
|
+
return { isAuthenticated: hooks.useIsAuthenticated() };
|
|
345
|
+
}
|
package/src/react/README.md
CHANGED
|
@@ -4,4 +4,4 @@ The web layer is CSS-framework agnostic. Import `convex-feedback-ui/styles.css`
|
|
|
4
4
|
|
|
5
5
|
Default selectors are scoped beneath `.cf-board` and use low-specificity `:where(...)` selectors so host CSS, CSS Modules, Tailwind classes, or inline styles can override them without a specificity fight.
|
|
6
6
|
|
|
7
|
-
Use compound primitives for custom layouts
|
|
7
|
+
Use compound primitives for custom layouts, `FeedbackScreen` for the feedback board, or `RoadmapScreen` for public roadmap browsing. Interactive leaf primitives accept render-function children to replace their visual implementation while retaining typed state and actions. Pass `onUnauthenticated` to let the host open its auth UI when an anonymous visitor selects an authenticated-only action.
|