Compare commits

...

1 Commits

Author SHA1 Message Date
Henry
a2ac3acfb6 fix: prevent maximum update depth exceeded error 2026-08-12 20:06:11 +01:00
7 changed files with 185 additions and 118 deletions

View File

@@ -4,7 +4,7 @@ import { Button } from "@headlessui/react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { HiBolt } from "react-icons/hi2"; import { HiBolt } from "react-icons/hi2";
import { import {
TbLayoutSidebarLeftCollapse, TbLayoutSidebarLeftCollapse,
@@ -92,56 +92,59 @@ export default function SideNavigation({
href: string; href: string;
icon: object; icon: object;
keyboardShortcut: KeyboardShortcut; keyboardShortcut: KeyboardShortcut;
}[] = [ }[] = useMemo(
{ () => [
name: t`Boards`, {
href: "/boards", name: t`Boards`,
icon: isDarkMode ? boardsIconDark : boardsIconLight, href: "/boards",
keyboardShortcut: { icon: isDarkMode ? boardsIconDark : boardsIconLight,
type: "SEQUENCE", keyboardShortcut: {
strokes: [{ key: "G" }, { key: "B" }], type: "SEQUENCE",
action: () => router.push("/boards"), strokes: [{ key: "G" }, { key: "B" }],
group: "NAVIGATION", action: () => router.push("/boards"),
description: t`Go to boards`, group: "NAVIGATION",
description: t`Go to boards`,
},
}, },
}, {
{ name: t`Templates`,
name: t`Templates`, href: "/templates",
href: "/templates", icon: isDarkMode ? templatesIconDark : templatesIconLight,
icon: isDarkMode ? templatesIconDark : templatesIconLight, keyboardShortcut: {
keyboardShortcut: { type: "SEQUENCE",
type: "SEQUENCE", strokes: [{ key: "G" }, { key: "T" }],
strokes: [{ key: "G" }, { key: "T" }], action: () => router.push("/templates"),
action: () => router.push("/templates"), group: "NAVIGATION",
group: "NAVIGATION", description: t`Go to templates`,
description: t`Go to templates`, },
}, },
}, {
{ name: t`Members`,
name: t`Members`, href: "/members",
href: "/members", icon: isDarkMode ? membersIconDark : membersIconLight,
icon: isDarkMode ? membersIconDark : membersIconLight, keyboardShortcut: {
keyboardShortcut: { type: "SEQUENCE",
type: "SEQUENCE", strokes: [{ key: "G" }, { key: "M" }],
strokes: [{ key: "G" }, { key: "M" }], action: () => router.push("/members"),
action: () => router.push("/members"), group: "NAVIGATION",
group: "NAVIGATION", description: t`Go to members`,
description: t`Go to members`, },
}, },
}, {
{ name: t`Settings`,
name: t`Settings`, href: "/settings",
href: "/settings", icon: isDarkMode ? settingsIconDark : settingsIconLight,
icon: isDarkMode ? settingsIconDark : settingsIconLight, keyboardShortcut: {
keyboardShortcut: { type: "SEQUENCE",
type: "SEQUENCE", strokes: [{ key: "G" }, { key: "S" }],
strokes: [{ key: "G" }, { key: "S" }], action: () => router.push("/settings"),
action: () => router.push("/settings"), group: "NAVIGATION",
group: "NAVIGATION", description: t`Go to settings`,
description: t`Go to settings`, },
}, },
}, ],
]; [isDarkMode],
);
const toggleCollapse = () => { const toggleCollapse = () => {
setIsCollapsed(!isCollapsed); setIsCollapsed(!isCollapsed);

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { Root } from "react-dom/client"; import type { Root } from "react-dom/client";
import type { Placement } from "tippy.js"; import type { Placement, Instance as TippyInstance } from "tippy.js";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import tippy from "tippy.js"; import tippy from "tippy.js";
@@ -20,16 +20,16 @@ export function Tooltip({
}: TooltipProps) { }: TooltipProps) {
const triggerRef = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<Root | null>(null); const rootRef = useRef<Root | null>(null);
const tippyRef = useRef<TippyInstance | null>(null);
const contentRef = useRef(content);
contentRef.current = content;
useEffect(() => { useEffect(() => {
if (!triggerRef.current) return; if (!triggerRef.current) return;
if (!content) return;
const container = document.createElement("div"); const container = document.createElement("div");
const root = createRoot(container); const root = createRoot(container);
rootRef.current = root; rootRef.current = root;
root.render(content);
const instance = tippy(triggerRef.current, { const instance = tippy(triggerRef.current, {
content: container, content: container,
@@ -39,12 +39,33 @@ export function Tooltip({
theme: "tooltip", theme: "tooltip",
touch: false, touch: false,
}); });
tippyRef.current = instance;
if (contentRef.current) {
root.render(contentRef.current);
} else {
instance.disable();
}
return () => { return () => {
instance.destroy(); instance.destroy();
rootRef.current?.unmount(); tippyRef.current = null;
root.unmount();
rootRef.current = null;
}; };
}, [content, placement, delay]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [placement, delay]);
useEffect(() => {
if (!content) {
tippyRef.current?.disable();
return;
}
if (tippyRef.current) {
tippyRef.current.enable();
rootRef.current?.render(content);
}
}, [content]);
return ( return (
<div ref={triggerRef} className="inline-flex"> <div ref={triggerRef} className="inline-flex">

View File

@@ -2,7 +2,7 @@ import { useRouter } from "next/navigation";
import { Button, Menu, Transition } from "@headlessui/react"; import { Button, Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { Fragment, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2"; import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -26,17 +26,22 @@ export default function WorkspaceMenu({
const router = useRouter(); const router = useRouter();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { tooltipContent: commandPaletteShortcutTooltipContent } = const commandPaletteShortcut = useMemo(
useKeyboardShortcut({ () => ({
type: "PRESS", type: "PRESS" as const,
stroke: { stroke: {
key: "k", key: "k",
modifiers: ["META"], modifiers: ["META"] as ("META" | "CONTROL" | "ALT" | "SHIFT")[],
}, },
action: () => setIsOpen(true), action: () => setIsOpen(true),
description: t`Open command menu`, description: t`Open command menu`,
group: "GENERAL", group: "GENERAL" as const,
}); }),
[],
);
const { tooltipContent: commandPaletteShortcutTooltipContent } =
useKeyboardShortcut(commandPaletteShortcut);
return ( return (
<> <>

View File

@@ -1,4 +1,5 @@
import { useEffect } from "react"; import { useCallback, useEffect, useRef } from "react";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
interface UseModalFormStateOptions<T> { interface UseModalFormStateOptions<T> {
@@ -12,25 +13,43 @@ export function useModalFormState<T extends Record<string, any>>({
initialValues, initialValues,
resetOnClose = false, resetOnClose = false,
}: UseModalFormStateOptions<T>) { }: UseModalFormStateOptions<T>) {
const { modalContentType, isOpen, getModalState, setModalState, clearModalState } = useModal(); const {
modalContentType,
isOpen,
getModalState,
setModalState,
clearModalState,
} = useModal();
const isCurrentModal = modalContentType === modalType; const isCurrentModal = modalContentType === modalType;
const savedState = getModalState(modalType) as T | undefined; const savedState = getModalState(modalType) as T | undefined;
// get current form state (using the saved values if available, otherwise the initial values) // get current form state (using the saved values if available, otherwise the initial values)
const formState = savedState || initialValues; const formState = savedState || initialValues;
const saveFormState = (state: Partial<T>) => { // Keep refs so the callbacks below stay stable across re-renders.
if (!isCurrentModal) return; const modalTypeRef = useRef(modalType);
const initialValuesRef = useRef(initialValues);
const currentState = getModalState(modalType) || initialValues; const getModalStateRef = useRef(getModalState);
const newState = { ...currentState, ...state }; modalTypeRef.current = modalType;
setModalState(modalType, newState); initialValuesRef.current = initialValues;
}; getModalStateRef.current = getModalState;
const clearFormState = () => { const saveFormState = useCallback(
clearModalState(modalType); (state: Partial<T>) => {
}; const type = modalTypeRef.current;
const currentState =
getModalStateRef.current(type) ?? initialValuesRef.current;
const newState = { ...currentState, ...state };
setModalState(type, newState);
},
// setModalState is stable (useCallback with [] deps in ModalProvider)
[setModalState],
);
const clearFormState = useCallback(() => {
clearModalState(modalTypeRef.current);
}, [clearModalState]);
useEffect(() => { useEffect(() => {
if (resetOnClose && !isOpen && savedState) { if (resetOnClose && !isOpen && savedState) {
@@ -45,4 +64,4 @@ export function useModalFormState<T extends Record<string, any>>({
isCurrentModal, isCurrentModal,
hasSavedState: !!savedState, hasSavedState: !!savedState,
}; };
} }

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useState } from "react"; import { createContext, useCallback, useContext, useState } from "react";
interface PopupContextType { interface PopupContextType {
isOpen: boolean; isOpen: boolean;
@@ -25,24 +25,27 @@ export const PopupProvider: React.FC<Props> = ({ children }) => {
const [popupMessage, setPopupMessage] = useState(""); const [popupMessage, setPopupMessage] = useState("");
const [popupIcon, setPopupIcon] = useState(""); const [popupIcon, setPopupIcon] = useState("");
const showPopup = ({ const showPopup = useCallback(
header, ({
message, header,
icon, message,
}: { icon,
header: string; }: {
message: string; header: string;
icon: string; message: string;
}) => { icon: string;
setIsOpen(true); }) => {
setPopupHeader(header); setIsOpen(true);
setPopupMessage(message); setPopupHeader(header);
setPopupIcon(icon); setPopupMessage(message);
}; setPopupIcon(icon);
},
[],
);
const hidePopup = () => { const hidePopup = useCallback(() => {
setIsOpen(false); setIsOpen(false);
}; }, []);
return ( return (
<PopupContext.Provider <PopupContext.Provider

View File

@@ -5,7 +5,7 @@ import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { keepPreviousData } from "@tanstack/react-query"; import { keepPreviousData } from "@tanstack/react-query";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { DragDropContext, Draggable } from "react-beautiful-dnd"; import { DragDropContext, Draggable } from "react-beautiful-dnd";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { import {
@@ -46,10 +46,10 @@ import { CardContextMembersModal } from "./components/CardContextMembersModal";
import { CardContextMenu } from "./components/CardContextMenu"; import { CardContextMenu } from "./components/CardContextMenu";
import { CardContextMoveListModal } from "./components/CardContextMoveListModal"; import { CardContextMoveListModal } from "./components/CardContextMoveListModal";
import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation"; import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation";
import { MoveBoardForm } from "./components/MoveBoardForm";
import { DeleteListConfirmation } from "./components/DeleteListConfirmation"; import { DeleteListConfirmation } from "./components/DeleteListConfirmation";
import Filters from "./components/Filters"; import Filters from "./components/Filters";
import List from "./components/List"; import List from "./components/List";
import { MoveBoardForm } from "./components/MoveBoardForm";
import { NewCardForm } from "./components/NewCardForm"; import { NewCardForm } from "./components/NewCardForm";
import { NewListForm } from "./components/NewListForm"; import { NewListForm } from "./components/NewListForm";
import { NewTemplateForm } from "./components/NewTemplateForm"; import { NewTemplateForm } from "./components/NewTemplateForm";
@@ -85,21 +85,26 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const { canCreateList, canEditList, canEditCard, canEditBoard } = const { canCreateList, canEditList, canEditCard, canEditBoard } =
usePermissions(); usePermissions();
const { tooltipContent: createListShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
stroke: { key: "C" },
action: () => boardId && canCreateList && openNewListForm(boardId),
description: t`Create new list`,
group: "ACTIONS",
});
const boardId = params?.boardId const boardId = params?.boardId
? Array.isArray(params.boardId) ? Array.isArray(params.boardId)
? params.boardId[0] ? params.boardId[0]
: params.boardId : params.boardId
: null; : null;
const createListShortcut = useMemo(
() => ({
type: "PRESS" as const,
stroke: { key: "C" },
action: () => boardId && canCreateList && openNewListForm(boardId),
description: t`Create new list`,
group: "ACTIONS" as const,
}),
[boardId, canCreateList],
);
const { tooltipContent: createListShortcutTooltipContent } =
useKeyboardShortcut(createListShortcut);
const updateBoard = api.board.update.useMutation(); const updateBoard = api.board.update.useMutation();
const { register, handleSubmit, setValue } = useForm<UpdateBoardInput>({ const { register, handleSubmit, setValue } = useForm<UpdateBoardInput>({

View File

@@ -5,8 +5,12 @@ import {
ListboxOptions, ListboxOptions,
} from "@headlessui/react"; } from "@headlessui/react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2"; import { useMemo, useState } from "react";
import { useState } from "react"; import {
HiArrowDownTray,
HiChevronDown,
HiOutlinePlusSmall,
} from "react-icons/hi2";
import Button from "~/components/Button"; import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal"; import FeedbackModal from "~/components/FeedbackModal";
@@ -33,14 +37,19 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards"); const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards");
const { canCreateBoard } = usePermissions(); const { canCreateBoard } = usePermissions();
const { tooltipContent: createModalShortcutTooltipContent } = const createBoardShortcut = useMemo(
useKeyboardShortcut({ () => ({
type: "PRESS", type: "PRESS" as const,
stroke: { key: "C" }, stroke: { key: "C" },
action: () => canCreateBoard && openModal("NEW_BOARD"), action: () => canCreateBoard && openModal("NEW_BOARD"),
description: t`Create new ${isTemplate ? "template" : "board"}`, description: t`Create new ${isTemplate ? "template" : "board"}`,
group: "ACTIONS", group: "ACTIONS" as const,
}); }),
[canCreateBoard, isTemplate, openModal],
);
const { tooltipContent: createModalShortcutTooltipContent } =
useKeyboardShortcut(createBoardShortcut);
return ( return (
<> <>
@@ -137,7 +146,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
onChange={(tab) => setActiveTab(tab)} onChange={(tab) => setActiveTab(tab)}
> >
<div className="relative mb-4"> <div className="relative mb-4">
<ListboxButton className="w-full appearance-none rounded-md border-0 bg-light-50 py-3 pl-3 pr-10 text-left text-sm font-semibold text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500"> <ListboxButton className="w-full appearance-none rounded-md border-0 bg-light-50 py-3 pl-3 pr-10 text-left text-sm font-semibold text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
{boardsTabs.find((tab) => tab.key === activeTab)?.label ?? {boardsTabs.find((tab) => tab.key === activeTab)?.label ??
"Select a tab"} "Select a tab"}
<HiChevronDown <HiChevronDown
@@ -151,9 +160,10 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
key={tab.key} key={tab.key}
value={tab.key} value={tab.key}
className={({ selected }) => className={({ selected }) =>
`relative cursor-pointer select-none py-2 pl-3 pr-9 ${selected `relative cursor-pointer select-none py-2 pl-3 pr-9 ${
? "font-bold text-light-1000 dark:text-dark-1000" selected
: "font-normal text-light-1000 dark:text-dark-1000" ? "font-bold text-light-1000 dark:text-dark-1000"
: "font-normal text-light-1000 dark:text-dark-1000"
}` }`
} }
> >
@@ -175,10 +185,11 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
key={tab.key} key={tab.key}
type="button" type="button"
onClick={() => setActiveTab(tab.key)} onClick={() => setActiveTab(tab.key)}
className={`whitespace-nowrap px-1 py-0 mt-2 mb-8 text-sm font-semibold transition-colors focus:outline-none ${activeTab === tab.key className={`mb-8 mt-2 whitespace-nowrap px-1 py-0 text-sm font-semibold transition-colors focus:outline-none ${
? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000" activeTab === tab.key
: "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950" ? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000"
}`} : "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950"
}`}
> >
{tab.label} {tab.label}
</button> </button>