Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useParams } from "@tanstack/react-router";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { FeedbackButton } from "./components/FeedbackButton";
import { OpenInMenuButton } from "./components/OpenInMenuButton";
import { OrganizationDropdown } from "./components/OrganizationDropdown";
import { SidebarToggle } from "./components/SidebarToggle";
Expand Down Expand Up @@ -36,6 +37,7 @@ export function TopBar() {
/>
)}
<OrganizationDropdown />
<FeedbackButton />
{!isMac && <WindowControls />}
</div>
</div>
Expand Down
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 = "";
}
};
Comment on lines +49 to +71
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add error handling for FileReader failures.

The FileReader.onerror callback is not handled. If a file read fails (e.g., corrupted file, permission issue), the user receives no feedback. Additionally, event.target?.result could be null.

🛡️ 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
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/FeedbackButton/FeedbackButton.tsx`
around lines 49 - 71, handleFileSelect currently ignores FileReader failures and
assumes event.target.result is non-null; add an onerror handler on the
FileReader to handle read failures (reader.onerror) and report the error (e.g.,
via console.error or the app's notification/error handler) and avoid calling
addImage on failure. Also guard the onload result so if event.target?.result is
null skip adding the image and report an appropriate error, and ensure
fileInputRef.current.value is still cleared after both success and error paths;
update the FileReader usage in handleFileSelect to include these checks and
error reporting.


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
Expand Up @@ -30,6 +30,7 @@ import { LuKeyboard } from "react-icons/lu";
import { authClient } from "renderer/lib/auth-client";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import { useOpenFeedbackModal } from "renderer/stores/feedback-modal";
import { useHotkeyText } from "renderer/stores/hotkeys";

export function OrganizationDropdown() {
Expand All @@ -39,6 +40,7 @@ export function OrganizationDropdown() {
const navigate = useNavigate();
const settingsHotkey = useHotkeyText("OPEN_SETTINGS");
const shortcutsHotkey = useHotkeyText("SHOW_HOTKEYS");
const openFeedbackModal = useOpenFeedbackModal();

const activeOrganizationId = session?.session?.activeOrganizationId;

Expand Down Expand Up @@ -159,9 +161,7 @@ export function OrganizationDropdown() {
<DropdownMenuShortcut>{shortcutsHotkey}</DropdownMenuShortcut>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openExternal(COMPANY.REPORT_ISSUE_URL)}
>
<DropdownMenuItem onClick={openFeedbackModal}>
<IoBugOutline className="h-4 w-4" />
Report Issue
</DropdownMenuItem>
Expand Down
74 changes: 74 additions & 0 deletions apps/desktop/src/renderer/stores/feedback-modal.ts
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Persisting image data URLs can exceed storage quotas and break rehydration.
persist defaults to localStorage; storing base64 images there can easily hit quota and throw, which prevents draft persistence. Consider moving image persistence to a larger store (e.g., IndexedDB) or exclude images from persisted state and keep them in-memory only.

💡 Minimal mitigation (exclude images from persisted state)
 			{
 				name: "feedback-form-storage",
 				partialize: (state) => ({
 					message: state.message,
-					images: state.images,
 				}),
 			},
Zustand persist default storage (localStorage) size limits and how to configure custom storage (e.g., IndexedDB)
🤖 Prompt for AI Agents
In `@apps/desktop/src/renderer/stores/feedback-modal.ts` around lines 22 - 52, The
persist is currently saving images (base64) which can exceed localStorage
quotas; update the useFeedbackModalStore persist config to avoid persisting
images by changing partialize to only include message (remove images) or
alternatively supply a custom storage implementation (e.g., IndexedDB) to the
persist call; target the persist config and the partialize function in
useFeedbackModalStore and ensure image mutations (addImage, removeImage,
clearForm) continue to operate in-memory on the images array while only message
is written to persistent storage.

{ 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);
14 changes: 14 additions & 0 deletions packages/db/drizzle/0017_add_feedback_table.sql
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");
Loading