diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx index b475a0036e2..735da436a0e 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 5d526e9bd26..f37b6e6867c 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx @@ -74,6 +74,7 @@ export const icons = { collection: , cube: , cubeTransparent: , + cursorClick: , database: , document: , documentReport: , @@ -84,13 +85,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 9497a48c842..76d5840ae83 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/Link.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/Link.tsx @@ -42,7 +42,12 @@ export const Link = { children: ( <> {props.children} - + {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__/DataEntry.test.ts.snap b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/DataEntry.test.ts.snap index 3c892b69f24..5ec3270944f 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/DataEntry.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/DataEntry.test.ts.snap @@ -1,11 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` renders without errors 1`] = ` +exports[`Add renders without errors 1`] = ` + +`; + +exports[`eye renders without errors 1`] = ` + + + +`; + +exports[`minus renders without errors 1`] = ` + + + +`; + +exports[`pencil renders without errors 1`] = ` + + + +`; + +exports[`search renders without errors 1`] = ` + + + +`; 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 bce11478917..89cca86fe31 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/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx index e58d70ba633..a6b49f1f128 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx @@ -422,6 +422,7 @@ export function FormTable({ {isExpanded[resource.cid] === true && ( @@ -441,30 +442,31 @@ export function FormTable({ ); - const addButton = + + const canAdd = typeof handleAddResources === 'function' && mode !== 'view' && !disableAdding && hasTablePermission( relationship.relatedTable.name, isDependent ? 'create' : 'read' - ) ? ( - { - const resource = new relationship.relatedTable.Resource(); - handleAddResources([resource]); - } - : (): void => - setState({ - type: 'SearchState', - }) + ); + const handleAddClick = + !canAdd || disableAdding + ? undefined + : isDependent + ? (): void => { + const resource = new relationship.relatedTable.Resource(); + handleAddResources([resource]); } - /> - ) : undefined; + : (): void => + setState({ + type: 'SearchState', + }); + + const addButton = canAdd ? ( + + ) : undefined; return dialog === false ? ( diff --git a/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx b/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx index 9d6ccd6c1c6..1306b3712a3 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 { UnloadProtectsContext } from '../Router/UnloadProtect'; import { AutoNumbering } from './AutoNumbering'; import { CarryForwardConfig } from './CarryForward'; @@ -43,22 +44,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' ? ( @@ -66,7 +77,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 a16823b4a1e..e19e2243e84 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 8e594d2a0a6..b7f372fbbcb 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx @@ -46,7 +46,7 @@ export function IntegratedRecordSelector({ ...rest }: Omit< Parameters[0], - 'children' | 'onSlide' | 'table' + 'children' | 'enableKeyboardShortcuts' | 'onSlide' | 'table' > & { readonly dialog: 'modal' | 'nonModal' | false; readonly formType: FormType; @@ -119,8 +119,9 @@ export function IntegratedRecordSelector({ { + onAdd={(resources): void => { if (isInteraction) { setInteractionResource(resources[0]); handleOpenDialog(); @@ -193,6 +194,7 @@ export function IntegratedRecordSelector({ new collection.table.specifyTable.Resource(); handleAdd([resource]); }} + enableShortcut={dialog !== false} /> ) : undefined} {hasTablePermission( diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx index 08e845d3e23..1e4f6ce1032 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx @@ -35,6 +35,7 @@ export type RecordSelectorProps = { | ((newIndex: number, replace: boolean, callback?: () => void) => void) | undefined; readonly isCollapsed?: boolean; + readonly enableKeyboardShortcuts: boolean; }; export type RecordSelectorState = { @@ -70,6 +71,7 @@ export function useRecordSelector({ index, onSlide: handleSlide, totalCount, + enableKeyboardShortcuts, }: RecordSelectorProps & { // Total number of elements in the collection readonly totalCount: number; @@ -90,11 +92,12 @@ export function useRecordSelector({ slider: ( handleSlide?.(index, false) + : (index): void => handleSlide?.(index, false) } /> ), diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx index 5572e425d32..3f28a580062 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx @@ -50,7 +50,10 @@ export function RecordSelectorFromIds({ 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) @@ -124,6 +127,7 @@ export function RecordSelectorFromIds({ isLoading, } = useRecordSelector({ ...rest, + enableKeyboardShortcuts: true, index, table, records: @@ -207,6 +211,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 86ba9f041f8..8e61b68f30d 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx @@ -180,6 +180,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 ? (