diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx index 1b8db504a00..25526a789fd 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx @@ -4,10 +4,12 @@ import type { LocalizedString } from 'typesafe-i18n'; import { commonText } from '../../localization/common'; import { formsText } from '../../localization/forms'; import type { RA } from '../../utils/types'; +import { localized } from '../../utils/types'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import type { ViewDescription } from '../FormParse'; import type { cellAlign, cellVerticalAlign } from '../FormParse/cells'; +import { userPreferences } from '../Preferences/userPreferences'; import { Button } from './Button'; import { className } from './className'; import type { icons } from './Icons'; @@ -19,23 +21,24 @@ const dataEntryButton = ( className: string, title: LocalizedString, icon: keyof typeof icons -) => - function ( +) => { + const component = ( props: Omit, 'children' | 'type'> & { readonly onClick: | ((event: React.MouseEvent) => void) | undefined; } - ): JSX.Element { - return ( - - ); - }; + ): JSX.Element => ( + + ); + Object.defineProperty(component, 'name', { value: icon }); + return component; +}; export const columnDefinitionsToCss = ( columns: RA, @@ -54,6 +57,12 @@ export const columnDefinitionsToCss = ( * This is called DataEntry instead of Form because "Form" is already taken */ +const DataEntryAdd = dataEntryButton( + className.dataEntryAdd, + commonText.add(), + 'plus' +); + export const DataEntry = { Grid: wrap< 'div', @@ -145,7 +154,29 @@ export const DataEntry = { }) ), SubFormTitle: wrap('DataEntry.SubFormTitle', 'h3', className.formTitle), - Add: dataEntryButton(className.dataEntryAdd, commonText.add(), 'plus'), + Add({ + enableShortcut, + onClick: handleClick, + title = commonText.add(), + ...rest + }: Omit[0], 'onClick'> & { + readonly onClick: (() => void) | undefined; + readonly enableShortcut: boolean; + }): JSX.Element { + const addButtonShortcut = userPreferences.useKeyboardShortcut( + 'form', + 'recordSet', + 'addResource', + enableShortcut && rest.disabled !== true ? handleClick : undefined + ); + return ( + + ); + }, View: dataEntryButton(className.dataEntryView, commonText.view(), 'eye'), Edit: dataEntryButton(className.dataEntryEdit, commonText.edit(), 'pencil'), Clone: dataEntryButton( @@ -170,14 +201,21 @@ export const DataEntry = { readonly className?: string; readonly resource: SpecifyResource | undefined; }): JSX.Element | null { + const ref = React.useRef(null); + const keyboardShortcut = userPreferences.useKeyboardShortcut( + 'form', + 'queryComboBox', + 'openRelatedRecordInNewTab', + resource === undefined ? undefined : (): void => ref.current?.click() + ); return typeof resource === 'object' && !resource.isNew() ? ( ) : null; }, }; -/* eslint-enable @typescript-eslint/naming-convention */ diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx index d376691fecf..8c14f5ce555 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx @@ -75,6 +75,7 @@ export const icons = { collection: , cube: , cubeTransparent: , + cursorClick: , database: , document: , documentReport: , @@ -86,13 +87,10 @@ export const icons = { exclamationCircle: , externalLink: , eye: , + filter: , fingerPrint: , - gallery: - -, - globe: - -, + gallery:, + globe: , hashtag: , // This icon is not from Heroicons. It was drawn by @grantfitzsimmons history: , diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/Link.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/Link.tsx index d762b6ab951..ed346930742 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/Link.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/Link.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { LocalizedString } from 'typesafe-i18n'; +import { commonText } from '../../localization/common'; import type { IR, RA, RR } from '../../utils/types'; import { className } from './className'; import type { IconProps } from './Icons'; @@ -46,7 +47,15 @@ export const Link = { children: ( <> {props.children} - {icons.externalLink} + + {commonText.opensInNewTab()} + {icons.externalLink} + ), }; diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/DataEntry.test.ts b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/DataEntry.test.ts index 9ecd6142915..a642b17faf0 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/DataEntry.test.ts +++ b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/DataEntry.test.ts @@ -68,7 +68,7 @@ snapshot(DataEntry.Footer, { children: 'Test' }); snapshot(DataEntry.SubForm, { children: 'Test' }); snapshot(DataEntry.SubFormHeader, { children: 'Test' }); snapshot(DataEntry.SubFormTitle, { children: 'Test' }); -snapshot(DataEntry.Add, { onClick: f.never }); +snapshot(DataEntry.Add, { onClick: f.never, enableShortcut: true }); snapshot(DataEntry.View, { onClick: f.never }); snapshot(DataEntry.Edit, { onClick: f.never }); snapshot(DataEntry.Clone, { onClick: f.never }); diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/index.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/index.test.tsx.snap index 9aab22b6984..8e68abca9de 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/index.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/index.test.tsx.snap @@ -91,7 +91,7 @@ exports[`H3 renders without errors 1`] = ` exports[`Key renders without errors 1`] = ` View diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/index.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/index.tsx index 805ddb91797..5dd9336c596 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/index.tsx @@ -91,7 +91,7 @@ export const Summary = wrap< export const Key = wrap( 'Key', 'kbd', - 'bg-gray-200 border-1 dark:border-none dark:bg-neutral-700 rounded-sm mx-1 p-0.5' + 'bg-gray-200 border-1 dark:border-none dark:bg-neutral-700 rounded-sm mx-1 p-0.5 text-xl' ); const defaultOneRem = 16; diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/COJODialog.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/COJODialog.tsx index 698b347b4e3..00daccd2708 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCells/COJODialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCells/COJODialog.tsx @@ -86,7 +86,7 @@ export function COJODialog({ return ( <> - + {isOpen && ( {table.label} { setState('Add'); setResourceTable(table); diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx index baf13a48768..1b367ba9cde 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx @@ -554,6 +554,7 @@ export function FormTable({ {isExpanded[resource.cid] === true && ( @@ -594,6 +595,7 @@ export function FormTable({ ) : undefined} {hasTablePermission(relationship.relatedTable.name, 'create') ? ( { const resource = new relationship.relatedTable.Resource(); handleAddResources([resource]); diff --git a/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx b/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx index b7f5d50a3cc..2bca0e45890 100644 --- a/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx @@ -29,6 +29,7 @@ import { ProtectedAction, ProtectedTool, } from '../Permissions/PermissionDenied'; +import { userPreferences } from '../Preferences/userPreferences'; import { canMerge } from '../QueryBuilder/Results'; import { UnloadProtectsContext } from '../Router/UnloadProtect'; import { AutoNumbering } from './AutoNumbering'; @@ -44,22 +45,32 @@ import { ShareRecord } from './ShareRecord'; import { SubViewMeta } from './SubViewMeta'; /** - * Form preferences host context aware user preferences and other meta-actions. + * Form preferences, context aware user preferences, and other meta-actions. * List of available features: https://github.com/specify/specify7/issues/1330 */ export function FormMeta({ resource, className, viewDescription, + enableKeyboardShortcut, }: { readonly resource: SpecifyResource | undefined; readonly className?: string; readonly viewDescription: ViewDescription | undefined; + readonly enableKeyboardShortcut: boolean; }): JSX.Element | null { const [isOpen, _, handleClose, handleToggle] = useBooleanState(); const [isReadOnly = false] = useCachedState('forms', 'readOnlyMode'); const subView = React.useContext(SubViewContext); const isInFormEditor = React.useContext(InFormEditorContext); + + const keyboardShortcut = userPreferences.useKeyboardShortcut( + 'form', + 'actions', + 'openFormMeta', + enableKeyboardShortcut && resource !== undefined ? handleToggle : undefined + ); + return isInFormEditor && typeof viewDescription === 'object' ? ( ) : typeof resource === 'object' ? ( @@ -67,7 +78,7 @@ export function FormMeta({ {icons.cog} diff --git a/specifyweb/frontend/js_src/lib/components/FormPlugins/CollectionRelOneToMany.tsx b/specifyweb/frontend/js_src/lib/components/FormPlugins/CollectionRelOneToMany.tsx index 81da6994c87..8fe7f2ef350 100644 --- a/specifyweb/frontend/js_src/lib/components/FormPlugins/CollectionRelOneToMany.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormPlugins/CollectionRelOneToMany.tsx @@ -164,6 +164,7 @@ export function CollectionOneToManyPlugin({ typeof data === 'object' ? ( setState( state.type === 'SearchState' diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx index 8b6da91d914..3756f1cccd1 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx @@ -56,7 +56,7 @@ export function IntegratedRecordSelector({ ...rest }: Omit< Parameters[0], - 'children' | 'onSlide' | 'table' + 'children' | 'enableKeyboardShortcuts' | 'onSlide' | 'table' > & { readonly dialog: 'modal' | 'nonModal' | false; readonly formType: FormType; @@ -186,6 +186,7 @@ export function IntegratedRecordSelector({ { if (isInteraction) { @@ -314,6 +315,7 @@ export function IntegratedRecordSelector({ handleAdd, }); }} + enableShortcut={dialog !== false} /> ) ) : undefined} diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx index ea57737d30d..c8138580758 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx @@ -36,6 +36,7 @@ export type RecordSelectorProps = { | ((newIndex: number, replace: boolean, callback?: () => void) => void) | undefined; readonly isCollapsed?: boolean; + readonly enableKeyboardShortcuts: boolean; }; export type RecordSelectorState = { @@ -72,6 +73,7 @@ export function useRecordSelector({ index, onSlide: handleSlide, totalCount, + enableKeyboardShortcuts, }: RecordSelectorProps & { // Total number of elements in the collection readonly totalCount: number; @@ -120,6 +122,7 @@ export function useRecordSelector({ slider: ( ({ onFetch: handleFetch, hasSeveralResourceType, ...rest -}: Omit, 'index' | 'records'> & { +}: Omit< + RecordSelectorProps, + 'enableKeyboardShortcuts' | 'index' | 'records' +> & { /* * Undefined IDs are placeholders for items with unknown IDs (e.g in record * sets or query results with thousands of items) @@ -126,6 +129,7 @@ export function RecordSelectorFromIds({ isLoading, } = useRecordSelector({ ...rest, + enableKeyboardShortcuts: true, index, table, records: @@ -209,6 +213,7 @@ export function RecordSelectorFromIds({ { const resource = new table.Resource(); diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx index bd3995be1fc..62cfa6ecc76 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx @@ -181,6 +181,7 @@ function RecordSet({ }: Omit< RecordSelectorProps, | 'defaultIndex' + | 'enableKeyboardShortcuts' | 'field' | 'index' | 'onDelete' diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/Slider.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/Slider.tsx index 6b5d9d90705..73d7c84793c 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/Slider.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/Slider.tsx @@ -5,15 +5,21 @@ import { clamp } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { Input } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; +import { userPreferences } from '../Preferences/userPreferences'; export function Slider({ value, count, onChange: handleChange, + enableKeyboardShortcuts, }: { readonly value: number; readonly count: number; readonly onChange: ((newValue: number) => void) | undefined; + /** + * If true, keyboard shortcuts will be enabled for this slider + */ + readonly enableKeyboardShortcuts: boolean; }): JSX.Element | null { const [pendingValue, setPendingValue] = React.useState(value); const inputRef = React.useRef(null); @@ -26,22 +32,63 @@ export function Slider({ ); const max = Math.max(1, count); const resolvedValue = Number.isNaN(pendingValue) ? '' : pendingValue + 1; + + const goToFirstRecord = + value === 0 || handleChange === undefined + ? undefined + : (): void => handleChange?.(0); + const goToPreviousRecord = + value === 0 || handleChange === undefined + ? undefined + : (): void => handleChange(value - 1); + const goToNextRecord = + value + 1 === count || handleChange === undefined + ? undefined + : (): void => handleChange?.(value + 1); + const goToLastRecord = + value + 1 === count || handleChange === undefined + ? undefined + : (): void => handleChange?.(count - 1); + + const goToFirstRecordShortcut = userPreferences.useKeyboardShortcut( + 'form', + 'recordSet', + 'goToFirstRecord', + enableKeyboardShortcuts ? goToFirstRecord : undefined + ); + const goToPreviousRecordShortcut = userPreferences.useKeyboardShortcut( + 'form', + 'recordSet', + 'goToPreviousRecord', + enableKeyboardShortcuts ? goToPreviousRecord : undefined + ); + const goToNextRecordShortcut = userPreferences.useKeyboardShortcut( + 'form', + 'recordSet', + 'goToNextRecord', + enableKeyboardShortcuts ? goToNextRecord : undefined + ); + const goToLastRecordShortcut = userPreferences.useKeyboardShortcut( + 'form', + 'recordSet', + 'goToLastRecord', + enableKeyboardShortcuts ? goToLastRecord : undefined + ); + return count > 0 ? (