From 94612a6dd366bf6ec4078e877a8ca2372ae7fdcd Mon Sep 17 00:00:00 2001 From: tonym Date: Fri, 16 Jan 2026 00:09:06 -0600 Subject: [PATCH] feat(ux): Sticky Save/Cancel buttons on forms (#38) - Add sticky positioning to form action buttons (always visible at bottom) - Add Ctrl+S / Cmd+S keyboard shortcut to save forms - Create reusable useFormShortcuts hook - Apply to all create forms: spool, filament, vendor Fixes: https://github.com/Donkie/Spoolman/issues/274 Co-Authored-By: Claude Opus 4.5 --- client/src/pages/filaments/create.tsx | 9 ++++++++- client/src/pages/spools/create.tsx | 9 ++++++++- client/src/pages/vendors/create.tsx | 9 ++++++++- client/src/utils/overrides.css | 12 ++++++++++++ client/src/utils/useFormShortcuts.ts | 26 ++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 client/src/utils/useFormShortcuts.ts diff --git a/client/src/pages/filaments/create.tsx b/client/src/pages/filaments/create.tsx index 29da712..599c527 100644 --- a/client/src/pages/filaments/create.tsx +++ b/client/src/pages/filaments/create.tsx @@ -5,7 +5,7 @@ import { PlusOutlined } from "@ant-design/icons"; import TextArea from "antd/es/input/TextArea"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields"; import { FilamentImportModal } from "../../components/filamentImportModal"; import { VendorCreateModal } from "../../components/vendorCreateModal"; @@ -20,6 +20,7 @@ import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from ". import { ExternalFilament } from "../../utils/queryExternalDB"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { getCurrencySymbol, useCurrency } from "../../utils/settings"; +import { useFormShortcuts } from "../../utils/useFormShortcuts"; import { getOrCreateVendorFromExternal } from "../vendors/functions"; import { IVendor } from "../vendors/model"; import { IFilament, IFilamentParsedExtras } from "./model"; @@ -83,6 +84,12 @@ export const FilamentCreate: React.FC { + handleSubmit("list"); + }, []); + useFormShortcuts(handleSaveShortcut); + const { selectProps: vendorSelect } = useSelect({ resource: "vendor", optionLabel: "name", diff --git a/client/src/pages/spools/create.tsx b/client/src/pages/spools/create.tsx index c373d9a..7c30456 100644 --- a/client/src/pages/spools/create.tsx +++ b/client/src/pages/spools/create.tsx @@ -5,7 +5,7 @@ import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Se import TextArea from "antd/es/input/TextArea"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields"; import { FilamentCreateModal } from "../../components/filamentCreateModal"; import { useSpoolmanLocations } from "../../components/otherModels"; @@ -14,6 +14,7 @@ import "../../utils/overrides.css"; import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { getCurrencySymbol, useCurrency } from "../../utils/settings"; +import { useFormShortcuts } from "../../utils/useFormShortcuts"; import { createFilamentFromExternal } from "../filaments/functions"; import { IFilament } from "../filaments/model"; import { useGetFilamentSelectOptions } from "./functions"; @@ -140,6 +141,12 @@ export const SpoolCreate: React.FC { + handleSubmit("list"); + }, []); + useFormShortcuts(handleSaveShortcut); + // Use useEffect to update the form's initialValues when the extra fields are loaded // This is necessary because the form is rendered before the extra fields are loaded useEffect(() => { diff --git a/client/src/pages/vendors/create.tsx b/client/src/pages/vendors/create.tsx index cdbbb1e..3cec161 100644 --- a/client/src/pages/vendors/create.tsx +++ b/client/src/pages/vendors/create.tsx @@ -4,9 +4,10 @@ import { Button, Form, Input, InputNumber, Typography } from "antd"; import TextArea from "antd/es/input/TextArea"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields"; import { EntityType, useGetFields } from "../../utils/queryFields"; +import { useFormShortcuts } from "../../utils/useFormShortcuts"; import { IVendor, IVendorParsedExtras } from "./model"; dayjs.extend(utc); @@ -41,6 +42,12 @@ export const VendorCreate: React.FC { + handleSubmit("list"); + }, []); + useFormShortcuts(handleSaveShortcut); + // Use useEffect to update the form's initialValues when the extra fields are loaded // This is necessary because the form is rendered before the extra fields are loaded useEffect(() => { diff --git a/client/src/utils/overrides.css b/client/src/utils/overrides.css index 5139cdf..b455210 100644 --- a/client/src/utils/overrides.css +++ b/client/src/utils/overrides.css @@ -6,3 +6,15 @@ .ant-form { max-width: 800px; } + +/* Sticky Save/Cancel buttons on forms (#38) */ +.ant-card-actions { + position: sticky; + bottom: 0; + z-index: 100; + background: var(--ant-color-bg-container, #fff); + border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0); + margin: 0 -24px -24px -24px; + padding: 12px 24px; + border-radius: 0 0 8px 8px; +} diff --git a/client/src/utils/useFormShortcuts.ts b/client/src/utils/useFormShortcuts.ts new file mode 100644 index 0000000..b7853b0 --- /dev/null +++ b/client/src/utils/useFormShortcuts.ts @@ -0,0 +1,26 @@ +import { useEffect } from "react"; + +/** + * Hook to add keyboard shortcuts to forms. + * - Ctrl+S / Cmd+S: Save (calls onSave callback) + * - Escape: Cancel (calls onCancel callback if provided) + */ +export function useFormShortcuts(onSave: () => void, onCancel?: () => void) { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ctrl+S or Cmd+S to save + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); + onSave(); + } + // Escape to cancel (if handler provided) + if (e.key === "Escape" && onCancel) { + e.preventDefault(); + onCancel(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onSave, onCancel]); +}