-
Notifications
You must be signed in to change notification settings - Fork 991
feat(desktop): add feedback button and modal #1103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0c77169
dce00b8
a6583ba
be19062
00344d7
b1e826e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,255 @@ | ||
| import { Button } from "@superset/ui/button"; | ||
| import { | ||
| Dialog, | ||
| DialogContent, | ||
| DialogFooter, | ||
| DialogHeader, | ||
| DialogTitle, | ||
| } from "@superset/ui/dialog"; | ||
| import { toast } from "@superset/ui/sonner"; | ||
| import { Textarea } from "@superset/ui/textarea"; | ||
| import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; | ||
| import { useRef, useState } from "react"; | ||
| import { FaDiscord } from "react-icons/fa6"; | ||
| import { | ||
| LuBookOpen, | ||
| LuCircleHelp, | ||
| LuGithub, | ||
| LuImagePlus, | ||
| LuLoader, | ||
| LuTrash2, | ||
| LuX, | ||
| } from "react-icons/lu"; | ||
| import { apiTrpcClient } from "renderer/lib/api-trpc-client"; | ||
| import { | ||
| useAddFeedbackImage, | ||
| useClearFeedbackForm, | ||
| useCloseFeedbackModal, | ||
| useFeedbackImages, | ||
| useFeedbackMessage, | ||
| useFeedbackModalOpen, | ||
| useOpenFeedbackModal, | ||
| useRemoveFeedbackImage, | ||
| useSetFeedbackMessage, | ||
| } from "renderer/stores/feedback-modal"; | ||
|
|
||
| export function FeedbackButton() { | ||
| const isOpen = useFeedbackModalOpen(); | ||
| const openModal = useOpenFeedbackModal(); | ||
| const closeModal = useCloseFeedbackModal(); | ||
| const message = useFeedbackMessage(); | ||
| const images = useFeedbackImages(); | ||
| const setMessage = useSetFeedbackMessage(); | ||
| const addImage = useAddFeedbackImage(); | ||
| const removeImage = useRemoveFeedbackImage(); | ||
| const clearForm = useClearFeedbackForm(); | ||
| const fileInputRef = useRef<HTMLInputElement>(null); | ||
| const [isSubmitting, setIsSubmitting] = useState(false); | ||
|
|
||
| const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| const files = e.target.files; | ||
| if (!files) return; | ||
|
|
||
| for (const file of files) { | ||
| if (!file.type.startsWith("image/")) continue; | ||
|
|
||
| const reader = new FileReader(); | ||
| reader.onload = (event) => { | ||
| const dataUrl = event.target?.result as string; | ||
| addImage({ | ||
| id: crypto.randomUUID(), | ||
| dataUrl, | ||
| name: file.name, | ||
| }); | ||
| }; | ||
| reader.readAsDataURL(file); | ||
| } | ||
|
|
||
| if (fileInputRef.current) { | ||
| fileInputRef.current.value = ""; | ||
| } | ||
| }; | ||
|
|
||
| const handleSend = async () => { | ||
| if (!message.trim()) { | ||
| toast.error("Please enter a message"); | ||
| return; | ||
| } | ||
|
|
||
| setIsSubmitting(true); | ||
| try { | ||
| await apiTrpcClient.feedback.create.mutate({ | ||
| message: message.trim(), | ||
| images: images.map((img) => img.dataUrl), | ||
| }); | ||
|
|
||
| toast.success("Feedback sent", { | ||
| description: "Thank you for your feedback!", | ||
| }); | ||
| clearForm(); | ||
| closeModal(); | ||
| } catch (error) { | ||
| console.error("[feedback] Failed to submit:", error); | ||
| toast.error("Failed to send feedback", { | ||
| description: "Please try again later", | ||
| }); | ||
| } finally { | ||
| setIsSubmitting(false); | ||
| } | ||
| }; | ||
|
|
||
| const handleClear = () => { | ||
| clearForm(); | ||
| }; | ||
|
|
||
| const hasContent = message.trim() || images.length > 0; | ||
|
|
||
| return ( | ||
| <> | ||
| <Tooltip delayDuration={300}> | ||
| <TooltipTrigger asChild> | ||
| <button | ||
| type="button" | ||
| onClick={openModal} | ||
| className="no-drag flex items-center justify-center size-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors" | ||
| > | ||
| <LuCircleHelp className="size-4" strokeWidth={1.5} /> | ||
| </button> | ||
| </TooltipTrigger> | ||
| <TooltipContent side="bottom">Feedback</TooltipContent> | ||
| </Tooltip> | ||
|
|
||
| <Dialog | ||
| modal | ||
| open={isOpen} | ||
| onOpenChange={(open) => !open && closeModal()} | ||
| > | ||
| <DialogContent className="sm:max-w-[480px]"> | ||
| <DialogHeader> | ||
| <DialogTitle>Send Feedback</DialogTitle> | ||
| </DialogHeader> | ||
|
|
||
| <div className="space-y-4"> | ||
| <Textarea | ||
| placeholder="Tell us what's on your mind..." | ||
| value={message} | ||
| onChange={(e) => setMessage(e.target.value)} | ||
| className="min-h-[120px] resize-none" | ||
| disabled={isSubmitting} | ||
| /> | ||
|
|
||
| {images.length > 0 && ( | ||
| <div className="flex flex-wrap gap-2"> | ||
| {images.map((image) => ( | ||
| <div key={image.id} className="relative group"> | ||
| <img | ||
| src={image.dataUrl} | ||
| alt={image.name} | ||
| className="h-16 w-16 object-cover rounded-md border border-border" | ||
| /> | ||
| <button | ||
| type="button" | ||
| onClick={() => removeImage(image.id)} | ||
| disabled={isSubmitting} | ||
| className="absolute -top-1.5 -right-1.5 size-5 flex items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50" | ||
| > | ||
| <LuX className="size-3" /> | ||
| </button> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| )} | ||
|
|
||
| <div className="flex items-center gap-2"> | ||
| <input | ||
| ref={fileInputRef} | ||
| type="file" | ||
| accept="image/*" | ||
| multiple | ||
| onChange={handleFileSelect} | ||
| className="hidden" | ||
| disabled={isSubmitting} | ||
| /> | ||
| <Button | ||
| type="button" | ||
| variant="outline" | ||
| size="sm" | ||
| onClick={() => fileInputRef.current?.click()} | ||
| disabled={isSubmitting} | ||
| className="gap-1.5" | ||
| > | ||
| <LuImagePlus className="size-4" /> | ||
| Attach Image | ||
| </Button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground"> | ||
| <span>Looking for help? Try opening a</span> | ||
| <a | ||
| href="https://github.com/superset-sh/superset/issues" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted text-foreground hover:bg-accent transition-colors" | ||
| > | ||
| <LuGithub className="size-3" /> | ||
| GitHub issue | ||
| </a> | ||
| <span>, our</span> | ||
| <a | ||
| href="https://docs.superset.sh" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted text-foreground hover:bg-accent transition-colors" | ||
| > | ||
| <LuBookOpen className="size-3" /> | ||
| docs | ||
| </a> | ||
| <span>, or</span> | ||
| <a | ||
| href="https://discord.gg/cZeD9WYcV7" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted text-foreground hover:bg-accent transition-colors" | ||
| > | ||
| <FaDiscord className="size-3" /> | ||
| Discord | ||
| </a> | ||
| <span>.</span> | ||
| </div> | ||
|
|
||
| <DialogFooter className="gap-2 sm:gap-0"> | ||
| {hasContent && ( | ||
| <Button | ||
| type="button" | ||
| variant="ghost" | ||
| size="sm" | ||
| onClick={handleClear} | ||
| disabled={isSubmitting} | ||
| className="gap-1.5 text-muted-foreground hover:text-destructive" | ||
| > | ||
| <LuTrash2 className="size-4" /> | ||
| Clear | ||
| </Button> | ||
| )} | ||
| <Button | ||
| type="button" | ||
| size="sm" | ||
| onClick={handleSend} | ||
| disabled={isSubmitting || !message.trim()} | ||
| > | ||
| {isSubmitting ? ( | ||
| <> | ||
| <LuLoader className="size-4 animate-spin" /> | ||
| Sending... | ||
| </> | ||
| ) : ( | ||
| "Send Feedback" | ||
| )} | ||
| </Button> | ||
| </DialogFooter> | ||
| </DialogContent> | ||
| </Dialog> | ||
| </> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { FeedbackButton } from "./FeedbackButton"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { create } from "zustand"; | ||
| import { devtools, persist } from "zustand/middleware"; | ||
|
|
||
| interface FeedbackImage { | ||
| id: string; | ||
| dataUrl: string; | ||
| name: string; | ||
| } | ||
|
|
||
| interface FeedbackModalState { | ||
| isOpen: boolean; | ||
| message: string; | ||
| images: FeedbackImage[]; | ||
| openModal: () => void; | ||
| closeModal: () => void; | ||
| setMessage: (message: string) => void; | ||
| addImage: (image: FeedbackImage) => void; | ||
| removeImage: (id: string) => void; | ||
| clearForm: () => void; | ||
| } | ||
|
|
||
| export const useFeedbackModalStore = create<FeedbackModalState>()( | ||
| devtools( | ||
| persist( | ||
| (set) => ({ | ||
| isOpen: false, | ||
| message: "", | ||
| images: [], | ||
|
|
||
| openModal: () => set({ isOpen: true }), | ||
| closeModal: () => set({ isOpen: false }), | ||
|
|
||
| setMessage: (message) => set({ message }), | ||
|
|
||
| addImage: (image) => | ||
| set((state) => ({ images: [...state.images, image] })), | ||
|
|
||
| removeImage: (id) => | ||
| set((state) => ({ | ||
| images: state.images.filter((img) => img.id !== id), | ||
| })), | ||
|
|
||
| clearForm: () => set({ message: "", images: [] }), | ||
| }), | ||
| { | ||
| name: "feedback-form-storage", | ||
| partialize: (state) => ({ | ||
| message: state.message, | ||
| images: state.images, | ||
| }), | ||
| }, | ||
| ), | ||
|
Comment on lines
+22
to
+52
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Persisting image data URLs can exceed storage quotas and break rehydration. 💡 Minimal mitigation (exclude images from persisted state) {
name: "feedback-form-storage",
partialize: (state) => ({
message: state.message,
- images: state.images,
}),
},🤖 Prompt for AI Agents |
||
| { name: "FeedbackModalStore" }, | ||
| ), | ||
| ); | ||
|
|
||
| export const useFeedbackModalOpen = () => | ||
| useFeedbackModalStore((state) => state.isOpen); | ||
| export const useOpenFeedbackModal = () => | ||
| useFeedbackModalStore((state) => state.openModal); | ||
| export const useCloseFeedbackModal = () => | ||
| useFeedbackModalStore((state) => state.closeModal); | ||
| export const useFeedbackMessage = () => | ||
| useFeedbackModalStore((state) => state.message); | ||
| export const useFeedbackImages = () => | ||
| useFeedbackModalStore((state) => state.images); | ||
| export const useSetFeedbackMessage = () => | ||
| useFeedbackModalStore((state) => state.setMessage); | ||
| export const useAddFeedbackImage = () => | ||
| useFeedbackModalStore((state) => state.addImage); | ||
| export const useRemoveFeedbackImage = () => | ||
| useFeedbackModalStore((state) => state.removeImage); | ||
| export const useClearFeedbackForm = () => | ||
| useFeedbackModalStore((state) => state.clearForm); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| CREATE TABLE "feedback" ( | ||
| "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | ||
| "user_id" uuid NOT NULL, | ||
| "organization_id" uuid, | ||
| "message" text NOT NULL, | ||
| "images" jsonb DEFAULT '[]'::jsonb, | ||
| "metadata" jsonb, | ||
| "created_at" timestamp DEFAULT now() NOT NULL | ||
| ); | ||
| --> statement-breakpoint | ||
| ALTER TABLE "feedback" ADD CONSTRAINT "feedback_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | ||
| ALTER TABLE "feedback" ADD CONSTRAINT "feedback_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | ||
| CREATE INDEX "feedback_user_id_idx" ON "feedback" USING btree ("user_id");--> statement-breakpoint | ||
| CREATE INDEX "feedback_created_at_idx" ON "feedback" USING btree ("created_at"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add error handling for FileReader failures.
The
FileReader.onerrorcallback is not handled. If a file read fails (e.g., corrupted file, permission issue), the user receives no feedback. Additionally,event.target?.resultcould benull.🛡️ Proposed fix with error handling
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const files = e.target.files; if (!files) return; for (const file of files) { if (!file.type.startsWith("image/")) continue; const reader = new FileReader(); reader.onload = (event) => { - const dataUrl = event.target?.result as string; + const dataUrl = event.target?.result; + if (typeof dataUrl !== "string") return; addImage({ id: crypto.randomUUID(), dataUrl, name: file.name, }); }; + reader.onerror = () => { + console.error("[feedback] Failed to read file:", file.name); + toast.error(`Failed to read ${file.name}`); + }; reader.readAsDataURL(file); } if (fileInputRef.current) { fileInputRef.current.value = ""; } };🤖 Prompt for AI Agents