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

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
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 { createRoot } from "react-dom/client";
import tippy from "tippy.js";
@@ -20,16 +20,16 @@ export function Tooltip({
}: TooltipProps) {
const triggerRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<Root | null>(null);
const tippyRef = useRef<TippyInstance | null>(null);
const contentRef = useRef(content);
contentRef.current = content;
useEffect(() => {
if (!triggerRef.current) return;
if (!content) return;
const container = document.createElement("div");
const root = createRoot(container);
rootRef.current = root;
root.render(content);
const instance = tippy(triggerRef.current, {
content: container,
@@ -39,12 +39,33 @@ export function Tooltip({
theme: "tooltip",
touch: false,
});
tippyRef.current = instance;
if (contentRef.current) {
root.render(contentRef.current);
} else {
instance.disable();
}
return () => {
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 (
<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 { t } from "@lingui/core/macro";
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 { twMerge } from "tailwind-merge";
@@ -26,17 +26,22 @@ export default function WorkspaceMenu({
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const { tooltipContent: commandPaletteShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
const commandPaletteShortcut = useMemo(
() => ({
type: "PRESS" as const,
stroke: {
key: "k",
modifiers: ["META"],
modifiers: ["META"] as ("META" | "CONTROL" | "ALT" | "SHIFT")[],
},
action: () => setIsOpen(true),
description: t`Open command menu`,
group: "GENERAL",
});
group: "GENERAL" as const,
}),
[],
);
const { tooltipContent: commandPaletteShortcutTooltipContent } =
useKeyboardShortcut(commandPaletteShortcut);
return (
<>

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { keepPreviousData } from "@tanstack/react-query";
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 { useForm } from "react-hook-form";
import {
@@ -46,10 +46,10 @@ import { CardContextMembersModal } from "./components/CardContextMembersModal";
import { CardContextMenu } from "./components/CardContextMenu";
import { CardContextMoveListModal } from "./components/CardContextMoveListModal";
import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation";
import { MoveBoardForm } from "./components/MoveBoardForm";
import { DeleteListConfirmation } from "./components/DeleteListConfirmation";
import Filters from "./components/Filters";
import List from "./components/List";
import { MoveBoardForm } from "./components/MoveBoardForm";
import { NewCardForm } from "./components/NewCardForm";
import { NewListForm } from "./components/NewListForm";
import { NewTemplateForm } from "./components/NewTemplateForm";
@@ -85,21 +85,26 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const { canCreateList, canEditList, canEditCard, canEditBoard } =
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
? Array.isArray(params.boardId)
? params.boardId[0]
: params.boardId
: 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 { register, handleSubmit, setValue } = useForm<UpdateBoardInput>({

View File

@@ -5,8 +5,12 @@ import {
ListboxOptions,
} from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2";
import { useState } from "react";
import { useMemo, useState } from "react";
import {
HiArrowDownTray,
HiChevronDown,
HiOutlinePlusSmall,
} from "react-icons/hi2";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
@@ -33,14 +37,19 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards");
const { canCreateBoard } = usePermissions();
const { tooltipContent: createModalShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
const createBoardShortcut = useMemo(
() => ({
type: "PRESS" as const,
stroke: { key: "C" },
action: () => canCreateBoard && openModal("NEW_BOARD"),
description: t`Create new ${isTemplate ? "template" : "board"}`,
group: "ACTIONS",
});
group: "ACTIONS" as const,
}),
[canCreateBoard, isTemplate, openModal],
);
const { tooltipContent: createModalShortcutTooltipContent } =
useKeyboardShortcut(createBoardShortcut);
return (
<>
@@ -137,7 +146,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
onChange={(tab) => setActiveTab(tab)}
>
<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 ??
"Select a tab"}
<HiChevronDown
@@ -151,9 +160,10 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
key={tab.key}
value={tab.key}
className={({ selected }) =>
`relative cursor-pointer select-none py-2 pl-3 pr-9 ${selected
? "font-bold text-light-1000 dark:text-dark-1000"
: "font-normal text-light-1000 dark:text-dark-1000"
`relative cursor-pointer select-none py-2 pl-3 pr-9 ${
selected
? "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}
type="button"
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
? "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"
}`}
className={`mb-8 mt-2 whitespace-nowrap px-1 py-0 text-sm font-semibold transition-colors focus:outline-none ${
activeTab === tab.key
? "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}
</button>