diff --git a/specifyweb/frontend/js_src/css/workbench.css b/specifyweb/frontend/js_src/css/workbench.css index 9c0af09723d..f7839c71071 100644 --- a/specifyweb/frontend/js_src/css/workbench.css +++ b/specifyweb/frontend/js_src/css/workbench.css @@ -77,6 +77,16 @@ --accent-color: var(--search-result); } +/* + * Override default font properties: required for handsontable 10 and above + * See: https://github.com/handsontable/handsontable/pull/8681 + */ +.handsontable { + @apply text-inherit; + font-family: inherit; + font-size: inherit; +} + /* Handsontable dark mode */ .handsontable td, .htContextMenu table tbody tr td { diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx index 70b41e9eb52..b2ad3765208 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx @@ -200,7 +200,7 @@ export const routes: RA = [ { path: ':id', element: () => - import('../WorkBench/Template').then(({ WorkBench }) => WorkBench), + import('../WorkBench/index').then(({ WorkBench }) => WorkBench), }, { path: 'import', diff --git a/specifyweb/frontend/js_src/lib/components/WbActions/WbNoUploadPlan.tsx b/specifyweb/frontend/js_src/lib/components/WbActions/WbNoUploadPlan.tsx new file mode 100644 index 00000000000..9e3cc357c91 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbActions/WbNoUploadPlan.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { wbPlanText } from '../../localization/wbPlan'; +import { Button } from '../Atoms/Button'; +import { Link } from '../Atoms/Link'; +import { Dialog } from '../Molecules/Dialog'; +import { hasPermission } from '../Permissions/helpers'; +import type { WbMapping } from '../WorkBench/mapping'; + +export function WbNoUploadPlan({ + isUploaded, + mappings, + datasetId, + noUploadPlan, + onOpenNoUploadPlan: handleOpenNoUploadPlan, + onCloseNoUploadPlan: handleCloseNoUploadPlan, +}: { + readonly isUploaded: boolean; + readonly mappings: WbMapping | undefined; + readonly datasetId: number; + readonly noUploadPlan: boolean; + readonly onOpenNoUploadPlan: () => void; + readonly onCloseNoUploadPlan: () => void; +}): JSX.Element { + React.useEffect(() => { + if ( + !isUploaded && + (mappings?.lines ?? []).length === 0 && + hasPermission('/workbench/dataset', 'upload') + ) { + handleOpenNoUploadPlan(); + } + }, []); + + return ( + <> + {noUploadPlan && ( + + {commonText.close()} + + {commonText.create()} + + + } + header={wbPlanText.noUploadPlan()} + onClose={handleCloseNoUploadPlan} + > + {wbPlanText.noUploadPlanDescription()} + + )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/WbActions/WbRevert.tsx b/specifyweb/frontend/js_src/lib/components/WbActions/WbRevert.tsx new file mode 100644 index 00000000000..d78f7124621 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbActions/WbRevert.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { useBooleanState } from '../../hooks/useBooleanState'; +import { commonText } from '../../localization/common'; +import { wbText } from '../../localization/workbench'; +import { Button } from '../Atoms/Button'; +import { Dialog } from '../Molecules/Dialog'; + +export function WbRevert({ + hasUnsavedChanges, + onRefresh: handleRefresh, + onSpreadsheetUpToDate: handleSpreadsheetUpToDate, +}: { + readonly hasUnsavedChanges: boolean; + readonly onRefresh: () => void; + readonly onSpreadsheetUpToDate: () => void; +}): JSX.Element { + const [showRevert, openRevert, closeRevert] = useBooleanState(); + + const handleRevert = () => { + handleRefresh(); + closeRevert(); + handleSpreadsheetUpToDate(); + }; + + return ( + <> + + {wbText.revert()} + + {showRevert && ( + + {commonText.cancel()} + + {wbText.revert()} + + + } + header={wbText.revertChanges()} + onClose={closeRevert} + > + {wbText.revertChangesDescription()} + + )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/Components.tsx b/specifyweb/frontend/js_src/lib/components/WbActions/WbRollback.tsx similarity index 55% rename from specifyweb/frontend/js_src/lib/components/WorkBench/Components.tsx rename to specifyweb/frontend/js_src/lib/components/WbActions/WbRollback.tsx index b8146093640..7e437f74347 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/Components.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbActions/WbRollback.tsx @@ -1,18 +1,51 @@ import React from 'react'; +import { useBooleanState } from '../../hooks/useBooleanState'; import { commonText } from '../../localization/common'; import { wbText } from '../../localization/workbench'; import { ping } from '../../utils/ajax/ping'; import { Button } from '../Atoms/Button'; import { LoadingContext } from '../Core/Contexts'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; +import type { WbStatus } from '../WorkBench/WbView'; -export function RollbackConfirmation({ - dataSetId, +export function WbRollback({ + datasetId, + triggerStatusComponent, +}: { + readonly datasetId: number; + readonly triggerStatusComponent: (mode: WbStatus) => void; +}): JSX.Element { + const [confirmRollback, handleOpen, handleClose] = useBooleanState(); + + const handleRollback = () => triggerStatusComponent('unupload'); + + return ( + <> + + {wbText.rollback()} + + {confirmRollback && ( + + )} + + ); +} + +function RollbackConfirmation({ + datasetId, onClose: handleClose, onRollback: handleRollback, }: { - readonly dataSetId: number; + readonly datasetId: number; readonly onClose: () => void; readonly onRollback: () => void; }): JSX.Element { @@ -25,7 +58,7 @@ export function RollbackConfirmation({ loading( - ping(`/api/workbench/unupload/${dataSetId}/`, { + ping(`/api/workbench/unupload/${datasetId}/`, { method: 'POST', }) .then(handleRollback) diff --git a/specifyweb/frontend/js_src/lib/components/WbActions/WbSave.tsx b/specifyweb/frontend/js_src/lib/components/WbActions/WbSave.tsx new file mode 100644 index 00000000000..0b05fe8fa61 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbActions/WbSave.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +import { useBooleanState } from '../../hooks/useBooleanState'; +import { commonText } from '../../localization/common'; +import { wbText } from '../../localization/workbench'; +import { Http } from '../../utils/ajax/definitions'; +import { ping } from '../../utils/ajax/ping'; +import { overwriteReadOnly } from '../../utils/types'; +import { Button } from '../Atoms/Button'; +import { className } from '../Atoms/className'; +import { loadingBar } from '../Molecules'; +import { Dialog } from '../Molecules/Dialog'; +import type { Workbench } from '../WorkBench/WbView'; + +export function WbSave({ + workbench, + hasUnsavedChanges, + checkDeletedFail, + searchRef, + onSpreadsheetUpToDate: handleSpreadsheetUpToDate, +}: { + readonly workbench: Workbench; + readonly hasUnsavedChanges: boolean; + readonly searchRef: React.MutableRefObject; + readonly checkDeletedFail: (statusCode: number) => void; + readonly onSpreadsheetUpToDate: () => void; +}): JSX.Element { + const [showProgressBar, openProgressBar, closeProgressBar] = + useBooleanState(); + + const handleSave = () => { + // Clear validation + overwriteReadOnly(workbench.dataset, 'rowresults', null); + workbench.validation.stopLiveValidation(); + + // Show saving progress bar + openProgressBar(); + + // Send data + ping(`/api/workbench/rows/${workbench.dataset.id}/`, { + method: 'PUT', + body: workbench.data, + expectedErrors: [Http.NO_CONTENT, Http.NOT_FOUND], + }) + .then((status) => checkDeletedFail(status)) + .then(() => { + handleSpreadsheetUpToDate(); + workbench.cells.cellMeta = []; + workbench.utils?.searchCells( + { key: 'SettingsChange' }, + searchRef.current + ); + workbench.hot?.render(); + closeProgressBar(); + }); + }; + + return ( + <> + + {commonText.save()} + + {showProgressBar && ( + + {loadingBar} + + )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/WbActions/WbUpload.tsx b/specifyweb/frontend/js_src/lib/components/WbActions/WbUpload.tsx new file mode 100644 index 00000000000..4823c588881 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbActions/WbUpload.tsx @@ -0,0 +1,74 @@ +import React from 'react'; + +import { useBooleanState } from '../../hooks/useBooleanState'; +import { commonText } from '../../localization/common'; +import { wbText } from '../../localization/workbench'; +import { Button } from '../Atoms/Button'; +import { Dialog } from '../Molecules/Dialog'; +import type { WbCellCounts } from '../WorkBench/CellMeta'; +import type { WbMapping } from '../WorkBench/mapping'; +import type { WbStatus } from '../WorkBench/WbView'; + +export function WbUpload({ + hasUnsavedChanges, + mappings, + openNoUploadPlan, + startUpload, + cellCounts, +}: { + readonly hasUnsavedChanges: boolean; + readonly mappings: WbMapping | undefined; + readonly openNoUploadPlan: () => void; + readonly startUpload: (mode: WbStatus) => void; + readonly cellCounts: WbCellCounts; +}): JSX.Element { + const [showUpload, openUpload, closeUpload] = useBooleanState(); + + const handleUpload = (): void => { + if ((mappings?.lines ?? []).length > 0) { + openUpload(); + } else { + openNoUploadPlan(); + } + }; + + const handleConfirmUpload = (): void => { + startUpload('upload'); + closeUpload(); + }; + + return ( + <> + 0} + title={ + hasUnsavedChanges + ? wbText.unavailableWhileEditing() + : cellCounts.invalidCells > 0 + ? wbText.uploadUnavailableWhileHasErrors() + : undefined + } + onClick={handleUpload} + > + {wbText.upload()} + + {showUpload && ( + + {commonText.cancel()} + + {wbText.upload()} + + + } + header={wbText.startUpload()} + onClose={closeUpload} + > + {wbText.startUploadDescription()} + + )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/WbActions/WbValidate.tsx b/specifyweb/frontend/js_src/lib/components/WbActions/WbValidate.tsx new file mode 100644 index 00000000000..5398e16c159 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbActions/WbValidate.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +import { useBooleanState } from '../../hooks/useBooleanState'; +import { commonText } from '../../localization/common'; +import { wbText } from '../../localization/workbench'; +import { Button } from '../Atoms/Button'; +import { userPreferences } from '../Preferences/userPreferences'; +import type { WbValidation } from '../WorkBench/WbValidation'; +import type { WbStatus } from '../WorkBench/WbView'; + +export function WbValidate({ + hasUnsavedChanges, + startUpload, + validation, + isMapped, + isResultsOpen, +}: { + readonly hasUnsavedChanges: boolean; + readonly startUpload: (mode: WbStatus) => void; + readonly validation: WbValidation; + readonly isMapped: boolean; + readonly isResultsOpen: boolean; +}): JSX.Element { + const [canLiveValidate] = userPreferences.use( + 'workBench', + 'general', + 'liveValidation' + ); + const [isLiveValidateOn, _, __, toggleLiveValidate] = useBooleanState(); + const handleValidate = () => startUpload('validate'); + const handleToggleDataCheck = () => { + validation.toggleDataCheck(); + toggleLiveValidate(); + }; + + return ( + <> + {canLiveValidate && ( + + {isLiveValidateOn && validation.validationMode === 'live' + ? validation.liveValidationStack.length > 0 + ? commonText.countLine({ + resource: wbText.dataCheckOn(), + count: validation.liveValidationStack.length, + }) + : wbText.dataCheckOn() + : wbText.dataCheck()} + + )} + + {wbText.validate()} + + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/WbActions/index.tsx b/specifyweb/frontend/js_src/lib/components/WbActions/index.tsx new file mode 100644 index 00000000000..6349a50e37e --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbActions/index.tsx @@ -0,0 +1,346 @@ +import React from 'react'; +import type { LocalizedString } from 'typesafe-i18n'; + +import { useBooleanState } from '../../hooks/useBooleanState'; +import { commonText } from '../../localization/common'; +import { wbText } from '../../localization/workbench'; +import { Http } from '../../utils/ajax/definitions'; +import { ping } from '../../utils/ajax/ping'; +import { Button } from '../Atoms/Button'; +import { LoadingContext } from '../Core/Contexts'; +import { ErrorBoundary } from '../Errors/ErrorBoundary'; +import { Dialog } from '../Molecules/Dialog'; +import { hasPermission } from '../Permissions/helpers'; +import type { Dataset, Status } from '../WbPlanView/Wrapped'; +import type { WbCellCounts } from '../WorkBench/CellMeta'; +import type { WbMapping } from '../WorkBench/mapping'; +import { CreateRecordSetButton } from '../WorkBench/RecordSet'; +import { WbStatus as WbStatusComponent } from '../WorkBench/Status'; +import type { WbStatus, Workbench } from '../WorkBench/WbView'; +import { WbNoUploadPlan } from './WbNoUploadPlan'; +import { WbRevert } from './WbRevert'; +import { WbRollback } from './WbRollback'; +import { WbSave } from './WbSave'; +import { WbUpload } from './WbUpload'; +import { WbValidate } from './WbValidate'; + +export function WbActions({ + dataset, + hasUnsavedChanges, + isUploaded, + isResultsOpen, + workbench, + cellCounts, + mappings, + checkDeletedFail, + searchRef, + onDatasetRefresh: handleRefresh, + onSpreadsheetUpToDate: handleSpreadsheetUpToDate, + onToggleResults: handleToggleResults, +}: { + readonly dataset: Dataset; + readonly hasUnsavedChanges: boolean; + readonly isUploaded: boolean; + readonly isResultsOpen: boolean; + readonly workbench: Workbench; + readonly cellCounts: WbCellCounts; + readonly mappings: WbMapping | undefined; + readonly searchRef: React.MutableRefObject; + readonly checkDeletedFail: (statusCode: number) => void; + readonly onDatasetRefresh: () => void; + readonly onSpreadsheetUpToDate: () => void; + readonly onToggleResults: () => void; +}): JSX.Element { + const [noUploadPlan, openNoUploadPlan, closeNoUploadPlan] = useBooleanState(); + const [showStatus, openStatus, closeStatus] = useBooleanState(); + const [operationAborted, openAbortedMessage, closeAbortedMessage] = + useBooleanState(); + const [operationCompleted, openOperationCompleted, closeOperationCompleted] = + useBooleanState(); + const { mode, refreshInitiatorAborted, startUpload, triggerStatusComponent } = + useWbActions({ + datasetId: dataset.id, + onRefresh: handleRefresh, + checkDeletedFail, + onOpenStatus: openStatus, + workbench, + }); + + const message = mode === undefined ? undefined : getMessage(cellCounts, mode); + + const isMapped = mappings !== undefined; + + const dataCheckInProgress = React.useMemo( + () => workbench.validation.liveValidationStack.length > 0, + [workbench.validation.liveValidationStack.length] + ); + + return ( + <> + + {!isUploaded && hasPermission('/workbench/dataset', 'validate') ? ( + + + + ) : undefined} + + + {commonText.results()} + + + {isUploaded && hasPermission('/workbench/dataset', 'unupload') ? ( + + + + ) : undefined} + {!isUploaded && hasPermission('/workbench/dataset', 'upload') ? ( + + + + ) : undefined} + {!isUploaded && hasPermission('/workbench/dataset', 'update') ? ( + <> + + + + + + + + ) : undefined} + {typeof mode === 'string' && showStatus ? ( + { + refreshInitiatorAborted.current = wasAborted; + closeStatus(); + if (wasAborted) openAbortedMessage(); + else openOperationCompleted(); + handleRefresh(); + }} + /> + ) : undefined} + {operationCompleted && ( + + {cellCounts.invalidCells === 0 && mode === 'upload' && ( + { + refreshInitiatorAborted.current = false; + closeOperationCompleted(); + }} + /> + )} + {commonText.close()} + + } + header={message!.header} + onClose={closeOperationCompleted} + > + {message?.message} + + )} + {operationAborted && ( + + {mode === 'validate' + ? wbText.validationCanceledDescription() + : mode === 'unupload' + ? wbText.rollbackCanceledDescription() + : wbText.uploadCanceledDescription()} + + )} + + ); +} + +function useWbActions({ + datasetId, + workbench, + checkDeletedFail, + onRefresh: handleRefresh, + onOpenStatus: handleOpenStatus, +}: { + readonly datasetId: number; + readonly workbench: Workbench; + readonly checkDeletedFail: (statusCode: number) => void; + readonly onRefresh: () => void; + readonly onOpenStatus: () => void; +}): { + readonly mode: WbStatus | undefined; + readonly refreshInitiatorAborted: React.MutableRefObject; + readonly startUpload: (newMode: WbStatus) => void; + readonly triggerStatusComponent: (newMode: WbStatus) => void; +} { + const [mode, setMode] = React.useState(undefined); + const refreshInitiatorAborted = React.useRef(false); + const loading = React.useContext(LoadingContext); + + const startUpload = (newMode: WbStatus): void => { + workbench.validation.stopLiveValidation(); + loading( + ping(`/api/workbench/${newMode}/${datasetId}/`, { + method: 'POST', + expectedErrors: [Http.CONFLICT], + }).then((statusCode): void => { + checkDeletedFail(statusCode); + checkConflictFail(statusCode); + triggerStatusComponent(newMode); + }) + ); + }; + + const triggerStatusComponent = (newMode: WbStatus): void => { + setMode(newMode); + handleOpenStatus(); + }; + + const checkConflictFail = (statusCode: number): boolean => { + if (statusCode === Http.CONFLICT) + /* + * Upload/Validation/Un-Upload has been initialized by another session + * Need to reload the page to display the new state + */ + handleRefresh(); + return statusCode === Http.CONFLICT; + }; + + return { + mode, + refreshInitiatorAborted, + startUpload, + triggerStatusComponent, + }; +} + +function getMessage( + cellCounts: WbCellCounts, + mode: WbStatus +): { + readonly header: LocalizedString; + readonly message: JSX.Element | LocalizedString; +} { + const messages = { + validate: + cellCounts.invalidCells === 0 + ? { + header: wbText.validationNoErrors(), + message: ( + <> + {wbText.validationNoErrorsDescription()} +
+
+ {wbText.validationReEditWarning()} + + ), + } + : { + header: wbText.validationErrors(), + message: ( + <> + {wbText.validationErrorsDescription()} +
+
+ {wbText.validationReEditWarning()} + + ), + }, + upload: + cellCounts.invalidCells === 0 + ? { + header: wbText.uploadSuccessful(), + message: wbText.uploadSuccessfulDescription(), + } + : { + header: wbText.uploadErrors(), + message: ( + <> + {wbText.uploadErrorsDescription()} +
+
+ {wbText.uploadErrorsSecondDescription()} + + ), + }, + unupload: { + header: wbText.dataSetRollback(), + message: wbText.dataSetRollbackDescription(), + }, + }; + + return messages[mode]; +} diff --git a/specifyweb/frontend/js_src/lib/components/WbActions/useResults.ts b/specifyweb/frontend/js_src/lib/components/WbActions/useResults.ts new file mode 100644 index 00000000000..19212389933 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbActions/useResults.ts @@ -0,0 +1,91 @@ +import type Handsontable from 'handsontable'; +import React from 'react'; + +import { useBooleanState } from '../../hooks/useBooleanState'; +import { f } from '../../utils/functools'; +import { getHotPlugin, identifyDefaultValues } from '../WorkBench/handsontable'; +import type { Workbench } from '../WorkBench/WbView'; + +export function useResults({ + hot, + workbench, + triggerDatasetRefresh, +}: { + readonly hot: Handsontable | undefined; + readonly workbench: Workbench; + readonly triggerDatasetRefresh: () => void; +}): { + readonly showResults: boolean; + readonly closeResults: () => void; + readonly toggleResults: () => void; +} { + const [showResults, _, closeResults, toggleResults] = useBooleanState(); + + const initialHiddenRows = React.useMemo( + () => + hot === undefined ? [] : getHotPlugin(hot, 'hiddenRows').getHiddenRows(), + [hot, workbench.dataset.columns] + ); + const initialHiddenCols = React.useMemo( + () => + hot === undefined + ? [] + : getHotPlugin(hot, 'hiddenColumns').getHiddenColumns(), + [hot, workbench.dataset.columns] + ); + + // Makes the hot changes required for upload view results + React.useEffect(() => { + if (hot === undefined) return; + + const rowsToInclude = new Set(); + const colsToInclude = new Set(); + Object.entries(workbench.cells.cellMeta).forEach(([physicalRow, rowMeta]) => + rowMeta.forEach((metaArray, physicalCol) => { + if (!workbench.cells.getCellMetaFromArray(metaArray, 'isNew')) return; + rowsToInclude.add(f.fastParseInt(physicalRow)); + colsToInclude.add(physicalCol); + }) + ); + const rowsToHide = workbench.data + .map((_, physicalRow) => physicalRow) + .filter( + (physicalRow) => + !rowsToInclude.has(physicalRow) && + !initialHiddenRows.includes(physicalRow) + ) + .map(hot.toVisualRow); + const colsToHide = workbench.dataset.columns + .map((_, physicalCol) => physicalCol) + .filter( + (physicalCol) => + !colsToInclude.has(physicalCol) && + !initialHiddenCols.includes(physicalCol) + ) + .map(hot.toVisualColumn); + + hot.batch(() => { + if (showResults) { + identifyDefaultValues(hot, workbench.mappings); + getHotPlugin(hot, 'hiddenRows').hideRows(rowsToHide); + getHotPlugin(hot, 'hiddenColumns').hideColumns(colsToHide); + + workbench.utils.toggleCellTypes('newCells', 'remove'); + } else { + getHotPlugin(hot, 'hiddenRows').showRows( + rowsToHide.filter( + (visualRow) => !initialHiddenRows.includes(visualRow) + ) + ); + getHotPlugin(hot, 'hiddenColumns').showColumns( + colsToHide.filter( + (visualCol) => !initialHiddenCols.includes(visualCol) + ) + ); + triggerDatasetRefresh(); + } + }); + }, [showResults]); + + return { showResults, closeResults, toggleResults }; +} diff --git a/specifyweb/frontend/js_src/lib/components/WbToolkit/ChangeOwner.tsx b/specifyweb/frontend/js_src/lib/components/WbToolkit/ChangeOwner.tsx new file mode 100644 index 00000000000..e6743b2e691 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbToolkit/ChangeOwner.tsx @@ -0,0 +1,127 @@ +import React from 'react'; + +import { useAsyncState } from '../../hooks/useAsyncState'; +import { useBooleanState } from '../../hooks/useBooleanState'; +import { useId } from '../../hooks/useId'; +import { commonText } from '../../localization/common'; +import { wbText } from '../../localization/workbench'; +import { formData } from '../../utils/ajax/helpers'; +import { ping } from '../../utils/ajax/ping'; +import type { RA } from '../../utils/types'; +import { Button } from '../Atoms/Button'; +import { Form, Label, Select } from '../Atoms/Form'; +import { Submit } from '../Atoms/Submit'; +import { LoadingContext } from '../Core/Contexts'; +import { fetchCollection } from '../DataModel/collection'; +import type { SerializedResource } from '../DataModel/helperTypes'; +import type { SpecifyUser } from '../DataModel/types'; +import { userInformation } from '../InitialContext/userInformation'; +import { Dialog } from '../Molecules/Dialog'; +import { unsafeNavigate } from '../Router/Router'; +import type { Dataset } from '../WbPlanView/Wrapped'; + +export function WbChangeOwner({ + hasUnsavedChanges, + dataset, +}: { + readonly hasUnsavedChanges: boolean; + readonly dataset: Dataset; +}): JSX.Element { + const [showChangeOwner, openChangeOwner, closeChangeOwner] = + useBooleanState(); + return ( + <> + + {wbText.changeOwner()} + + {showChangeOwner && ( + + )} + + ); +} + +const fetchListOfUsers = async (): Promise< + RA> +> => + fetchCollection('SpecifyUser', { limit: 500, domainFilter: false }).then( + ({ records: users }) => users.filter(({ id }) => id !== userInformation.id) + ); + +function ChangeOwner({ + dataset, + onClose: handleClose, +}: { + readonly dataset: Dataset; + readonly onClose: () => void; +}): JSX.Element | null { + const [users] = useAsyncState>>( + fetchListOfUsers, + true + ); + + const id = useId('change-data-set-owner'); + const [newOwner, setNewOwner] = React.useState(undefined); + const [isChanged, setIsChanged] = React.useState(false); + const loading = React.useContext(LoadingContext); + + return users === undefined ? null : isChanged ? ( + unsafeNavigate('/specify/', { replace: true })} + > +

{wbText.dataSetOwnerChanged()}

+
+ ) : ( + + {commonText.cancel()} + + {wbText.changeOwner()} + + + } + header={wbText.changeDataSetOwner()} + onClose={handleClose} + > +
+ loading( + ping(`/api/workbench/transfer/${dataset.id}/`, { + method: 'POST', + body: formData({ + specifyuserid: newOwner!, + }), + }).then(() => setIsChanged(true)) + ) + } + > + +

{wbText.changeDataSetOwnerDescription()}

+ +
+
+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/CoordinateConverter.tsx b/specifyweb/frontend/js_src/lib/components/WbToolkit/CoordinateConverter.tsx similarity index 76% rename from specifyweb/frontend/js_src/lib/components/WorkBench/CoordinateConverter.tsx rename to specifyweb/frontend/js_src/lib/components/WbToolkit/CoordinateConverter.tsx index fb4f6f31b92..049665853a2 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/CoordinateConverter.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbToolkit/CoordinateConverter.tsx @@ -1,10 +1,12 @@ import type Handsontable from 'handsontable'; import React from 'react'; +import { useBooleanState } from '../../hooks/useBooleanState'; import { useCachedState } from '../../hooks/useCachedState'; import { commonText } from '../../localization/common'; import { localityText } from '../../localization/locality'; import { wbText } from '../../localization/workbench'; +import { f } from '../../utils/functools'; import type { ConversionFunction } from '../../utils/latLong'; import { Lat, Long } from '../../utils/latLong'; import type { RA, RR } from '../../utils/types'; @@ -12,7 +14,64 @@ import { Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; import { Input, Label } from '../Atoms/Form'; import { Dialog } from '../Molecules/Dialog'; -import { getSelectedCells, getSelectedLast, setHotData } from './hotHelpers'; +import type { Dataset } from '../WbPlanView/Wrapped'; +import { + getSelectedCells, + getSelectedLast, + setHotData, +} from '../WorkBench/hotHelpers'; +import type { WbMapping } from '../WorkBench/mapping'; + +export function WbConvertCoordinates({ + hasLocality, + dataset, + data, + mappings, + hot, + isUploaded, + isResultsOpen, +}: { + readonly hasLocality: boolean; + readonly dataset: Dataset; + readonly data: RA>; + readonly mappings: WbMapping | undefined; + readonly hot: Handsontable; + readonly isUploaded: boolean; + readonly isResultsOpen: boolean; +}): JSX.Element { + const [showConvertCoords, openConvertCoords, closeConvertCoords] = + useBooleanState(); + return ( + <> + + {wbText.convertCoordinates()} + + {showConvertCoords && mappings !== undefined ? ( + + ) : undefined} + + ); +} const options: RA<{ readonly label: string; @@ -51,7 +110,7 @@ const options: RA<{ }, ]; -export function CoordinateConverter({ +function CoordinateConverter({ hot, data, columns, @@ -77,13 +136,13 @@ export function CoordinateConverter({ >(undefined); const [showDirection, setShowDirection] = React.useState(false); - const changeCount = React.useRef(0); + const changeCountRef = React.useRef(0); // List of coordinate columns const columnsToWorkWith = React.useMemo( () => Object.keys(coordinateColumns).map((physicalCol) => - hot.toVisualColumn(Number.parseInt(physicalCol)) + hot.toVisualColumn(f.fastParseInt(physicalCol)) ), [] ); @@ -147,7 +206,7 @@ export function CoordinateConverter({ return value !== data[physicalRow][physicalCol]; }); if (changes.length > 0) { - changeCount.current += 1; + changeCountRef.current += 1; setHotData(hot, changes); } }, [ @@ -173,7 +232,9 @@ export function CoordinateConverter({ modal={false} onClose={(): void => { hot.batch(() => - Array.from({ length: changeCount.current }).forEach(() => hot.undo()) + Array.from({ length: changeCountRef.current }).forEach(() => + hot.undo() + ) ); handleClose(); }} diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/DevShowPlan.tsx b/specifyweb/frontend/js_src/lib/components/WbToolkit/DevShowPlan.tsx similarity index 61% rename from specifyweb/frontend/js_src/lib/components/WorkBench/DevShowPlan.tsx rename to specifyweb/frontend/js_src/lib/components/WbToolkit/DevShowPlan.tsx index 2d4e208d77e..6c6a7b1f97f 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/DevShowPlan.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbToolkit/DevShowPlan.tsx @@ -1,29 +1,70 @@ +/** + * Show upload plan as JSON + */ + import React from 'react'; +import { useBooleanState } from '../../hooks/useBooleanState'; import { commonText } from '../../localization/common'; import { wbPlanText } from '../../localization/wbPlan'; +import { wbText } from '../../localization/workbench'; import { Http } from '../../utils/ajax/definitions'; import { ping } from '../../utils/ajax/ping'; +import { overwriteReadOnly } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { LoadingContext } from '../Core/Contexts'; import { AutoGrowTextArea } from '../Molecules/AutoGrowTextArea'; import { Dialog } from '../Molecules/Dialog'; import { downloadFile } from '../Molecules/FilePicker'; import type { UploadPlan } from '../WbPlanView/uploadPlanParser'; +import type { Dataset } from '../WbPlanView/Wrapped'; -/** - * Show upload plan as JSON. Available in Development only - */ -export function DevShowPlan({ - dataSetId, - dataSetName: name, +export function WbRawPlan({ + dataset, + onDatasetDeleted: handleDatasetDeleted, + triggerDatasetRefresh, +}: { + readonly dataset: Dataset; + readonly onDatasetDeleted: () => void; + readonly triggerDatasetRefresh: () => void; +}): JSX.Element { + const [showRawPlan, openRawPlan, closeRawPlan] = useBooleanState(); + return ( + <> + + {wbText.uploadPlan()} + + {showRawPlan && ( + { + overwriteReadOnly(dataset, 'uploadplan', plan); + triggerDatasetRefresh(); + }} + onClose={closeRawPlan} + onDeleted={handleDatasetDeleted} + /> + )} + + ); +} + +function RawUploadPlan({ + datasetId, + datasetName: name, uploadPlan: rawPlan, onClose: handleClose, onChanged: handleChanged, onDeleted: handleDeleted, }: { - readonly dataSetId: number; - readonly dataSetName: string; + readonly datasetId: number; + readonly datasetName: string; readonly uploadPlan: UploadPlan; readonly onClose: () => void; readonly onChanged: (plan: UploadPlan) => void; @@ -49,7 +90,7 @@ export function DevShowPlan({ const plan = uploadPlan.length === 0 ? null : JSON.parse(uploadPlan); loading( - ping(`/api/workbench/dataset/${dataSetId}/`, { + ping(`/api/workbench/dataset/${datasetId}/`, { method: 'PUT', body: { uploadplan: plan }, expectedErrors: [Http.NOT_FOUND], diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/GeoLocate.tsx b/specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx similarity index 79% rename from specifyweb/frontend/js_src/lib/components/WorkBench/GeoLocate.tsx rename to specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx index d6e6a90a69f..5665ffbaebc 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/GeoLocate.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx @@ -1,7 +1,10 @@ import type Handsontable from 'handsontable'; import React from 'react'; +import { useBooleanState } from '../../hooks/useBooleanState'; import { commonText } from '../../localization/common'; +import { localityText } from '../../localization/locality'; +import { wbText } from '../../localization/workbench'; import { f } from '../../utils/functools'; import type { IR, RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; @@ -13,9 +16,62 @@ import { } from '../Leaflet/wbLocalityDataExtractor'; import type { GeoLocatePayload } from '../Molecules/GeoLocate'; import { GenericGeoLocate } from '../Molecules/GeoLocate'; -import { getSelectedRegions, getVisualHeaders, setHotData } from './hotHelpers'; +import type { Dataset } from '../WbPlanView/Wrapped'; +import { + getSelectedRegions, + getVisualHeaders, + setHotData, +} from '../WorkBench/hotHelpers'; +import type { WbMapping } from '../WorkBench/mapping'; export function WbGeoLocate({ + hasLocality, + hot, + dataset, + mappings, + isUploaded, + isResultsOpen, +}: { + readonly hasLocality: boolean; + readonly hot: Handsontable; + readonly dataset: Dataset; + readonly mappings: WbMapping | undefined; + readonly isUploaded: boolean; + readonly isResultsOpen: boolean; +}): JSX.Element { + const [showGeoLocate, openGeoLocate, closeGeoLocate] = useBooleanState(); + return ( + <> + + {localityText.geoLocate()} + + {showGeoLocate && mappings !== undefined ? ( + + ) : undefined} + + ); +} + +function GeoLocate({ hot, columns, localityColumns, @@ -31,13 +87,12 @@ export function WbGeoLocate({ const selection = React.useMemo( () => getSelectedLocalities(hot, columns, localityColumns, true), - [hot, columns, localityColumns] + [columns, localityColumns] ); function handleMove(newLocalityIndex: number): void { - if (selection === undefined) return; const { localityColumns, visualRow } = - selection.parseLocalityIndex(newLocalityIndex); + selection!.parseLocalityIndex(newLocalityIndex); setData(getGeoLocateData(hot, columns, { localityColumns, visualRow })); hot.selectRows(visualRow); setLocalityIndex(newLocalityIndex); @@ -49,8 +104,9 @@ export function WbGeoLocate({ ); React.useEffect(() => { - handleMove(0); - }, [hot, columns, localityColumns]); + if (selection === undefined) return; + else handleMove(0); + }, [columns, localityColumns]); const handleResult = React.useCallback( ({ latitude, longitude, uncertainty }: GeoLocatePayload) => { diff --git a/specifyweb/frontend/js_src/lib/components/WbToolkit/WbLeafletMap.tsx b/specifyweb/frontend/js_src/lib/components/WbToolkit/WbLeafletMap.tsx new file mode 100644 index 00000000000..f316a7c4c69 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbToolkit/WbLeafletMap.tsx @@ -0,0 +1,75 @@ +import type Handsontable from 'handsontable'; +import React from 'react'; + +import { useBooleanState } from '../../hooks/useBooleanState'; +import { localityText } from '../../localization/locality'; +import { wbText } from '../../localization/workbench'; +import { Button } from '../Atoms/Button'; +import { LeafletMap } from '../Leaflet/Map'; +import { getLocalitiesDataFromSpreadsheet } from '../Leaflet/wbLocalityDataExtractor'; +import type { Dataset } from '../WbPlanView/Wrapped'; +import { getSelectedLast, getVisualHeaders } from '../WorkBench/hotHelpers'; +import type { WbMapping } from '../WorkBench/mapping'; +import { getSelectedLocalities } from './GeoLocate'; + +export function WbLeafletMap({ + hasLocality, + hot, + dataset, + mappings, +}: { + readonly hasLocality: boolean; + readonly hot: Handsontable; + readonly dataset: Dataset; + readonly mappings: WbMapping | undefined; +}): JSX.Element { + const [showLeafletMap, openLeafletMap, closeLeafletMap] = useBooleanState(); + const localityPoints = React.useMemo(() => { + if (mappings === undefined) return undefined; + const selection = getSelectedLocalities( + hot, + dataset.columns, + mappings.localityColumns, + false + ); + + if (selection === undefined) return undefined; + + return getLocalitiesDataFromSpreadsheet( + mappings.localityColumns, + selection.visualRows.map((visualRow) => hot.getDataAtRow(visualRow)), + getVisualHeaders(hot, dataset.columns), + selection.visualRows + ); + }, [mappings?.localityColumns]); + + return ( + <> + + {localityText.geoMap()} + + {showLeafletMap && ( + { + if (localityPoints === undefined) return; + const rowNumber = localityPoints[localityPoint].rowNumber.value; + if (typeof rowNumber !== 'number') + throw new Error('rowNumber must be a number'); + const [_currentRow, currentCol] = getSelectedLast(hot); + hot.scrollViewportTo(rowNumber, currentCol); + // Select entire row + hot.selectRows(rowNumber); + }} + /> + )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/WbToolkit/index.tsx b/specifyweb/frontend/js_src/lib/components/WbToolkit/index.tsx new file mode 100644 index 00000000000..7186f342011 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbToolkit/index.tsx @@ -0,0 +1,125 @@ +import type Handsontable from 'handsontable'; +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { wbText } from '../../localization/workbench'; +import type { RA } from '../../utils/types'; +import { Button } from '../Atoms/Button'; +import { raise } from '../Errors/Crash'; +import { ErrorBoundary } from '../Errors/ErrorBoundary'; +import { hasPermission, hasTablePermission } from '../Permissions/helpers'; +import { userPreferences } from '../Preferences/userPreferences'; +import type { Dataset } from '../WbPlanView/Wrapped'; +import { downloadDataSet } from '../WorkBench/helpers'; +import type { WbMapping } from '../WorkBench/mapping'; +import { WbChangeOwner } from './ChangeOwner'; +import { WbConvertCoordinates } from './CoordinateConverter'; +import { WbRawPlan } from './DevShowPlan'; +import { WbGeoLocate } from './GeoLocate'; +import { WbLeafletMap } from './WbLeafletMap'; + +export function WbToolkit({ + dataset, + hot, + mappings, + isUploaded, + isResultsOpen, + data, + onDatasetDeleted: handleDatasetDeleted, + hasUnsavedChanges, + triggerDatasetRefresh, +}: { + readonly dataset: Dataset; + readonly hot: Handsontable; + readonly mappings: WbMapping | undefined; + readonly data: RA>; + readonly isUploaded: boolean; + readonly isResultsOpen: boolean; + readonly onDatasetDeleted: () => void; + readonly hasUnsavedChanges: boolean; + readonly triggerDatasetRefresh: () => void; +}): JSX.Element { + const handleExport = (): void => { + const delimiter = userPreferences.get( + 'workBench', + 'editor', + 'exportFileDelimiter' + ); + + downloadDataSet( + dataset.name, + dataset.rows, + dataset.columns, + delimiter + ).catch(raise); + }; + + const hasLocality = + mappings === undefined ? false : mappings.localityColumns.length > 0; + + return ( +
+ {hasPermission('/workbench/dataset', 'transfer') && + hasTablePermission('SpecifyUser', 'read') ? ( + + + + ) : undefined} + + + + + {commonText.export()} + + + {hasPermission('/workbench/dataset', 'update') && ( + <> + + + + + + + + )} + + + +
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/Navigation.tsx b/specifyweb/frontend/js_src/lib/components/WbUtils/Navigation.tsx new file mode 100644 index 00000000000..cd2e0f69eed --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/Navigation.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import type { LocalizedString } from 'typesafe-i18n'; + +import { useBooleanState } from '../../hooks/useBooleanState'; +import { wbText } from '../../localization/workbench'; +import { Button } from '../Atoms/Button'; +import { className } from '../Atoms/className'; +import { icons } from '../Atoms/Icons'; +import { ReadOnlyContext } from '../Core/Contexts'; +import type { WbCellCounts } from '../WorkBench/CellMeta'; +import type { WbUtils } from './Utils'; +import { StringToJsx } from '../../localization/utils'; +import { localized } from '../../utils/types'; + +export function Navigation({ + name, + label, + totalCount, + utils, + isPressed = false, + onToggle: handleToggle, +}: { + readonly name: keyof WbCellCounts; + readonly label: LocalizedString; + readonly totalCount: number; + readonly utils: WbUtils; + readonly isPressed?: boolean; + readonly onToggle?: () => void; +}): JSX.Element { + const isReadOnly = React.useContext(ReadOnlyContext); + const [currentPosition, setCurrentPosition] = React.useState(0); + const [buttonIsPressed, _press, _unpress, togglePress] = + useBooleanState(isPressed); + + const handleTypeToggle = () => { + if (typeof handleToggle === 'function') handleToggle(); + else togglePress(); + utils.toggleCellTypes(name, 'toggle'); + }; + + const handlePrevious = () => { + const [_, position] = utils.navigateCells({ + type: name, + direction: 'previous', + currentCellPosition: currentPosition, + totalCount, + }); + setCurrentPosition(position); + }; + const handleNext = () => { + const [_, position] = utils.navigateCells({ + type: name, + direction: 'next', + currentCellPosition: currentPosition, + totalCount, + }); + setCurrentPosition(position); + }; + + // Reset current position when total count resets + React.useEffect(() => { + if (totalCount === 0) setCurrentPosition(0); + }, [totalCount]); + + return ( + + + {icons.chevronLeft} + + + {currentPosition} + ), + totalCount: {totalCount}, + }} + string={localized(`${label} (/)`)} + /> + + + {icons.chevronRight} + + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/Utils.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/Utils.ts new file mode 100644 index 00000000000..9c8b45ba327 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/Utils.ts @@ -0,0 +1,445 @@ +/** + * Workbench Utilities: + * Search & Replace, GeoMap, GeoLocate, Coordinate Convertor + * + * @module + * + */ + +import type React from 'react'; + +import { f } from '../../utils/functools'; +import type { RA, WritableArray } from '../../utils/types'; +import { camelToKebab } from '../../utils/utils'; +import type { WbSearchPreferences } from '../WorkBench/AdvancedSearch'; +import { getInitialSearchPreferences } from '../WorkBench/AdvancedSearch'; +import type { WbCellCounts } from '../WorkBench/CellMeta'; +import { getHotPlugin } from '../WorkBench/handsontable'; +import { getSelectedLast } from '../WorkBench/hotHelpers'; +import type { Workbench } from '../WorkBench/WbView'; + +const HOT_OFFSET = 3; + +/* eslint-disable functional/no-this-expression */ +export class WbUtils { + // eslint-disable-next-line functional/prefer-readonly-type + public searchQuery: RegExp | string | undefined = undefined; + + // eslint-disable-next-line functional/prefer-readonly-type + private rawSearchQuery: string | undefined = undefined; + + // eslint-disable-next-line functional/prefer-readonly-type + public searchPreferences: WbSearchPreferences = getInitialSearchPreferences(); + + public constructor( + private readonly workbench: Workbench, + private readonly spreadsheetContainerRef: React.RefObject + ) {} + + public navigateCells({ + type, + direction, + currentCellPosition = 0, + totalCount = 0, + matchCurrentCell = false, + currentCell = undefined, + }: { + readonly type: keyof WbCellCounts; + readonly direction: 'next' | 'previous'; + readonly currentCellPosition?: number; + readonly totalCount?: number; + // If true and current cell is of correct type, don't navigate away + readonly matchCurrentCell?: boolean; + /* + * Overwrite what is considered to be a current cell + * Setting to [0,0] and matchCurrentCell=true allows navigation to the first + * cell of type (used on hitting "Enter" in the Search Box) + */ + readonly currentCell?: readonly [number, number] | undefined; + }): + | readonly [ + { + readonly visualRow: number; + readonly visualCol: number; + }, + number + ] + | readonly [undefined, number] { + const cellMetaObject = this.workbench.cells.getCellMetaObject(); + /* + * The cellMetaObject is transposed if navigation direction is "Column + * first". + * In that case, the meaning of visualRow and visualCol is swapped. + * resolveIndex exists to resolve the canonical visualRow/visualCol + */ + const resolveIndex = ( + visualRow: number, + visualCol: number, + first: boolean + ): number => + (this.searchPreferences.navigation.direction === 'rowFirst') === first + ? visualRow + : visualCol; + + const [currentRow, currentCol] = + currentCell ?? getSelectedLast(this.workbench.hot!); + + const [currentTransposedRow, currentTransposedCol] = [ + resolveIndex(currentRow, currentCol, true), + resolveIndex(currentRow, currentCol, false), + ]; + + const compareRows = + direction === 'next' + ? (visualRow: number) => visualRow >= currentTransposedRow + : (visualRow: number) => visualRow <= currentTransposedRow; + + const compareCols = + direction === 'next' + ? matchCurrentCell + ? (visualCol: number) => visualCol >= currentTransposedCol + : (visualCol: number) => visualCol > currentTransposedCol + : matchCurrentCell + ? (visualCol: number) => visualCol <= currentTransposedCol + : (visualCol: number) => visualCol < currentTransposedCol; + + let matchedCell: + | { + readonly visualRow: number; + readonly visualCol: number; + } + | undefined; + let cellIsTypeCount = 0; + + const orderIt = + direction === 'next' + ? f.id + : (array: RA): RA => Array.from(array).reverse(); + + orderIt(Object.entries(cellMetaObject)).find(([visualRowString, metaRow]) => + typeof metaRow === 'object' + ? orderIt(Object.entries(metaRow)).find( + ([visualColString, metaArray]) => { + /* + * This is 10 times faster then Number.parseInt because of a slow + * Babel polyfill + */ + const visualRow = f.fastParseInt(visualRowString); + const visualCol = f.fastParseInt(visualColString); + + const cellTypeMatches = this.workbench.cells?.cellIsType( + metaArray, + type + ); + cellIsTypeCount += cellTypeMatches ? 1 : 0; + + const isWithinBounds = + compareRows(visualRow) && + (visualRow !== currentTransposedRow || compareCols(visualCol)); + + const matches = cellTypeMatches && isWithinBounds; + if (matches) + matchedCell = { + visualRow: resolveIndex(visualRow, visualCol, true), + visualCol: resolveIndex(visualRow, visualCol, false), + }; + return matches; + } + ) + : undefined + ); + + let cellRelativePosition; + if (matchedCell === undefined) cellRelativePosition = 0; + else if (direction === 'next') cellRelativePosition = cellIsTypeCount; + else cellRelativePosition = totalCount - cellIsTypeCount + 1; + + const boundaryCell = direction === 'next' ? totalCount : 1; + + let finalCellPosition = currentCellPosition; + if ( + cellRelativePosition !== 0 || + currentCellPosition !== boundaryCell || + totalCount === 0 + ) + finalCellPosition = cellRelativePosition; + + if (matchedCell === undefined) return [undefined, finalCellPosition]; + + this.workbench.hot?.selectCell( + matchedCell.visualRow, + matchedCell.visualCol + ); + + // Turn on the respective cell type if it was hidden + this.toggleCellTypes(type, 'remove'); + + return [matchedCell, finalCellPosition]; + } + + public searchCells( + event: + | React.KeyboardEvent + | { readonly key: 'SettingsChange' }, + searchQueryElement: HTMLInputElement | null + ): void { + if (this.workbench.hot === undefined || searchQueryElement === null) return; + /* + * Don't rerun search on live search if search query did not change + * (e.x, if Ctrl/Cmd+A is clicked in the search box) + */ + if ( + searchQueryElement.value === this.rawSearchQuery && + !['SettingsChange', 'Enter'].includes(event.key) + ) + return; + + // Don't handle onKeyDown event if live search is disabled + if (event.key !== 'Enter' && !this.searchPreferences.search.liveUpdate) + return; + + if (this.parseSearchQuery(searchQueryElement) === undefined) { + this.toggleCellTypes('searchResults', 'add'); + return; + } + this.toggleCellTypes('searchResults', 'remove'); + + const data = this.workbench.dataset.rows; + const firstVisibleRow = + getHotPlugin(this.workbench.hot, 'autoRowSize').getFirstVisibleRow() - + HOT_OFFSET; + const lastVisibleRow = + getHotPlugin(this.workbench.hot, 'autoRowSize').getLastVisibleRow() + + HOT_OFFSET; + const firstVisibleColumn = + getHotPlugin( + this.workbench.hot, + 'autoColumnSize' + ).getFirstVisibleColumn() - HOT_OFFSET; + const lastVisibleColumn = + getHotPlugin( + this.workbench.hot, + 'autoColumnSize' + ).getLastVisibleColumn() + HOT_OFFSET; + + for (let visualRow = 0; visualRow < data.length; visualRow++) { + const physicalRow = this.workbench.hot.toPhysicalRow(visualRow); + for ( + let visualCol = 0; + visualCol < this.workbench.dataset.columns.length; + visualCol++ + ) { + const physicalCol = this.workbench.hot.toPhysicalColumn(visualCol); + const isSearchResult = this.searchFunction( + (data[physicalRow][physicalCol] || + this.workbench.mappings?.defaultValues[physicalCol]) ?? + '' + ); + + let cell = undefined; + let render = false; + + /* + * Calling hot.getCell only if cell is within the render + * bounds. + * While hot.getCell is supposed to check for this too, doing it this + * way makes search about 25% faster + */ + if ( + firstVisibleRow <= visualRow && + lastVisibleRow >= visualRow && + firstVisibleColumn <= visualCol && + lastVisibleColumn >= visualCol + ) { + cell = this.workbench.hot.getCell(visualRow, visualCol) ?? undefined; + render = Boolean(cell); + } + + this.workbench.cells[render ? 'updateCellMeta' : 'setCellMeta']( + physicalRow, + physicalCol, + 'isSearchResult', + isSearchResult, + { + cell, + visualRow, + visualCol, + } + ); + } + } + + this.workbench.cells.updateCellInfoStats(); + + // Navigate to the first search result when hitting Enter + if (event.key === 'Enter') + this.navigateCells({ + type: 'searchResults', + direction: 'next', + matchCurrentCell: event.key === 'Enter', + currentCell: event.key === 'Enter' ? [0, 0] : undefined, + }); + } + + private parseSearchQuery(searchQueryElement: HTMLInputElement) { + if (searchQueryElement === null) return; + + this.rawSearchQuery = searchQueryElement.value; + + this.searchQuery = this.searchPreferences.search.useRegex + ? this.rawSearchQuery + : this.rawSearchQuery.trim(); + + if (this.searchQuery === '') { + this.searchQuery = undefined; + return; + } + + if (this.searchPreferences.search.useRegex) + try { + if (this.searchPreferences.search.fullMatch) { + if (!this.searchQuery.startsWith('^')) + this.searchQuery = `^${this.searchQuery}`; + if (!this.searchQuery.endsWith('$')) + this.searchQuery = `${this.searchQuery}$`; + } + // Regex may be coming from the user, thus disable strict mode + + this.searchQuery = new RegExp( + this.searchQuery, + this.searchPreferences.search.caseSensitive ? '' : 'i' + ); + } catch (error) { + searchQueryElement.setCustomValidity((error as SyntaxError).message); + searchQueryElement.reportValidity(); + this.searchQuery = undefined; + return; + } + else if (!this.searchPreferences.search.caseSensitive) + this.searchQuery = this.searchQuery.toLowerCase(); + + searchQueryElement.setCustomValidity(''); + + return this.searchQuery; + } + + public toggleCellTypes( + navigationType: keyof WbCellCounts, + action: 'add' | 'remove' | 'toggle' = 'toggle' + ): void { + const groupName = camelToKebab(navigationType); + const cssClassName = `wb-hide-${groupName}`; + this.spreadsheetContainerRef?.current?.classList[action](cssClassName); + } + + public searchFunction(initialCellValue = ''): boolean { + let cellValue = initialCellValue; + + if (this.searchQuery === undefined) return false; + + if (!this.searchPreferences.search.caseSensitive) + cellValue = cellValue.toLowerCase(); + + if (this.searchPreferences.search.useRegex) + return cellValue.search(this.searchQuery) !== -1; + + return this.searchPreferences.search.fullMatch + ? cellValue === this.searchQuery + : cellValue.includes(this.searchQuery as string); + } + + public replaceCells( + event: React.KeyboardEvent, + replacementValueElement: HTMLInputElement | null + ): void { + if ( + event.key !== 'Enter' || + (this.searchPreferences.search.useRegex && + this.searchQuery === undefined) || + this.workbench.hot === undefined || + replacementValueElement === null + ) + return; + + const replacementValue = this.searchPreferences.search.useRegex + ? replacementValueElement.value + : replacementValueElement.value.trim(); + + const getNewCellValue = this.searchPreferences.search.fullMatch + ? (): string => replacementValue + : (cellValue: string): string => + this.searchPreferences.search.useRegex + ? cellValue.replaceAll( + // Regex may be coming from the user, thus disable strict mode + // eslint-disable-next-line require-unicode-regexp + new RegExp(this.searchQuery!, 'g'), + replacementValue + ) + : cellValue.split(this.searchQuery ?? '').join(replacementValue); + + if (this.searchPreferences.replace.replaceMode === 'replaceAll') { + // eslint-disable-next-line functional/prefer-readonly-type + const modifications: WritableArray<[number, number, string]> = []; + Object.entries(this.workbench.cells.cellMeta).forEach( + ([physicalRow, metaRow]) => + Object.entries(metaRow).forEach(([physicalCol, metaArray]) => { + if ( + !this.workbench.cells.getCellMetaFromArray( + metaArray, + 'isSearchResult' + ) + ) + return; + const visualRow = this.workbench.hot!.toVisualRow( + f.fastParseInt(physicalRow) + ); + const visualCol = this.workbench.hot!.toVisualColumn( + f.fastParseInt(physicalCol) + ); + const cellValue = + this.workbench.hot!.getDataAtCell(visualRow, visualCol) || ''; + // Don't replace cells with default values + if (cellValue === '') return; + modifications.push([ + visualRow, + visualCol, + getNewCellValue(cellValue), + ]); + }) + ); + this.workbench.hot.setDataAtCell(modifications); + } else { + const nextCellOfType = () => + this.navigateCells({ + type: 'searchResults', + direction: 'next', + matchCurrentCell: false, + }); + const [currentRow, currentCol] = getSelectedLast(this.workbench.hot); + const physicalRow = this.workbench.hot.toPhysicalRow(currentRow); + const physicalCol = this.workbench.hot.toPhysicalColumn(currentCol); + let nextCell = [currentRow, currentCol] as const; + if ( + !this.workbench.cells.cellIsType( + this.workbench.cells.cellMeta[physicalRow]?.[physicalCol], + 'searchResults' + ) + ) { + const [next, _] = nextCellOfType(); + if (typeof next === 'object') { + const { visualRow, visualCol } = next; + nextCell = [visualRow, visualCol]; + } + } + + if (!Array.isArray(nextCell)) return; + + this.workbench.hot.setDataAtCell( + ...nextCell, + getNewCellValue(this.workbench.hot.getDataAtCell(...nextCell)) + ); + + nextCellOfType(); + } + } +} diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/index.tsx b/specifyweb/frontend/js_src/lib/components/WbUtils/index.tsx new file mode 100644 index 00000000000..d4e8e0bfb73 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/index.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import _ from 'underscore'; + +import { useBooleanState } from '../../hooks/useBooleanState'; +import { commonText } from '../../localization/common'; +import { wbText } from '../../localization/workbench'; +import { Input } from '../Atoms/Form'; +import { ReadOnlyContext } from '../Core/Contexts'; +import { hasPermission } from '../Permissions/helpers'; +import { WbAdvancedSearch } from '../WorkBench/AdvancedSearch'; +import type { WbCellCounts, WbCellMeta } from '../WorkBench/CellMeta'; +import { Navigation } from './Navigation'; +import type { WbUtils } from './Utils'; + +export function WbUtilsComponent({ + isUploaded, + cellCounts, + utils, + cells, + debounceRate, + searchRef, +}: { + readonly isUploaded: boolean; + readonly cellCounts: WbCellCounts; + readonly utils: WbUtils; + readonly cells: WbCellMeta; + readonly debounceRate: number; + readonly searchRef: React.MutableRefObject; +}): JSX.Element { + const isReadOnly = React.useContext(ReadOnlyContext); + const replaceRef = React.useRef(null); + + const [isSearchClicked, clickSearch, unclickSearch, toggleSearch] = + useBooleanState(true); + + const handleSearch = React.useCallback( + _.debounce( + (event: React.KeyboardEvent) => { + if (searchRef.current && searchRef.current?.value.length > 0) + unclickSearch(); + else clickSearch(); + utils.searchCells(event, searchRef.current); + }, + debounceRate, + false + ), + [debounceRate] + ); + + return ( +
+ +
+ +
+ {!isUploaded && hasPermission('/workbench/dataset', 'update') ? ( +
+ + utils.replaceCells(event, replaceRef.current) + } + /> +
+ ) : undefined} + { + if ( + newSearchPreferences.navigation.direction !== + utils.searchPreferences.navigation.direction + ) { + cells.indexedCellMeta = undefined; + } + utils.searchPreferences = newSearchPreferences; + if ( + utils.searchPreferences.search.liveUpdate && + searchRef.current !== null + ) + utils.searchCells( + { + key: 'SettingsChange', + }, + searchRef.current + ); + }} + /> +
+ + {!isUploaded && hasPermission('/workbench/dataset', 'update') ? ( + + ) : undefined} + + {!isUploaded && ( + + )} +
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/CellMeta.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/CellMeta.ts index a96c3d2f9fa..b83586c3f72 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/CellMeta.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/CellMeta.ts @@ -1,9 +1,9 @@ import { backEndText } from '../../localization/backEnd'; -import { wbText } from '../../localization/workbench'; +import { f } from '../../utils/functools'; import type { RA, WritableArray } from '../../utils/types'; -import { throttle } from '../../utils/utils'; +import { SET, throttle } from '../../utils/utils'; import { getHotPlugin } from './handsontable'; -import type { WbView } from './WbView'; +import type { Workbench } from './WbView'; const metaKeys = [ 'isNew', @@ -31,11 +31,11 @@ export type WbCellCounts = { // REFACTOR: replace usages of WbMetaArray with WbMeta and test performance/memory // eslint-disable-next-line functional/prefer-readonly-type export type WbMetaArray = [ - boolean, - boolean, - boolean, - RA, - string | undefined + isNew: boolean, + isModified: boolean, + isSearchResult: boolean, + issues: RA, + originalValue: string | undefined ]; const defaultMetaValues = Object.freeze([ @@ -54,19 +54,16 @@ export class WbCellMeta { // Meta data for each cell (indexed by visual columns) // eslint-disable-next-line functional/prefer-readonly-type - private indexedCellMeta: RA> | undefined = undefined; + public indexedCellMeta: RA> | undefined = undefined; - // eslint-disable-next-line functional/prefer-readonly-type - public flushIndexedCellData: boolean = true; - - public constructor(private readonly wbView: WbView) { + public constructor(private readonly workbench: Workbench) { this.updateCellInfoStats = throttle( this.updateCellInfoStats.bind(this), - this.wbView.throttleRate + this.workbench.throttleRate ); } - getCellMeta( + public getCellMeta( physicalRow: number, physicalCol: number, key: KEY @@ -76,7 +73,7 @@ export class WbCellMeta { defaultMetaValues[index]) as unknown as WbMeta[KEY]; } - getCellMetaFromArray( + public getCellMetaFromArray( metaCell: WbMetaArray, key: KEY ): WbMeta[KEY] { @@ -88,14 +85,13 @@ export class WbCellMeta { * This does not run visual side effects * For changing meta with side effects, use this.updateCellMeta */ - setCellMeta( + public setCellMeta( physicalRow: number, physicalCol: number, key: KEY, value: WbMeta[KEY] ) { const currentValue = this.getCellMeta(physicalRow, physicalCol, key); - const issuesChanged = key === 'issues' && ((currentValue as RA).length !== (value as RA).length || @@ -116,7 +112,7 @@ export class WbCellMeta { ) as unknown as WbMetaArray; this.cellMeta[physicalRow][physicalCol][index] = value; - this.flushIndexedCellData = true; + this.indexedCellMeta = undefined; return true; } @@ -149,14 +145,14 @@ export class WbCellMeta { const cellValueChanged = originalCellValue !== undefined && (originalCellValue?.toString() ?? '') !== - (this.wbView.data[physicalRow][physicalCol]?.toString() ?? ''); + (this.workbench.data[physicalRow][physicalCol]?.toString() ?? ''); if (cellValueChanged) return true; /* * If cell was disambiguated, it should show up as changed, even if value * is unchanged */ - return this.wbView.disambiguation.cellWasDisambiguated( + return this.workbench.disambiguation?.cellWasDisambiguated( physicalRow, physicalCol ); @@ -168,21 +164,19 @@ export class WbCellMeta { public recalculateIsModifiedState( physicalRow: number, physicalCol: number, - { - // Can optionally provide this to improve performance - visualRow = undefined, - // Can optionally provide this to improve performance - visualCol = undefined, - }: { + visualIndexes: { readonly visualRow?: number; readonly visualCol?: number; } = {} ): void { const isModified = this.isCellModified(physicalRow, physicalCol); - this.updateCellMeta(physicalRow, physicalCol, 'isModified', isModified, { - visualRow, - visualCol, - }); + this.updateCellMeta( + physicalRow, + physicalCol, + 'isModified', + isModified, + visualIndexes + ); } /** @@ -190,14 +184,14 @@ export class WbCellMeta { * This is the case when rendering off-screen issues (which don't require a cell * reference) */ - runMetaUpdateEffects( + public runMetaUpdateEffects( cell: HTMLTableCellElement | undefined, key: KEY, value: WbMeta[KEY], visualRow: number, visualCol: number ) { - if (this.wbView.hot === undefined) return; + if (this.workbench.hot === undefined) return; if (key === 'isNew') cell?.classList[value === true ? 'add' : 'remove']('wb-no-match-cell'); @@ -210,17 +204,17 @@ export class WbCellMeta { else if (key === 'issues') { const issues = value as RA; if (issues.length === 0) - getHotPlugin(this.wbView.hot, 'comments').removeCommentAtCell( + getHotPlugin(this.workbench.hot, 'comments').removeCommentAtCell( visualRow, visualCol ); else { - getHotPlugin(this.wbView.hot, 'comments').setCommentAtCell( + getHotPlugin(this.workbench.hot, 'comments').setCommentAtCell( visualRow, visualCol, issues.join('\n') ); - getHotPlugin(this.wbView.hot, 'comments').updateCommentMeta( + getHotPlugin(this.workbench.hot, 'comments').updateCommentMeta( visualRow, visualCol, { @@ -237,7 +231,7 @@ export class WbCellMeta { ); } - updateCellMeta( + public updateCellMeta( physicalRow: number, physicalCol: number, key: KEY, @@ -255,7 +249,7 @@ export class WbCellMeta { readonly visualCol?: number; } = {} ) { - if (this.wbView.hot === undefined) return; + if (this.workbench.hot === undefined) return; const isValueChanged = this.setCellMeta( physicalRow, physicalCol, @@ -265,10 +259,11 @@ export class WbCellMeta { if (!isValueChanged) return false; const visualRow = - initialVisualRow ?? this.wbView.hot.toVisualRow(physicalRow); + initialVisualRow ?? this.workbench.hot.toVisualRow(physicalRow); const visualCol = - initialVisualCol ?? this.wbView.hot.toVisualColumn(physicalCol); - const cell = initialCell ?? this.wbView.hot.getCell(visualRow, visualCol); + initialVisualCol ?? this.workbench.hot.toVisualColumn(physicalCol); + const cell = + initialCell ?? this.workbench.hot.getCell(visualRow, visualCol); this.runMetaUpdateEffects( cell ?? undefined, key, @@ -289,18 +284,18 @@ export class WbCellMeta { * Also, if navigation direction is set to ColByCol, the resulting array * is transposed. * - * this.flushIndexedCellData is set to true whenever visual indexes change + * this.indexedCellMeta is set to undefined whenever visual indexes change * */ public getCellMetaObject(): RA> { - if (this.flushIndexedCellData || this.indexedCellMeta === undefined) { - if (this.wbView.hot === undefined) return []; + if (this.indexedCellMeta === undefined) { + if (this.workbench.hot === undefined) return []; const resolveIndex = ( visualRow: number, visualCol: number, first: boolean ) => - (this.wbView.wbUtils.searchPreferences.navigation.direction === + (this.workbench.utils.searchPreferences.navigation.direction === 'rowFirst') === first ? visualRow @@ -309,11 +304,11 @@ export class WbCellMeta { const indexedCellMeta: WritableArray> = []; Object.entries(this.cellMeta).forEach(([physicalRow, metaRow]) => Object.entries(metaRow).forEach(([physicalCol, cellMeta]) => { - const visualRow = this.wbView.hot!.toVisualRow( - (physicalRow as unknown as number) | 0 + const visualRow = this.workbench.hot!.toVisualRow( + f.fastParseInt(physicalRow) ); - const visualCol = this.wbView.hot!.toVisualColumn( - (physicalCol as unknown as number) | 0 + const visualCol = this.workbench.hot!.toVisualColumn( + f.fastParseInt(physicalCol) ); indexedCellMeta[resolveIndex(visualRow, visualCol, true)] ??= []; indexedCellMeta[resolveIndex(visualRow, visualCol, true)][ @@ -323,15 +318,14 @@ export class WbCellMeta { ); this.indexedCellMeta = indexedCellMeta; } - this.flushIndexedCellData = false; return this.indexedCellMeta; } // MetaData - updateCellInfoStats() { + public updateCellInfoStats() { const cellMeta = this.cellMeta.flat(); - const cellCounts: WbCellCounts = { + this.workbench.cellCounts[SET]({ newCells: cellMeta.reduce( (count, info) => count + (this.getCellMetaFromArray(info, 'isNew') ? 1 : 0), @@ -353,47 +347,7 @@ export class WbCellMeta { count + (this.getCellMetaFromArray(info, 'isModified') ? 1 : 0), 0 ), - }; - - // Update navigation information - Object.values( - this.wbView.el.getElementsByClassName('wb-navigation-total') - ).forEach((navigationTotalElement) => { - const navigationContainer = navigationTotalElement.closest( - '.wb-navigation-section' - ); - if (navigationContainer === null) return; - const navigationType = navigationContainer.getAttribute( - 'data-navigation-type' - ) as keyof WbCellCounts | null; - if (navigationType === null) return; - navigationTotalElement.textContent = - cellCounts[navigationType]?.toString(); - - if (cellCounts[navigationType] === 0) { - const currentPositionElement = - navigationContainer.getElementsByClassName( - 'wb-navigation-position' - )?.[0]; - if (typeof currentPositionElement === 'object') - currentPositionElement.textContent = '0'; - } }); - - const uploadButton = - this.wbView.el.querySelector('.wb-upload'); - if (uploadButton === null) return; - const title = wbText.uploadUnavailableWhileHasErrors(); - if ( - !uploadButton.disabled || - uploadButton.getAttribute('title') === title - ) { - const hasErrors = cellCounts.invalidCells > 0; - uploadButton.toggleAttribute('disabled', hasErrors); - uploadButton.setAttribute('title', hasErrors ? title : ''); - } - - this.wbView.actions.operationCompletedMessage(cellCounts); } public cellIsType(metaArray: WbMetaArray, type: keyof WbCellCounts): boolean { diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx index ed46be83fe2..83d67f74f03 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx @@ -1,31 +1,24 @@ +import type Handsontable from 'handsontable'; import React from 'react'; import type { LocalizedString } from 'typesafe-i18n'; -import { useAsyncState } from '../../hooks/useAsyncState'; import { useBooleanState } from '../../hooks/useBooleanState'; import { useId } from '../../hooks/useId'; import { commonText } from '../../localization/common'; import { StringToJsx } from '../../localization/utils'; import { wbText } from '../../localization/workbench'; import { Http } from '../../utils/ajax/definitions'; -import { formData } from '../../utils/ajax/helpers'; import { ping } from '../../utils/ajax/ping'; -import type { RA } from '../../utils/types'; -import { defined, localized, overwriteReadOnly } from '../../utils/types'; +import { localized, overwriteReadOnly } from '../../utils/types'; import { Button } from '../Atoms/Button'; -import { Form, Input, Label, Select } from '../Atoms/Form'; +import { Form, Input, Label } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; import { formatNumber } from '../Atoms/Internationalization'; import { Submit } from '../Atoms/Submit'; import type { EagerDataSet } from '../AttachmentsBulkImport/Import'; import { LoadingContext } from '../Core/Contexts'; -import { Backbone } from '../DataModel/backbone'; -import { fetchCollection } from '../DataModel/collection'; import { getField } from '../DataModel/helpers'; -import type { SerializedResource } from '../DataModel/helperTypes'; import { tables } from '../DataModel/tables'; -import type { SpecifyUser } from '../DataModel/types'; -import { userInformation } from '../InitialContext/userInformation'; import { useTitle } from '../Molecules/AppTitle'; import { AutoGrowTextArea } from '../Molecules/AutoGrowTextArea'; import { DateElement } from '../Molecules/DateElement'; @@ -350,21 +343,25 @@ export function DataSetMeta({ ); } -function DataSetName({ +export function DataSetName({ dataset, - getRowCount, + hot, }: { readonly dataset: Dataset; - readonly getRowCount: () => number; + readonly hot: Handsontable | undefined; }): JSX.Element { const [showMeta, handleOpen, handleClose] = useBooleanState(); const [name, setName] = React.useState(dataset.name); + const getRowCount = () => + hot === undefined + ? dataset.rows.length + : hot.countRows() - hot.countEmptyRows(true); + useTitle(name); return ( <> - {' '}

{dataset.uploadplan !== null && ( @@ -397,133 +394,3 @@ function DataSetName({ ); } - -const fetchListOfUsers = async (): Promise< - RA> -> => - fetchCollection('SpecifyUser', { limit: 500, domainFilter: false }).then( - ({ records: users }) => users.filter(({ id }) => id !== userInformation.id) - ); - -function ChangeOwner({ - dataset, - onClose: handleClose, -}: { - readonly dataset: Dataset; - readonly onClose: () => void; -}): JSX.Element | null { - const [users] = useAsyncState>>( - fetchListOfUsers, - true - ); - - const id = useId('change-data-set-owner'); - const [newOwner, setNewOwner] = React.useState(undefined); - const [isChanged, setIsChanged] = React.useState(false); - const loading = React.useContext(LoadingContext); - - return users === undefined ? null : isChanged ? ( - unsafeNavigate('/specify/', { replace: true })} - > -

{wbText.dataSetOwnerChanged()}

-
- ) : ( - - {commonText.cancel()} - - {wbText.changeOwner()} - - - } - header={wbText.changeDataSetOwner()} - onClose={handleClose} - > -
- loading( - ping(`/api/workbench/transfer/${dataset.id}/`, { - method: 'POST', - body: formData({ - specifyuserid: newOwner!, - }), - }).then(() => setIsChanged(true)) - ) - } - > - -

{wbText.changeDataSetOwnerDescription()}

- -
-
-
- ); -} - -// A wrapper for DS Meta for embedding in the WB -export class DataSetNameView extends Backbone.View { - // eslint-disable-next-line functional/prefer-readonly-type - private dataSetMeta: (() => void) | undefined; - - // eslint-disable-next-line functional/prefer-readonly-type - private changeOwnerView: (() => void) | undefined; - - public constructor( - private readonly options: { - readonly dataset: Dataset; - readonly el: HTMLElement; - readonly display: ( - jsx: JSX.Element, - element?: HTMLElement, - destructor?: () => void - ) => () => void; - readonly getRowCount: () => number; - } - ) { - super(options); - } - - public render(): this { - const nameContainer = - this.options.el.getElementsByClassName('wb-name-container')?.[0]; - this.dataSetMeta = this.options.display( - , - defined(nameContainer, 'Unable to find Wb Name container') as HTMLElement - ); - return this; - } - - public changeOwner(): void { - const handleClose = (): void => void this.changeOwnerView?.(); - this.changeOwnerView = this.options.display( - - ); - } - - public remove(): this { - this.dataSetMeta?.(); - this.changeOwnerView?.(); - Backbone.View.prototype.remove.call(this); - return this; - } -} diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/Disambiguation.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/Disambiguation.tsx index ee47da643e5..9c86a809c42 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/Disambiguation.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/Disambiguation.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { useAsyncState } from '../../hooks/useAsyncState'; import { commonText } from '../../localization/common'; import { wbText } from '../../localization/workbench'; -import type { RA } from '../../utils/types'; +import type { RA, WritableArray } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { Input, Label } from '../Atoms/Form'; import type { AnySchema } from '../DataModel/helperTypes'; @@ -16,12 +16,14 @@ import { hasTablePermission } from '../Permissions/helpers'; export function DisambiguationDialog({ matches, + liveValidationStack, defaultResource, onSelected: handleSelected, onSelectedAll: handleSelectedAll, onClose: handleClose, }: { readonly matches: RA>; + readonly liveValidationStack?: WritableArray; readonly defaultResource?: SpecifyResource; readonly onSelected: (resource: SpecifyResource) => void; readonly onSelectedAll: (resource: SpecifyResource) => void; @@ -46,7 +48,14 @@ export function DisambiguationDialog({ {commonText.apply()} { handleSelectedAll(selected!); handleClose(); diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/DisambiguationLogic.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/DisambiguationLogic.ts new file mode 100644 index 00000000000..15a9f608e66 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/DisambiguationLogic.ts @@ -0,0 +1,120 @@ +import type { IR } from '../../utils/types'; +import type { MappingPath } from '../WbPlanView/Mapper'; +import { mappingPathToString } from '../WbPlanView/mappingHelpers'; +import { getSelectedLast } from './hotHelpers'; +import type { Workbench } from './WbView'; + +/* eslint-disable functional/no-this-expression */ +export class Disambiguation { + public constructor(private readonly workbench: Workbench) {} + + public getDisambiguation(physicalRow: number): IR { + const cols = this.workbench.dataset.columns.length; + const hiddenColumn = this.workbench.data[physicalRow][cols]; + const extra = + typeof hiddenColumn === 'string' && hiddenColumn.length > 0 + ? JSON.parse(hiddenColumn) + : {}; + return extra.disambiguation ?? {}; + } + + public isAmbiguousCell(): boolean { + if ( + this.workbench.mappings === undefined || + this.workbench.hot === undefined + ) + return false; + + const [visualRow, visualCol] = getSelectedLast(this.workbench.hot); + const physicalRow = this.workbench.hot.toPhysicalRow(visualRow); + const physicalCol = this.workbench.hot.toPhysicalColumn(visualCol); + const disambiguation = this.getDisambiguation(physicalRow); + + return ( + this.workbench.validation?.uploadResults.ambiguousMatches[physicalRow] ?? + [] + ).some( + ({ physicalCols, mappingPath }) => + physicalCols.includes(physicalCol) && + typeof disambiguation[mappingPathToString(mappingPath)] !== 'number' + ); + } + + public cellWasDisambiguated( + physicalRow: number, + physicalCol: number + ): boolean { + const da = this.getDisambiguation(physicalRow); + return Boolean( + this.workbench.validation?.uploadResults.ambiguousMatches[ + physicalRow + ]?.find( + ({ physicalCols, mappingPath }) => + physicalCols.includes(physicalCol) && + typeof da[mappingPathToString(mappingPath)] === 'number' + ) + ); + } + + private changeDisambiguation( + physicalRow: number, + changeFunction: (oldValue: IR) => IR, + source: 'Disambiguation.Clear' | 'Disambiguation.Set' + ): void { + if (this.workbench.hot === undefined) return; + const cols = this.workbench.dataset.columns.length; + const hidden = this.workbench.data[physicalRow][cols]; + const extra = hidden ? JSON.parse(hidden) : {}; + extra.disambiguation = changeFunction(extra.disambiguation || {}); + const visualRow = this.workbench.hot.toVisualRow(physicalRow); + const visualCol = this.workbench.hot.toVisualColumn(cols); + this.workbench.hot.setDataAtCell( + visualRow, + visualCol, + JSON.stringify(extra), + source + ); + this.workbench.spreadsheetChanged(); + this.afterChangeDisambiguation(physicalRow); + } + + public afterChangeDisambiguation(physicalRow: number): void { + ( + this.workbench.validation?.uploadResults.ambiguousMatches[physicalRow] ?? + [] + ) + .flatMap(({ physicalCols }) => physicalCols) + .forEach((physicalCol) => + this.workbench.cells?.recalculateIsModifiedState( + physicalRow, + physicalCol + ) + ); + this.workbench.cells?.updateCellInfoStats(); + } + + public clearDisambiguation(physicalRow: number): void { + const disambiguation = this.getDisambiguation(physicalRow); + if (Object.keys(disambiguation).length === 0) + // Nothing to clear + return; + this.changeDisambiguation(physicalRow, () => ({}), 'Disambiguation.Clear'); + } + + public setDisambiguation( + physicalRow: number, + mappingPath: MappingPath, + id: number + ): void { + this.changeDisambiguation( + physicalRow, + (disambiguations) => ({ + ...disambiguations, + [mappingPathToString(mappingPath)]: id, + }), + 'Disambiguation.Set' + ); + } +} + +/* eslint-enable functional/no-this-expression */ diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/DisambiguationLogic.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/DisambiguationLogic.tsx deleted file mode 100644 index 58802d91e72..00000000000 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/DisambiguationLogic.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import React from 'react'; - -import { commonText } from '../../localization/common'; -import { wbText } from '../../localization/workbench'; -import type { IR } from '../../utils/types'; -import type { AnySchema } from '../DataModel/helperTypes'; -import type { Collection } from '../DataModel/specifyTable'; -import { strictGetTable } from '../DataModel/tables'; -import { Dialog } from '../Molecules/Dialog'; -import { hasTablePermission } from '../Permissions/helpers'; -import type { MappingPath } from '../WbPlanView/Mapper'; -import { mappingPathToString } from '../WbPlanView/mappingHelpers'; -import { getTableFromMappingPath } from '../WbPlanView/navigator'; -import { DisambiguationDialog } from './Disambiguation'; -import { getSelectedLast } from './hotHelpers'; -import type { WbView } from './WbView'; - -/* eslint-disable functional/no-this-expression */ -export class Disambiguation { - public constructor(private readonly wbView: WbView) {} - - private getDisambiguation(physicalRow: number): IR { - const cols = this.wbView.dataset.columns.length; - const hiddenColumn = this.wbView.data[physicalRow][cols]; - const extra = - typeof hiddenColumn === 'string' && hiddenColumn.length > 0 - ? JSON.parse(hiddenColumn) - : {}; - return extra.disambiguation ?? {}; - } - - public isAmbiguousCell(): boolean { - if (this.wbView.mappings === undefined || this.wbView.hot === undefined) - return false; - - const [visualRow, visualCol] = getSelectedLast(this.wbView.hot); - const physicalRow = this.wbView.hot.toPhysicalRow(visualRow); - const physicalCol = this.wbView.hot.toPhysicalColumn(visualCol); - const disambiguation = this.getDisambiguation(physicalRow); - - return ( - this.wbView.validation.uploadResults.ambiguousMatches[physicalRow] ?? [] - ).some( - ({ physicalCols, mappingPath }) => - physicalCols.includes(physicalCol) && - typeof disambiguation[mappingPathToString(mappingPath)] !== 'number' - ); - } - - public cellWasDisambiguated( - physicalRow: number, - physicalCol: number - ): boolean { - const da = this.getDisambiguation(physicalRow); - return Boolean( - this.wbView.validation.uploadResults.ambiguousMatches[physicalRow]?.find( - ({ physicalCols, mappingPath }) => - physicalCols.includes(physicalCol) && - typeof da[mappingPathToString(mappingPath)] === 'number' - ) - ); - } - - private changeDisambiguation( - physicalRow: number, - changeFunction: (oldValue: IR) => IR, - source: 'Disambiguation.Clear' | 'Disambiguation.Set' - ): void { - if (this.wbView.hot === undefined) return; - const cols = this.wbView.dataset.columns.length; - const hidden = this.wbView.data[physicalRow][cols]; - const extra = hidden ? JSON.parse(hidden) : {}; - extra.disambiguation = changeFunction(extra.disambiguation || {}); - const visualRow = this.wbView.hot.toVisualRow(physicalRow); - const visualCol = this.wbView.hot.toVisualColumn(cols); - this.wbView.hot.setDataAtCell( - visualRow, - visualCol, - JSON.stringify(extra), - source - ); - this.wbView.actions.spreadSheetChanged(); - this.afterChangeDisambiguation(physicalRow); - } - - public afterChangeDisambiguation(physicalRow: number): void { - (this.wbView.validation.uploadResults.ambiguousMatches[physicalRow] ?? []) - .flatMap(({ physicalCols }) => physicalCols) - .forEach((physicalCol) => - this.wbView.cells.recalculateIsModifiedState(physicalRow, physicalCol) - ); - this.wbView.cells.updateCellInfoStats(); - } - - clearDisambiguation(physicalRow: number): void { - const disambiguation = this.getDisambiguation(physicalRow); - if (Object.keys(disambiguation).length === 0) - // Nothing to clear - return; - this.changeDisambiguation(physicalRow, () => ({}), 'Disambiguation.Clear'); - } - - private setDisambiguation( - physicalRow: number, - mappingPath: MappingPath, - id: number - ): void { - this.changeDisambiguation( - physicalRow, - (disambiguations) => ({ - ...disambiguations, - [mappingPathToString(mappingPath)]: id, - }), - 'Disambiguation.Set' - ); - } - - public openDisambiguationDialog() { - if (this.wbView.mappings === undefined || this.wbView.hot === undefined) - return; - - const [visualRow, visualCol] = getSelectedLast(this.wbView.hot); - const physicalRow = this.wbView.hot.toPhysicalRow(visualRow); - const physicalCol = this.wbView.hot.toPhysicalColumn(visualCol); - - const matches = this.wbView.validation.uploadResults.ambiguousMatches[ - physicalRow - ].find(({ physicalCols }) => physicalCols.includes(physicalCol)); - if (matches === undefined) return; - const tableName = getTableFromMappingPath( - this.wbView.mappings.baseTable.name, - matches.mappingPath - ); - const table = strictGetTable(tableName); - const resources = new table.LazyCollection({ - filters: { id__in: matches.ids.join(',') }, - }) as Collection; - - (hasTablePermission(table.name, 'read') - ? resources.fetch({ limit: 0 }) - : Promise.resolve(resources) - ).then(({ models }) => { - if (models.length === 0) { - const dialog = this.wbView.options.display( - dialog()} - > - {wbText.noDisambiguationResultsDescription()} - - ); - return; - } - - // Re-enable this once live validation is available again: - /* - * Disable "Apply All" if validation is still in progress. - * This is because we don't know all matches until validation is done - */ - /* - *Let applyAllAvailable = true; - *const applyAllButton = content.find('#applyAllButton'); - * - *const updateIt = () => { - * const newState = this.liveValidationStack.length === 0; - * if (newState !== applyAllAvailable) { - * applyAllAvailable = newState; - * applyAllButton.disabled = !newState; - * applyAllButton[newState ? 'removeAttribute' : 'setAttribute']( - * 'title', - * wbText.applyAllUnavailable() - * ); - * } - *}; - * - *const interval = globalThis.setInterval(updateIt, 100); - * // onClose: globalThis.clearInterval(interval); - */ - - const dialog = this.wbView.options.display( - dialog()} - onSelected={(selected) => { - this.setDisambiguation( - physicalRow, - matches.mappingPath, - selected.id - ); - this.wbView.validation.startValidateRow(physicalRow); - }} - onSelectedAll={(selected): void => - // Loop backwards so the live validation will go from top to bottom - this.wbView.hot?.batch(() => { - for ( - let visualRow = this.wbView.data.length - 1; - visualRow >= 0; - visualRow-- - ) { - const physicalRow = this.wbView.hot!.toPhysicalRow(visualRow); - const ambiguousMatchToDisambiguate = - this.wbView.validation.uploadResults.ambiguousMatches[ - physicalRow - ]?.find( - ({ key, mappingPath }) => - key === matches.key && - typeof this.getDisambiguation(physicalRow)[ - mappingPathToString(mappingPath) - ] !== 'number' - ); - - if (ambiguousMatchToDisambiguate === undefined) continue; - this.setDisambiguation( - physicalRow, - ambiguousMatchToDisambiguate.mappingPath, - selected.id - ); - this.wbView.validation.startValidateRow(physicalRow); - } - }) - } - /> - ); - }); - } -} - -/* eslint-enable functional/no-this-expression */ diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/RecordSet.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/RecordSet.tsx index a1e56b2c98d..6e33a396998 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/RecordSet.tsx @@ -6,7 +6,7 @@ import { wbText } from '../../localization/workbench'; import { ajax } from '../../utils/ajax'; import { formData } from '../../utils/ajax/helpers'; import { Button } from '../Atoms/Button'; -import { LoadingContext } from '../Core/Contexts'; +import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; import { tables } from '../DataModel/tables'; import { ProtectedAction, @@ -16,13 +16,13 @@ import { unsafeNavigate } from '../Router/Router'; import { EditRecordSet } from '../Toolbar/RecordSetEdit'; export function CreateRecordSetButton({ - dataSetId, - dataSetName, + datasetId, + datasetName, onClose: handleClosed, small, }: { - readonly dataSetId: number; - readonly dataSetName: string; + readonly datasetId: number; + readonly datasetName: string; readonly onClose: () => void; readonly small: boolean; }): JSX.Element { @@ -38,8 +38,8 @@ export function CreateRecordSetButton({ {isOpen && ( { handleClose(); handleClosed(); @@ -52,39 +52,44 @@ export function CreateRecordSetButton({ } function CreateRecordSetDialog({ - dataSetId, - dataSetName, + datasetId, + datasetName, onClose: handleClose, }: { - readonly dataSetId: number; - readonly dataSetName: string; + readonly datasetId: number; + readonly datasetName: string; readonly onClose: () => void; }): JSX.Element { const recordSet = React.useMemo( () => new tables.RecordSet.Resource({ - name: wbText.recordSetName({ dataSet: dataSetName }), + name: wbText.recordSetName({ dataSet: datasetName }), }), - [dataSetId] + [datasetId] ); const loading = React.useContext(LoadingContext); return ( - { - unsetUnloadProtect(); - loading( - ajax(`/api/workbench/create_recordset/${dataSetId}/`, { - method: 'POST', - headers: { Accept: 'application/json' }, - body: formData({ name: recordSet.get('name') }), - errorMode: 'dismissible', - }).then(({ data }) => unsafeNavigate(`/specify/record-set/${data}/`)) - ); - return false; - }} - /> + // Override readonly context set by workbench after upload so recordset meta can be edited + + { + unsetUnloadProtect(); + loading( + ajax(`/api/workbench/create_recordset/${datasetId}/`, { + method: 'POST', + headers: { Accept: 'application/json' }, + body: formData({ name: recordSet.get('name') }), + errorMode: 'dismissible', + }).then(({ data }) => + unsafeNavigate(`/specify/record-set/${data}/`) + ) + ); + return false; + }} + /> + ); } diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/Results.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/Results.tsx index 59ec3b8a6fd..ea86c70b285 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/Results.tsx @@ -15,63 +15,68 @@ import { Button } from '../Atoms/Button'; import { formatNumber } from '../Atoms/Internationalization'; import { strictGetTable } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; +import { ErrorBoundary } from '../Errors/ErrorBoundary'; import { TableIcon } from '../Molecules/TableIcon'; import { CreateRecordSetButton } from './RecordSet'; export function WbUploaded({ recordCounts, - dataSetId, - dataSetName, + datasetId, + datasetName, isUploaded, onClose: handleClose, }: { readonly recordCounts: Partial, number>>; - readonly dataSetId: number; - readonly dataSetName: string; + readonly datasetId: number; + readonly datasetName: string; readonly isUploaded: boolean; readonly onClose: () => void; }): JSX.Element { return ( -
-
-

- {isUploaded - ? wbText.uploadResults() - : wbText.potentialUploadResults()} -

-

- {isUploaded - ? wbText.wbUploadedDescription() - : wbText.wbUploadedPotentialDescription()} -

-
-
    - {Object.entries(recordCounts) - .sort(sortFunction(([_tableName, recordCount]) => recordCount, false)) - .map(([tableName, recordCount], index) => - typeof recordCount === 'number' ? ( - - ) : null + +
    +
    +

    + {isUploaded + ? wbText.uploadResults() + : wbText.potentialUploadResults()} +

    +

    + {isUploaded + ? wbText.wbUploadedDescription() + : wbText.wbUploadedPotentialDescription()} +

    +
    +
      + {Object.entries(recordCounts) + .sort( + sortFunction(([_tableName, recordCount]) => recordCount, false) + ) + .map(([tableName, recordCount], index) => + typeof recordCount === 'number' ? ( + + ) : null + )} +
    +
    + {isUploaded && ( + )} -
-
- {isUploaded && ( - - )} - - {commonText.close()} - + + {commonText.close()} + +
- + ); } diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/Template.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/Template.tsx deleted file mode 100644 index 52739b33205..00000000000 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/Template.tsx +++ /dev/null @@ -1,433 +0,0 @@ -/** - * Generate static template for the WbView using React - * All logic and event listeners would be attached in WbView.js - */ - -import React from 'react'; -import ReactDOMServer from 'react-dom/server'; -import { useNavigate, useParams } from 'react-router-dom'; -import type { LocalizedString } from 'typesafe-i18n'; - -import { useUnloadProtect } from '../../hooks/navigation'; -import { useAsyncState } from '../../hooks/useAsyncState'; -import { useBooleanState } from '../../hooks/useBooleanState'; -import { useErrorContext } from '../../hooks/useErrorContext'; -import { commonText } from '../../localization/common'; -import { localityText } from '../../localization/locality'; -import { wbPlanText } from '../../localization/wbPlan'; -import { wbText } from '../../localization/workbench'; -import { ajax } from '../../utils/ajax'; -import { f } from '../../utils/functools'; -import type { GetSet, RA } from '../../utils/types'; -import { replaceItem } from '../../utils/utils'; -import { Button } from '../Atoms/Button'; -import { className } from '../Atoms/className'; -import { Input } from '../Atoms/Form'; -import { Link } from '../Atoms/Link'; -import { LoadingContext } from '../Core/Contexts'; -import { useMenuItem } from '../Header/MenuContext'; -import { treeRanksPromise } from '../InitialContext/treeRanks'; -import { Dialog } from '../Molecules/Dialog'; -import { Portal } from '../Molecules/Portal'; -import { hasPermission, hasTablePermission } from '../Permissions/helpers'; -import { userPreferences } from '../Preferences/userPreferences'; -import { NotFoundView } from '../Router/NotFoundView'; -import type { Dataset } from '../WbPlanView/Wrapped'; -import type { WbStatus } from './WbView'; -import { WbView as WbViewClass } from './WbView'; - -function Navigation({ - name, - label, -}: { - readonly name: string; - readonly label: LocalizedString; -}): JSX.Element { - return ( - - - {'<'} - - - {label} (0/ - 0) - - - {'>'} - - - ); -} - -function WbView({ - isUploaded, - isMapped, - dataSetId, -}: { - readonly isUploaded: boolean; - readonly isMapped: boolean; - readonly dataSetId: number; -}): JSX.Element { - const canUpdate = hasPermission('/workbench/dataset', 'update'); - const [canLiveValidate] = userPreferences.use( - 'workBench', - 'general', - 'liveValidation' - ); - return ( - <> -
-
- - {commonText.tools()} - - - {canUpdate || isMapped ? ( - - {wbPlanText.dataMapper()} - - ) : undefined} - {!isUploaded && hasPermission('/workbench/dataset', 'validate') && ( - <> - - {wbText.dataCheck()} - - - {wbText.validate()} - - - )} - - {commonText.results()} - - {isUploaded ? ( - hasPermission('/workbench/dataset', 'unupload') && ( - - {wbText.rollback()} - - ) - ) : ( - <> - {hasPermission('/workbench/dataset', 'upload') && ( - - {wbText.upload()} - - )} - {hasPermission('/workbench/dataset', 'update') && ( - <> - - {wbText.revert()} - - - {commonText.save()} - - - )} - - )} -
-
- {hasPermission('/workbench/dataset', 'transfer') && - hasTablePermission('SpecifyUser', 'read') ? ( - <> - - {wbText.changeOwner()} - - - {wbText.uploadPlan()} - - - ) : undefined} - - {commonText.export()} - - - {hasPermission('/workbench/dataset', 'update') && ( - <> - - {wbText.convertCoordinates()} - - - {localityText.geoLocate()} - - - )} - - {localityText.geoMap()} - -
-
-
-
-
- -
- -
- {!isUploaded && hasPermission('/workbench/dataset', 'update') ? ( -
- -
- ) : undefined} - - - - {!isUploaded && hasPermission('/workbench/dataset', 'update') ? ( - - ) : undefined} - - {!isUploaded && ( - - )} -
- - ); -} - -export const wbViewTemplate = ( - isUploaded: boolean, - isMapped: boolean, - dataSetId: number -): string => - ReactDOMServer.renderToStaticMarkup( - - ); - -const fetchTreeRanks = async (): Promise => treeRanksPromise.then(f.true); - -export function WorkBench(): JSX.Element | null { - useMenuItem('workBench'); - - const [treeRanksLoaded = false] = useAsyncState(fetchTreeRanks, true); - const { id } = useParams(); - const dataSetId = f.parseInt(id); - - const [container, setContainer] = React.useState(null); - const [dataSet, setDataSet] = useDataSet(dataSetId); - useErrorContext('dataSet', dataSet); - const loading = React.useContext(LoadingContext); - const [isDeleted, handleDeleted] = useBooleanState(); - const [isDeletedConfirmation, handleDeletedConfirmation] = useBooleanState(); - const portals = useWbView( - dataSet, - treeRanksLoaded, - container, - handleDeleted, - handleDeletedConfirmation, - () => loading(fetchDataSet(dataSet!.id).then(setDataSet)) - ); - - const navigate = useNavigate(); - return dataSetId === undefined ? ( - - ) : isDeleted ? ( - <>{wbText.dataSetDeletedOrNotFound()} - ) : isDeletedConfirmation ? ( - navigate('/specify/', { replace: true })} - > - {wbText.dataSetDeletedDescription()} - - ) : ( - <> -
- {portals.map((portal, index) => - portal === undefined ? undefined : ( - - {portal.jsx} - - ) - )} - - ); -} - -// BUG: intercept 403 (if dataset has been transferred to another user) -function useDataSet( - dataSetId: number | undefined -): GetSet { - return useAsyncState( - React.useCallback(async () => fetchDataSet(dataSetId), [dataSetId]), - true - ); -} - -const fetchDataSet = async ( - dataSetId: number | undefined -): Promise => - typeof dataSetId === 'number' - ? ajax(`/api/workbench/dataset/${dataSetId}/`, { - headers: { Accept: 'application/json' }, - }).then(({ data }) => data) - : undefined; - -function useWbView( - dataSet: Dataset | undefined, - treeRanksLoaded: boolean, - container: HTMLElement | null, - handleDeleted: () => void, - handleDeletedConfirmation: () => void, - handleRefresh: () => void -): RA< - | { readonly jsx: JSX.Element; readonly element: HTMLElement | undefined } - | undefined -> { - const [portals, setPortals] = React.useState< - RA< - | { readonly jsx: JSX.Element; readonly element: HTMLElement | undefined } - | undefined - > - >([]); - - const mode = React.useRef(undefined); - const wasAborted = React.useRef(false); - - const [hasUnloadProtect, setUnloadProtect] = React.useState(false); - useUnloadProtect(hasUnloadProtect, wbText.wbUnloadProtect()); - - React.useEffect(() => { - if (!treeRanksLoaded || container === null || dataSet === undefined) - return undefined; - const contained = document.createElement('section'); - contained.setAttribute('class', `wbs-form ${className.containerFull}`); - container.append(contained); - const view = new WbViewClass( - dataSet, - mode.current, - wasAborted.current, - contained, - { - onSetUnloadProtect: setUnloadProtect, - onDeleted: handleDeleted, - onDeletedConfirmation: handleDeletedConfirmation, - display(jsx, element, destructor): () => void { - let index = 0; - setPortals((portals) => { - index = portals.length; - return [...portals, { jsx, element }]; - }); - return () => { - setPortals((portals) => replaceItem(portals, index, undefined)); - destructor?.(); - }; - }, - } - ) - .on('refresh', (newMode: WbStatus | undefined, newWasAborted = false) => { - setUnloadProtect(false); - mode.current = newMode; - wasAborted.current = newWasAborted; - handleRefresh(); - }) - .render(); - return () => void view.remove(); - }, [treeRanksLoaded, container, dataSet]); - - return portals; -} diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbActions.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbActions.tsx deleted file mode 100644 index f87c427a05e..00000000000 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbActions.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import React from 'react'; - -import { commonText } from '../../localization/common'; -import { wbPlanText } from '../../localization/wbPlan'; -import { wbText } from '../../localization/workbench'; -import { Http } from '../../utils/ajax/definitions'; -import { ping } from '../../utils/ajax/ping'; -import { overwriteReadOnly } from '../../utils/types'; -import { Button } from '../Atoms/Button'; -import { Link } from '../Atoms/Link'; -import { loadingBar } from '../Molecules'; -import { Dialog } from '../Molecules/Dialog'; -import type { Status } from '../WbPlanView/Wrapped'; -import type { WbCellCounts } from './CellMeta'; -import { RollbackConfirmation } from './Components'; -import { CreateRecordSetButton } from './RecordSet'; -import { WbStatus as WbStatusComponent } from './Status'; -import type { WbStatus, WbView } from './WbView'; - -/* eslint-disable functional/no-this-expression */ -export class WbActions { - // eslint-disable-next-line functional/prefer-readonly-type - private hasUnSavedChanges: boolean = false; - - // eslint-disable-next-line functional/prefer-readonly-type - public status: (() => void) | undefined = undefined; - - public constructor(private readonly wbView: WbView) {} - - /* - * Actions - * aka Rollback - */ - unupload(): void { - const dialog = this.wbView.options.display( - dialog()} - onRollback={() => this.openStatus('unupload')} - /> - ); - } - - // BUG: disable the button if there is nothing to upload - upload(event: MouseEvent): void { - const mode = - (event.currentTarget as HTMLElement | null)?.classList.contains( - 'wb-upload' - ) === true - ? 'upload' - : 'validate'; - if ((this.wbView.mappings?.lines ?? []).length > 0) { - if (mode === 'upload') { - const dialog = this.wbView.options.display( - - {commonText.cancel()} - { - this.startUpload(mode); - dialog(); - }} - > - {wbText.upload()} - - - } - header={wbText.startUpload()} - onClose={(): void => dialog()} - > - {wbText.startUploadDescription()} - - ); - } else this.startUpload(mode); - } else { - const dialog = this.wbView.options.display( - - {commonText.close()} - - {commonText.create()} - - - } - header={wbPlanText.noUploadPlan()} - onClose={() => dialog()} - > - {wbPlanText.noUploadPlanDescription()} - - ); - } - } - - startUpload(mode: WbStatus): void { - this.wbView.validation.stopLiveValidation(); - this.wbView.validation.updateValidationButton(); - ping(`/api/workbench/${mode}/${this.wbView.dataset.id}/`, { - method: 'POST', - expectedErrors: [Http.CONFLICT], - }) - .then((statusCode): void => { - this.wbView.checkDeletedFail(statusCode); - this.checkConflictFail(statusCode); - }) - .then(() => this.openStatus(mode)); - } - - openStatus(mode: WbStatus): void { - this.status = this.wbView.options.display( - { - this.status?.(); - this.status = undefined; - this.wbView.trigger('refresh', mode, wasAborted); - }} - /> - ); - } - - revertChanges(): void { - const dialog = this.wbView.options.display( - - {commonText.cancel()} - this.wbView.trigger('refresh')}> - {wbText.revert()} - - - } - header={wbText.revertChanges()} - onClose={() => dialog()} - > - {wbText.revertChangesDescription()} - - ); - } - - async save() { - // Clear validation - overwriteReadOnly(this.wbView.dataset, 'rowresults', null); - this.wbView.validation.stopLiveValidation(); - this.wbView.validation.updateValidationButton(); - - // Show saving progress bar - const dialog = this.wbView.options.display( - dialog()} - > - {loadingBar} - - ); - - // Send data - return ping(`/api/workbench/rows/${this.wbView.dataset.id}/`, { - method: 'PUT', - body: this.wbView.data, - expectedErrors: [Http.NO_CONTENT, Http.NOT_FOUND], - }) - .then((status) => this.wbView.checkDeletedFail(status)) - .then(() => { - this.spreadSheetUpToDate(); - this.wbView.cells.cellMeta = []; - this.wbView.wbUtils.searchCells({ key: 'SettingsChange' }); - this.wbView.hot?.render(); - }) - .finally(() => dialog()); - } - - // Check if AJAX failed because Data Set was modified by other session - checkConflictFail(statusCode: number): boolean { - if (statusCode === Http.CONFLICT) - /* - * Upload/Validation/Un-Upload has been initialized by another session - * Need to reload the page to display the new state - */ - this.wbView.trigger('reload'); - return statusCode === Http.CONFLICT; - } - - spreadSheetUpToDate(): void { - if (!this.hasUnSavedChanges) return; - this.hasUnSavedChanges = false; - Array.from( - this.wbView.el.querySelectorAll( - '.wb-upload, .wb-validate, .wb-export-data-set, .wb-change-data-set-owner' - ), - (element) => { - (element as HTMLButtonElement).disabled = false; - element.setAttribute('title', ''); - } - ); - this.wbView.el.querySelector('.wb-save')?.toggleAttribute('disabled', true); - this.wbView.el - .querySelector('.wb-revert') - ?.toggleAttribute('disabled', true); - this.wbView.options.onSetUnloadProtect(false); - } - - public operationCompletedMessage(cellCounts: WbCellCounts): void { - if (this.wbView.refreshInitiatedBy === undefined) return; - - const messages = { - validate: - cellCounts.invalidCells === 0 - ? { - header: wbText.validationNoErrors(), - message: ( - <> - {wbText.validationNoErrorsDescription()} -
-
- {wbText.validationReEditWarning()} - - ), - } - : { - header: wbText.validationErrors(), - message: ( - <> - {wbText.validationErrorsDescription()} -
-
- {wbText.validationReEditWarning()} - - ), - }, - upload: - cellCounts.invalidCells === 0 - ? { - header: wbText.uploadSuccessful(), - message: wbText.uploadSuccessfulDescription(), - } - : { - header: wbText.uploadErrors(), - message: ( - <> - {wbText.uploadErrorsDescription()} -
-
- {wbText.uploadErrorsSecondDescription()} - - ), - }, - unupload: { - header: wbText.dataSetRollback(), - message: wbText.dataSetRollbackDescription(), - }, - }; - - const dialog = this.wbView.options.display( - - {cellCounts.invalidCells === 0 && - this.wbView.refreshInitiatedBy === 'upload' ? ( - dialog()} - /> - ) : undefined} - {commonText.close()} - - } - header={messages[this.wbView.refreshInitiatedBy].header} - onClose={() => dialog()} - > - {messages[this.wbView.refreshInitiatedBy].message} - - ); - - this.wbView.refreshInitiatedBy = undefined; - this.wbView.refreshInitiatorAborted = false; - } - - operationAbortedMessage(): void { - if ( - this.wbView.refreshInitiatedBy === undefined || - this.wbView.refreshInitiatorAborted - ) - return; - - const dialog = this.wbView.options.display( - dialog()} - > - {this.wbView.refreshInitiatedBy === 'validate' - ? wbText.validationCanceledDescription() - : this.wbView.refreshInitiatedBy === 'unupload' - ? wbText.rollbackCanceledDescription() - : wbText.uploadCanceledDescription()} - - ); - this.wbView.refreshInitiatedBy = undefined; - this.wbView.refreshInitiatorAborted = false; - } - - spreadSheetChanged(): void { - if (this.hasUnSavedChanges) return; - this.hasUnSavedChanges = true; - - Array.from( - this.wbView.el.querySelectorAll( - '.wb-upload, .wb-validate, .wb-export-data-set, .wb-change-data-set-owner' - ), - (element) => { - (element as HTMLButtonElement).disabled = true; - element.setAttribute('title', wbText.unavailableWhileEditing()); - } - ); - this.wbView.el - .querySelector('.wb-save') - ?.toggleAttribute('disabled', false); - this.wbView.el - .querySelector('.wb-revert') - ?.toggleAttribute('disabled', false); - const uploadView = this.wbView.el.querySelector('.wb-show-upload-view'); - uploadView?.toggleAttribute('disabled', true); - uploadView?.setAttribute('title', wbText.wbUploadedUnavailable()); - this.wbView.options.onSetUnloadProtect(true); - } -} - -/* eslint-enable functional/no-this-expression */ diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx new file mode 100644 index 00000000000..76421f90275 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx @@ -0,0 +1,290 @@ +/** + * Component for the Handsontable React wrapper + */ + +import { HotTable } from '@handsontable/react'; +import type Handsontable from 'handsontable'; +import type { DetailedSettings } from 'handsontable/plugins/contextMenu'; +import { registerAllModules } from 'handsontable/registry'; +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { LANGUAGE } from '../../localization/utils/config'; +import { wbText } from '../../localization/workbench'; +import type { RA } from '../../utils/types'; +import { writable } from '../../utils/types'; +import { iconClassName, legacyNonJsxIcons } from '../Atoms/Icons'; +import { ReadOnlyContext } from '../Core/Contexts'; +import { strictGetTable } from '../DataModel/tables'; +import { getIcon, unknownIcon } from '../InitialContext/icons'; +import type { Dataset } from '../WbPlanView/Wrapped'; +import { configureHandsontable } from './handsontable'; +import { useHotHooks } from './hooks'; +import { getSelectedRegions, setHotData } from './hotHelpers'; +import { useHotProps } from './hotProps'; +import type { WbMapping } from './mapping'; +import { fetchWbPickLists } from './pickLists'; +import type { Workbench } from './WbView'; + +registerAllModules(); + +function WbSpreadsheetComponent({ + dataset, + setHotTable, + hot, + isUploaded, + data, + workbench, + mappings, + isResultsOpen, + checkDeletedFail, + spreadsheetChanged, + onClickDisambiguate: handleClickDisambiguate, +}: { + readonly dataset: Dataset; + readonly setHotTable: React.RefCallback; + readonly hot: Handsontable | undefined; + readonly isUploaded: boolean; + readonly data: RA>; + readonly workbench: Workbench; + readonly mappings: WbMapping | undefined; + readonly isResultsOpen: boolean; + readonly checkDeletedFail: (statusCode: number) => boolean; + readonly spreadsheetChanged: () => void; + readonly onClickDisambiguate: () => void; +}): JSX.Element { + const isReadOnly = React.useContext(ReadOnlyContext); + const physicalColToMappingCol = (physicalCol: number): number | undefined => + mappings?.lines.findIndex( + ({ headerName }) => headerName === dataset.columns[physicalCol] + ); + + const { validation, cells, disambiguation } = workbench; + + const contextMenuConfig: DetailedSettings | undefined = + hot === undefined + ? undefined + : { + items: isUploaded + ? ({ + // Display uploaded record + upload_results: { + disableSelection: true, + isCommand: false, + renderer: (hot, wrapper) => { + const { endRow: visualRow, endCol: visualCol } = + getSelectedRegions(hot).at(-1) ?? {}; + const physicalRow = hot.toPhysicalRow(visualRow ?? 0); + const physicalCol = hot.toPhysicalColumn(visualCol ?? 0); + + const createdRecords = + validation.uploadResults.newRecords[physicalRow]?.[ + physicalCol + ]; + + if ( + visualRow === undefined || + visualCol === undefined || + createdRecords === undefined || + !cells.getCellMeta(physicalRow, physicalCol, 'isNew') + ) { + wrapper.textContent = wbText.noUploadResultsAvailable(); + wrapper.parentElement?.classList.add('htDisabled'); + const span = document.createElement('span'); + span.style.display = 'none'; + return span; + } + + wrapper.setAttribute( + 'class', + `${wrapper.getAttribute('class')} flex flex-col !m-0 + pb-1` + ); + wrapper.innerHTML = createdRecords + .map(([tableName, recordId, label]) => { + const tableLabel = + label === '' + ? strictGetTable(tableName).label + : label; + // REFACTOR: use new table icons + const tableIcon = getIcon(tableName) ?? unknownIcon; + + return ` + + ${tableLabel} + ${legacyNonJsxIcons.link} + `; + }) + .join(''); + + const div = document.createElement('div'); + div.style.display = 'none'; + return div; + }, + }, + } as const) + : ({ + row_above: { + disabled: () => isReadOnly, + }, + row_below: { + disabled: () => isReadOnly, + }, + remove_row: { + disabled: () => { + if (isReadOnly) return true; + // Or if called on the last row + const selectedRegions = getSelectedRegions(hot); + return ( + selectedRegions.length === 1 && + selectedRegions[0].startRow === data.length - 1 && + selectedRegions[0].startRow === selectedRegions[0].endRow + ); + }, + }, + disambiguate: { + name: wbText.disambiguate(), + disabled: (): boolean => + !disambiguation.isAmbiguousCell() || isReadOnly, + callback: handleClickDisambiguate, + }, + ['separator_1' as 'undo']: '---------', + fill_down: fillCellsContextMenuItem(hot, 'down', isReadOnly), + fill_up: fillCellsContextMenuItem(hot, 'up', isReadOnly), + ['separator_2' as 'redo']: '---------', + undo: { + disabled: () => !hot.isUndoAvailable() || isReadOnly, + }, + redo: { + disabled: () => !hot.isRedoAvailable() || isReadOnly, + }, + } as const), + }; + + React.useEffect(() => { + if (hot === undefined) return; + hot.batch(() => { + (mappings === undefined + ? Promise.resolve({}) + : fetchWbPickLists(dataset.columns, mappings.tableNames, mappings.lines) + ).then((pickLists) => { + configureHandsontable(hot, mappings, dataset, pickLists); + // Check for reordered columns + if (dataset.visualorder?.some((column, index) => column !== index)) + hot.updateSettings({ + manualColumnMove: writable(dataset.visualorder), + }); + // Highlight validation cells + if (dataset.rowresults) { + validation.getValidationResults(); + if (validation.validationMode === 'static' && !isUploaded) + workbench.utils.toggleCellTypes('invalidCells', 'remove'); + workbench.cells.indexedCellMeta = undefined; + } + }); + }); + }, [hot, dataset.rowresults]); + + const { + autoWrapCol, + autoWrapRow, + columns, + enterMoves, + colHeaders, + enterBeginsEditing, + hiddenRows, + hiddenColumns, + minSpareRows, + tabMoves, + comments, + } = useHotProps({ dataset, mappings, physicalColToMappingCol }); + + const hooks = useHotHooks({ + workbench, + physicalColToMappingCol, + spreadsheetChanged, + checkDeletedFail, + isReadOnly, + isResultsOpen, + }); + + return ( +
+ +
+ ); +} + +export const WbSpreadsheet = React.memo(WbSpreadsheetComponent); + +// Context menu item definitions (common for fillUp and fillDown) +const fillCellsContextMenuItem = ( + hot: Handsontable, + mode: 'down' | 'up', + isReadOnly: boolean +): Handsontable.plugins.ContextMenu.MenuItemConfig => ({ + name: mode === 'up' ? wbText.fillUp() : wbText.fillDown(), + disabled: () => + isReadOnly || + (hot.getSelected()?.every((selection) => selection[0] === selection[2]) ?? + false), + callback: (_, selections) => + selections.forEach((selection) => + Array.from({ + length: selection.end.col + 1 - selection.start.col, + }).forEach((_, index) => { + const startRow = + mode === 'up' ? selection.start.row + 1 : selection.start.row; + const endRow = selection.end.row; + const col = selection.start.col + index; + const value = + mode === 'up' + ? hot.getDataAtCell(endRow, col) + : hot.getDataAtCell(startRow, col); + setHotData( + hot, + Array.from({ length: endRow - startRow }, (_, index) => [ + startRow + index + 1, + col, + value, + ]) + ); + }) + ), +}); diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbUtils.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbUtils.tsx deleted file mode 100644 index a7df7136d24..00000000000 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbUtils.tsx +++ /dev/null @@ -1,776 +0,0 @@ -/** - * Workbench Utilities: - * Search & Replace, GeoMap, GeoLocate, Coordinate Convertor - * - * @module - * - */ - -import type Handsontable from 'handsontable'; -import React from 'react'; -import _ from 'underscore'; - -import { wbText } from '../../localization/workbench'; -import { f } from '../../utils/functools'; -import type { RA, WritableArray } from '../../utils/types'; -import { filterArray } from '../../utils/types'; -import { camelToKebab, clamp } from '../../utils/utils'; -import { Backbone } from '../DataModel/backbone'; -import { softFail } from '../Errors/Crash'; -import { LeafletMap } from '../Leaflet/Map'; -import { getLocalitiesDataFromSpreadsheet } from '../Leaflet/wbLocalityDataExtractor'; -import { hasPermission } from '../Permissions/helpers'; -import type { WbSearchPreferences } from './AdvancedSearch'; -import { - getInitialSearchPreferences, - WbAdvancedSearch, -} from './AdvancedSearch'; -import type { WbCellCounts } from './CellMeta'; -import { CoordinateConverter } from './CoordinateConverter'; -import { getSelectedLocalities, WbGeoLocate } from './GeoLocate'; -import { getHotPlugin } from './handsontable'; -import { getSelectedLast, getVisualHeaders } from './hotHelpers'; -import type { WbView } from './WbView'; - -// REFACTOR: rewrite to React -/* eslint-disable functional/no-this-expression */ -export class WbUtils extends Backbone.View { - // eslint-disable-next-line functional/prefer-readonly-type - public searchQuery: RegExp | string | undefined = undefined; - - // eslint-disable-next-line functional/prefer-readonly-type - private rawSearchQuery: string | undefined = undefined; - - // eslint-disable-next-line functional/prefer-readonly-type - public searchPreferences: WbSearchPreferences = getInitialSearchPreferences(); - - // eslint-disable-next-line functional/prefer-readonly-type - private geoLocateDialog: (() => void) | undefined = undefined; - - // eslint-disable-next-line functional/prefer-readonly-type - private advancedSearch: (() => void) | undefined = undefined; - - // eslint-disable-next-line functional/prefer-readonly-type - private geoMapDialog: (() => void) | undefined = undefined; - - constructor(private readonly wbView: WbView) { - super({ - el: wbView.el, - events: { - 'click .wb-cell-navigation': 'navigateCells', - 'click .wb-navigation-text': 'toggleCellTypes', - 'keydown .wb-search-query': 'searchCells', - 'keydown .wb-replace-value': 'replaceCells', - 'click .wb-show-toolkit': 'toggleToolkit', - 'click .wb-geolocate': 'showGeoLocate', - 'click .wb-leafletmap': 'showLeafletMap', - 'click .wb-convert-coordinates': 'showCoordinateConversion', - }, - }); - - const debounced = _.debounce( - this.searchCells, - Math.ceil(clamp(10, this.wbView.data.length / 20, 200)), - false - ).bind(this); - // Workaround for _.debounce not working with async functions - this.searchCells = async (event): Promise => debounced(event); - } - - render() { - let initialNavigationDirection = - this.searchPreferences.navigation.direction; - this.advancedSearch = this.wbView.options.display( - { - this.searchPreferences = newSearchPreferences; - if ( - this.searchPreferences.navigation.direction !== - initialNavigationDirection - ) { - this.wbView.cells.flushIndexedCellData = true; - initialNavigationDirection = - this.searchPreferences.navigation.direction; - } - if (this.searchPreferences.search.liveUpdate) - this.searchCells({ - key: 'SettingsChange', - }).catch(softFail); - }} - />, - this.el.getElementsByClassName( - 'wb-advanced-search-wrapper' - )[0] as HTMLElement - ); - - return this; - } - - remove(): this { - this.advancedSearch?.(); - Backbone.View.prototype.remove.call(this); - return this; - } - - navigateCells( - event: { readonly target: Element }, - // If true and current cell is of correct type, don't navigate away - matchCurrentCell = false, - /* - * Overwrite what is considered to be a current cell - * Setting to [0,0] and matchCurrentCell=true allows navigation to the first - * cell of type (used on hitting "Enter" in the Search Box) - */ - currentCell: readonly [number, number] | undefined = undefined - ): - | { - readonly visualRow: number; - readonly visualCol: number; - } - | undefined { - const button = event.target as HTMLButtonElement | null; - if (this.wbView.hot === undefined || button === null) return undefined; - - /* - * Can get data-* via button.dataset, but this way is better as can find - * usages this way easily (the button.dataset converts keys to camelCase) - */ - const direction = button.getAttribute('data-navigation-direction'); - const buttonParent = button.parentElement; - if (buttonParent === null) return undefined; - const type = buttonParent.getAttribute('data-navigation-type') as - | keyof WbCellCounts - | null; - if (type === null) return undefined; - const currentPositionElement = buttonParent.getElementsByClassName( - 'wb-navigation-position' - )[0]; - const totalCountElement = buttonParent.getElementsByClassName( - 'wb-navigation-total' - )[0]; - const totalCount = Number.parseInt(totalCountElement.textContent ?? '0'); - - const cellMetaObject = this.wbView.cells.getCellMetaObject(); - - /* - * The cellMetaObject is transposed if navigation direction is "Column - * first". - * In that case, the meaning of visualRow and visualCol is swapped. - * resolveIndex exists to resolve the canonical visualRow/visualCol - */ - const resolveIndex = ( - visualRow: number, - visualCol: number, - first: boolean - ): number => - (this.searchPreferences.navigation.direction === 'rowFirst') === first - ? visualRow - : visualCol; - - const [currentRow, currentCol] = - currentCell ?? getSelectedLast(this.wbView.hot); - - const [currentTransposedRow, currentTransposedCol] = [ - resolveIndex(currentRow, currentCol, true), - resolveIndex(currentRow, currentCol, false), - ]; - - const compareRows = - direction === 'next' - ? (visualRow: number) => visualRow >= currentTransposedRow - : (visualRow: number) => visualRow <= currentTransposedRow; - - const compareCols = - direction === 'next' - ? matchCurrentCell - ? (visualCol: number) => visualCol >= currentTransposedCol - : (visualCol: number) => visualCol > currentTransposedCol - : matchCurrentCell - ? (visualCol: number) => visualCol <= currentTransposedCol - : (visualCol: number) => visualCol < currentTransposedCol; - - let matchedCell: - | { - readonly visualRow: number; - readonly visualCol: number; - } - | undefined; - let cellIsTypeCount = 0; - - const orderIt = - direction === 'next' - ? f.id - : (array: RA): RA => Array.from(array).reverse(); - - orderIt(Object.entries(cellMetaObject)).find(([visualRowString, metaRow]) => - typeof metaRow === 'object' - ? orderIt(Object.entries(metaRow)).find( - ([visualColString, metaArray]) => { - /* - * This is 10 times faster then Number.parseInt because of a slow - * Babel polyfill - */ - const visualRow = (visualRowString as unknown as number) | 0; - const visualCol = (visualColString as unknown as number) | 0; - - const cellTypeMatches = this.wbView.cells.cellIsType( - metaArray, - type - ); - cellIsTypeCount += cellTypeMatches ? 1 : 0; - - const isWithinBounds = - compareRows(visualRow) && - (visualRow !== currentTransposedRow || compareCols(visualCol)); - - const matches = cellTypeMatches && isWithinBounds; - if (matches) - matchedCell = { - visualRow: resolveIndex(visualRow, visualCol, true), - visualCol: resolveIndex(visualRow, visualCol, false), - }; - return matches; - } - ) - : undefined - ); - - let cellRelativePosition; - if (matchedCell === undefined) cellRelativePosition = 0; - else if (direction === 'next') cellRelativePosition = cellIsTypeCount; - else cellRelativePosition = totalCount - cellIsTypeCount + 1; - - const boundaryCell = direction === 'next' ? totalCount : 1; - - const initialCellRelativePosition = Number.parseInt( - currentPositionElement.textContent ?? '0' - ); - if ( - cellRelativePosition !== 0 || - initialCellRelativePosition !== boundaryCell || - totalCount === 0 - ) - currentPositionElement.textContent = cellRelativePosition.toString(); - - if (matchedCell === undefined) return undefined; - - this.wbView.hot.selectCell(matchedCell.visualRow, matchedCell.visualCol); - - // Turn on the respective cell type if it was hidden - this.toggleCellTypes(event, 'remove'); - - return matchedCell; - } - - toggleCellTypes( - event: keyof WbCellCounts | { readonly target: Element }, - action: 'add' | 'remove' | 'toggle' = 'toggle' - ): boolean { - let navigationType: keyof WbCellCounts | undefined; - let buttonContainer: HTMLElement | undefined; - if (typeof event === 'string') { - navigationType = event; - buttonContainer = - this.wbView.el.querySelector( - `.wb-navigation-section[data-navigation-type="${navigationType}"]` - ) ?? undefined; - } else { - const button = event.target as HTMLButtonElement | null; - buttonContainer = button?.closest('.wb-navigation-section') ?? undefined; - navigationType = (buttonContainer?.getAttribute('data-navigation-type') ?? - undefined) as keyof WbCellCounts | undefined; - } - if (buttonContainer === undefined || navigationType === undefined) - return false; - - const groupName = camelToKebab(navigationType); - const cssClassName = `wb-hide-${groupName}`; - this.el.classList[action](cssClassName); - const newState = this.el.classList.contains(cssClassName); - const indicator = - buttonContainer.getElementsByClassName('wb-navigation-text')[0]; - indicator.setAttribute('aria-pressed', newState ? 'true' : 'false'); - indicator.classList[newState ? 'add' : 'remove']('brightness-50'); - return newState; - } - - parseSearchQuery() { - const searchQueryElement = - this.el.querySelector('.wb-search-query'); - if (searchQueryElement === null) return; - - this.rawSearchQuery = searchQueryElement.value; - - this.searchQuery = this.searchPreferences.search.useRegex - ? this.rawSearchQuery - : this.rawSearchQuery.trim(); - - if (this.searchQuery === '') { - this.searchQuery = undefined; - return; - } - - if (this.searchPreferences.search.useRegex) - try { - if (this.searchPreferences.search.fullMatch) { - if (!this.searchQuery.startsWith('^')) - this.searchQuery = `^${this.searchQuery}`; - if (!this.searchQuery.endsWith('$')) - this.searchQuery = `${this.searchQuery}$`; - } - // Regex may be coming from the user, thus disable strict mode - - this.searchQuery = new RegExp( - this.searchQuery, - this.searchPreferences.search.caseSensitive ? '' : 'i' - ); - } catch (error) { - searchQueryElement.setCustomValidity((error as SyntaxError).message); - searchQueryElement.reportValidity(); - this.searchQuery = undefined; - return; - } - else if (!this.searchPreferences.search.caseSensitive) - this.searchQuery = this.searchQuery.toLowerCase(); - - searchQueryElement.setCustomValidity(''); - - return this.searchQuery; - } - - async searchCells( - event: KeyboardEvent | { readonly key: 'SettingsChange' } - ): Promise { - if (this.wbView.hot === undefined) return; - const searchQueryElement = this.el.getElementsByClassName( - 'wb-search-query' - )[0] as HTMLInputElement; - - /* - * Don't rerun search on live search if search query did not change - * (e.x, if Ctrl/Cmd+A is clicked in the search box) - */ - if ( - searchQueryElement.value === this.rawSearchQuery && - !['SettingsChange', 'Enter'].includes(event.key) - ) - return; - - // Don't handle onKeyDown event if live search is disabled - if (event.key !== 'Enter' && !this.searchPreferences.search.liveUpdate) - return; - - const navigationContainer = - this.el.querySelector( - '.wb-navigation-section[data-navigation-type="searchResults"]' - ) ?? undefined; - const navigationTotalElement = navigationContainer?.getElementsByClassName( - 'wb-navigation-total' - )[0]; - - if ( - navigationContainer === undefined || - navigationTotalElement === undefined - ) - return; - if (this.parseSearchQuery() === undefined) { - navigationTotalElement.textContent = '0'; - this.toggleCellTypes('searchResults', 'add'); - return; - } - this.toggleCellTypes('searchResults', 'remove'); - - const navigationButton = - navigationContainer.getElementsByClassName('wb-cell-navigation'); - - let resultsCount = 0; - const data = this.wbView.dataset.rows; - const firstVisibleRow = - getHotPlugin(this.wbView.hot, 'autoRowSize').getFirstVisibleRow() - 3; - const lastVisibleRow = - getHotPlugin(this.wbView.hot, 'autoRowSize').getLastVisibleRow() + 3; - const firstVisibleColumn = - getHotPlugin(this.wbView.hot, 'autoColumnSize').getFirstVisibleColumn() - - 3; - const lastVisibleColumn = - getHotPlugin(this.wbView.hot, 'autoColumnSize').getLastVisibleColumn() + - 3; - - for (let visualRow = 0; visualRow < this.wbView.data.length; visualRow++) { - const physicalRow = this.wbView.hot.toPhysicalRow(visualRow); - for ( - let visualCol = 0; - visualCol < this.wbView.dataset.columns.length; - visualCol++ - ) { - const physicalCol = this.wbView.hot.toPhysicalColumn(visualCol); - const isSearchResult = this.searchFunction( - (data[physicalRow][physicalCol] || - this.wbView.mappings?.defaultValues[physicalCol]) ?? - '' - ); - - let cell = undefined; - let render = false; - - /* - * Calling this.wbView.hot.getCell only if cell is within the render - * bounds. - * While hot.getCell is supposed to check for this too, doing it this - * way makes search about 25% faster - */ - if ( - firstVisibleRow <= visualRow && - lastVisibleRow >= visualRow && - firstVisibleColumn <= visualCol && - lastVisibleColumn >= visualCol - ) { - cell = this.wbView.hot.getCell(visualRow, visualCol) ?? undefined; - render = Boolean(cell); - } - - this.wbView.cells[render ? 'updateCellMeta' : 'setCellMeta']( - physicalRow, - physicalCol, - 'isSearchResult', - isSearchResult, - { - cell, - visualRow, - visualCol, - } - ); - if (isSearchResult) resultsCount += 1; - } - } - - navigationTotalElement.textContent = resultsCount.toString(); - - // Navigate to the first search result when hitting Enter - if (event.key === 'Enter') - this.navigateCells( - { target: navigationButton[1] }, - event.key === 'Enter', - event.key === 'Enter' ? [0, 0] : undefined - ); - } - - replaceCells(event: KeyboardEvent) { - const button = event.target as HTMLButtonElement | null; - if ( - event.key !== 'Enter' || - button === null || - (this.searchPreferences.search.useRegex && - this.searchQuery === undefined) || - this.wbView.hot === undefined - ) - return; - - const buttonContainer = button.parentElement; - const replacementValueElement = buttonContainer?.getElementsByClassName( - 'wb-replace-value' - )[0] as HTMLInputElement | undefined; - if (replacementValueElement === undefined) return; - const replacementValue = this.searchPreferences.search.useRegex - ? replacementValueElement.value - : replacementValueElement.value.trim(); - - const getNewCellValue = this.searchPreferences.search.fullMatch - ? (): string => replacementValue - : (cellValue: string): string => - this.searchPreferences.search.useRegex - ? cellValue.replaceAll( - // Regex may be coming from the user, thus disable strict mode - // eslint-disable-next-line require-unicode-regexp - new RegExp(this.searchQuery!, 'g'), - replacementValue - ) - : cellValue.split(this.searchQuery ?? '').join(replacementValue); - - if (this.searchPreferences.replace.replaceMode === 'replaceAll') { - // eslint-disable-next-line functional/prefer-readonly-type - const modifications: WritableArray<[number, number, string]> = []; - Object.entries(this.wbView.cells.cellMeta).forEach( - ([physicalRow, metaRow]) => - Object.entries(metaRow).forEach(([physicalCol, metaArray]) => { - if ( - !this.wbView.cells.getCellMetaFromArray( - metaArray, - 'isSearchResult' - ) - ) - return; - const visualRow = this.wbView.hot!.toVisualRow( - (physicalRow as unknown as number) | 0 - ); - const visualCol = this.wbView.hot!.toVisualColumn( - (physicalCol as unknown as number) | 0 - ); - const cellValue = - this.wbView.hot!.getDataAtCell(visualRow, visualCol) || ''; - // Don't replace cells with default values - if (cellValue === '') return; - modifications.push([ - visualRow, - visualCol, - getNewCellValue(cellValue), - ]); - }) - ); - this.wbView.hot.setDataAtCell(modifications); - } else { - const nextCellOfType = () => - this.navigateCells( - { - target: document.querySelector( - `.wb-navigation-section[data-navigation-type="searchResults"] - .wb-cell-navigation[data-navigation-direction="next"]` - )!, - }, - false - ); - const [currentRow, currentCol] = getSelectedLast(this.wbView.hot); - const physicalRow = this.wbView.hot.toPhysicalRow(currentRow); - const physicalCol = this.wbView.hot.toPhysicalColumn(currentCol); - let nextCell = [currentRow, currentCol] as const; - if ( - !this.wbView.cells.cellIsType( - this.wbView.cells.cellMeta[physicalRow]?.[physicalCol], - 'searchResults' - ) - ) { - const next = nextCellOfType(); - if (typeof next === 'object') { - const { visualRow, visualCol } = next; - nextCell = [visualRow, visualCol]; - } - } - - if (!Array.isArray(nextCell)) return; - - this.wbView.hot.setDataAtCell( - ...nextCell, - getNewCellValue(this.wbView.hot.getDataAtCell(...nextCell)) - ); - - nextCellOfType(); - } - } - - searchFunction(initialCellValue = '') { - let cellValue = initialCellValue; - - if (this.searchQuery === undefined) return false; - - if (!this.searchPreferences.search.caseSensitive) - cellValue = cellValue.toLowerCase(); - - if (this.searchPreferences.search.useRegex) - return cellValue.search(this.searchQuery) !== -1; - - return this.searchPreferences.search.fullMatch - ? cellValue === this.searchQuery - : cellValue.includes(this.searchQuery as string); - } - - toggleToolkit(event: MouseEvent): void { - const toolkit = this.el.getElementsByClassName( - 'wb-toolkit' - )[0] as HTMLElement; - const target = event.target as HTMLElement | null; - target?.toggleAttribute('aria-pressed', toolkit.style.display === 'none'); - toolkit.style.display = toolkit.style.display === 'none' ? '' : 'none'; - this.wbView.handleResize(); - } - - // Context menu item definitions (common for fillUp and fillDown) - fillCellsContextMenuItem( - mode: 'down' | 'up' - ): Handsontable.contextMenu.MenuItemConfig { - return { - name: mode === 'up' ? wbText.fillUp() : wbText.fillDown(), - disabled: () => - typeof this.wbView.uploadedView === 'function' || - typeof this.wbView.coordinateConverterView === 'function' || - !hasPermission('/workbench/dataset', 'update') || - (this.wbView.hot - ?.getSelected() - ?.every((selection) => selection[0] === selection[2]) ?? - false), - callback: (_, selections) => - selections.forEach((selection) => - Array.from( - new Array(selection.end.col + 1 - selection.start.col).keys() - ).forEach((index) => { - const startRow = - mode === 'up' ? selection.start.row + 1 : selection.start.row; - const endRow = selection.end.row; - const col = selection.start.col + index; - const value = - mode === 'up' - ? this.wbView.hot!.getDataAtCell(endRow, col) - : this.wbView.hot!.getDataAtCell(startRow, col); - this.wbView.hot?.setDataAtCell( - Array.from({ length: endRow - startRow }, (_, index) => [ - startRow + index + 1, - col, - value, - ]) - ); - }) - ), - }; - } - - public findLocalityColumns(): void { - const leafletButton = this.el.getElementsByClassName( - 'wb-leafletmap' - )[0] as HTMLButtonElement; - // These buttons only exist if user has data set update permission - const geoLocaleButton = this.el.getElementsByClassName( - 'wb-geolocate' - )[0] as HTMLButtonElement; - const coordinateConverterButton = this.el.getElementsByClassName( - 'wb-convert-coordinates' - )[0] as HTMLButtonElement; - - const localityColumns = this.wbView.mappings?.localityColumns ?? []; - if (localityColumns.length === 0) return; - leafletButton.disabled = false; - if (this.wbView.isUploaded) - filterArray([geoLocaleButton, coordinateConverterButton]).map((button) => - button.setAttribute('title', wbText.unavailableWhenUploaded()) - ); - else { - if (typeof geoLocaleButton === 'object') geoLocaleButton.disabled = false; - if (typeof coordinateConverterButton === 'object') - coordinateConverterButton.disabled = false; - } - } - - protected showGeoLocate(event: MouseEvent): void { - if (this.wbView.hot === undefined || this.wbView.mappings === undefined) - return; - const target = event.target as HTMLElement; - // Don't allow opening more than one window) - if (this.geoLocateDialog !== undefined) { - this.geoLocateDialog(); - return; - } - - this.geoLocateDialog = this.wbView.options.display( - this.geoLocateDialog?.()} - />, - undefined, - () => { - target.toggleAttribute('aria-pressed', false); - this.geoLocateDialog = undefined; - } - ); - - target.toggleAttribute('aria-pressed', true); - } - - protected showLeafletMap(event: MouseEvent): void { - if (this.wbView.hot === undefined || this.wbView.mappings === undefined) - return; - if (this.geoMapDialog !== undefined) { - this.geoMapDialog(); - return; - } - const target = event.target as HTMLElement; - target.toggleAttribute('aria-pressed', true); - - const selection = getSelectedLocalities( - this.wbView.hot, - this.wbView.dataset.columns, - this.wbView.mappings.localityColumns, - false - ); - - if (selection === undefined) return; - - const localityPoints = getLocalitiesDataFromSpreadsheet( - this.wbView.mappings.localityColumns, - selection.visualRows.map((visualRow) => - this.wbView.hot!.getDataAtRow(visualRow) - ), - getVisualHeaders(this.wbView.hot, this.wbView.dataset.columns), - selection.visualRows - ); - - this.geoMapDialog = this.wbView.options.display( - this.geoMapDialog?.()} - onMarkerClick={(localityPoint): void => { - const rowNumber = localityPoints[localityPoint].rowNumber.value; - if (typeof rowNumber !== 'number') - throw new Error('rowNumber must be a number'); - const [_currentRow, currentCol] = getSelectedLast(this.wbView.hot!); - this.wbView.hot?.scrollViewportTo(rowNumber, currentCol); - // Select entire row - this.wbView.hot?.selectRows(rowNumber); - }} - />, - undefined, - () => { - this.geoMapDialog = undefined; - target.toggleAttribute('aria-pressed', false); - } - ); - } - - protected showCoordinateConversion(): void { - if ( - this.wbView.coordinateConverterView !== undefined || - this.wbView.mappings === undefined || - this.wbView.hot === undefined - ) - return; - - const buttons = [ - 'wb-leafletmap', - 'wb-geolocate', - 'wb-convert-coordinates', - 'wb-replace-value', - ] - .map( - (className) => - this.el.getElementsByClassName(className)[0] as HTMLButtonElement - ) - .map((button) => [button, button.disabled] as const); - const originalReadOnlyState = this.wbView.hot.getSettings().readOnly; - this.wbView.hot.updateSettings({ - readOnly: true, - }); - this.el.classList.add('wb-focus-coordinates'); - - this.wbView.coordinateConverterView = this.wbView.options.display( - this.wbView.coordinateConverterView?.()} - />, - undefined, - () => { - this.wbView.coordinateConverterView = undefined; - buttons.forEach(([button, isDisabled]) => { - button.disabled = isDisabled; - }); - this.wbView.hot?.updateSettings({ - readOnly: originalReadOnlyState, - }); - this.el.classList.remove('wb-focus-coordinates'); - } - ); - } -} -/* eslint-enable functional/no-this-expression */ diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx index 418c43074ae..b220a90885e 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx @@ -1,4 +1,3 @@ -import { commonText } from '../../localization/common'; import { whitespaceSensitive } from '../../localization/utils'; import { wbText } from '../../localization/workbench'; import { ajax } from '../../utils/ajax'; @@ -15,7 +14,34 @@ import { import type { WbMeta } from './CellMeta'; import type { UploadResult } from './resultsParser'; import { resolveValidationMessage } from './resultsParser'; -import type { WbView } from './WbView'; +import type { Workbench } from './WbView'; + +type UploadResults = { + readonly ambiguousMatches: WritableArray< + WritableArray<{ + readonly physicalCols: RA; + readonly mappingPath: MappingPath; + readonly ids: RA; + readonly key: string; + }> + >; + readonly recordCounts: Partial, number>>; + readonly newRecords: Partial< + WritableArray< + WritableArray< + WritableArray< + Readonly< + readonly [ + tableName: Lowercase, + id: number, + alternativeLabel: string | '' + ] + > + > + > + > + >; +}; /* eslint-disable functional/no-this-expression */ export class WbValidation { @@ -29,50 +55,23 @@ export class WbValidation { public validationMode: 'live' | 'off' | 'static'; // eslint-disable-next-line functional/prefer-readonly-type - public uploadResults: { - readonly ambiguousMatches: WritableArray< - WritableArray<{ - readonly physicalCols: RA; - readonly mappingPath: MappingPath; - readonly ids: RA; - readonly key: string; - }> - >; - readonly recordCounts: Partial, number>>; - readonly newRecords: Partial< - WritableArray< - WritableArray< - WritableArray< - Readonly< - readonly [ - tableName: Lowercase, - id: number, - alternativeLabel: string | '' - ] - > - > - > - > - >; - } = { + public uploadResults: UploadResults = { ambiguousMatches: [], recordCounts: {}, newRecords: [], }; - public constructor(private readonly wbView: WbView) { + public constructor(private readonly workbench: Workbench) { this.stopLiveValidation(); this.validationMode = - this.wbView.dataset.rowresults === null ? 'off' : 'static'; + this.workbench.dataset.rowresults === null ? 'off' : 'static'; } - toggleDataCheck(event: MouseEvent): void { - const target = event.target as HTMLElement | null; - if (this.wbView.hot === undefined || target === null) return; - + public toggleDataCheck(): void { + if (!this.workbench?.hot) return; this.validationMode = this.validationMode === 'live' || - (this.wbView.mappings?.lines ?? []).length === 0 + (this.workbench.mappings?.lines ?? []).length === 0 ? 'off' : 'live'; @@ -81,30 +80,27 @@ export class WbValidation { recordCounts: {}, newRecords: [], }; - this.wbView.cells.cellMeta = []; + this.workbench.cells.cellMeta = []; switch (this.validationMode) { case 'live': { this.liveValidationStack = Array.from( - { length: this.wbView.hot.countRows() }, - (_, visualRow) => this.wbView.hot!.toPhysicalRow(visualRow) + { length: this.workbench.hot.countRows() }, + (_, visualRow) => this.workbench.hot!.toPhysicalRow(visualRow) ).reverse(); this.triggerLiveValidation(); - this.wbView.wbUtils.toggleCellTypes('newCells', 'remove'); - this.wbView.wbUtils.toggleCellTypes('invalidCells', 'remove'); - target.toggleAttribute('aria-pressed', true); + this.workbench.utils?.toggleCellTypes('newCells', 'remove'); + this.workbench.utils?.toggleCellTypes('invalidCells', 'remove'); break; } case 'off': { this.liveValidationStack = []; this.liveValidationActive = false; - target.toggleAttribute('aria-pressed', false); break; } } - this.wbView.hot.render(); - this.updateValidationButton(); + this.workbench.hot.render(); } public startValidateRow(physicalRow: number): void { @@ -115,20 +111,19 @@ export class WbValidation { this.triggerLiveValidation(); } - triggerLiveValidation() { - const pumpValidation = (): void => { - this.updateValidationButton(); + private async triggerLiveValidation(): Promise { + const pumpValidation = async (): Promise => { if (this.liveValidationStack.length === 0) { this.liveValidationActive = false; return; } this.liveValidationActive = true; const physicalRow = this.liveValidationStack.pop(); - if (physicalRow === undefined || this.wbView.hot === undefined) return; - const rowData = this.wbView.hot.getSourceDataAtRow(physicalRow); - ajax<{ + if (physicalRow === undefined || this.workbench.hot === undefined) return; + const rowData = this.workbench.hot.getSourceDataAtRow(physicalRow); + await ajax<{ readonly result: UploadResult; - } | null>(`/api/workbench/validate_row/${this.wbView.dataset.id}/`, { + } | null>(`/api/workbench/validate_row/${this.workbench.dataset.id}/`, { method: 'POST', body: rowData, headers: { Accept: 'application/json' }, @@ -142,52 +137,41 @@ export class WbValidation { }; if (!this.liveValidationActive) { - pumpValidation(); + return pumpValidation(); } } - updateValidationButton(): void { - const button = this.wbView.el.querySelector('.wb-data-check'); - if (button === null) return; - button.textContent = - this.validationMode === 'live' - ? this.liveValidationStack.length > 0 - ? commonText.countLine({ - resource: wbText.dataCheckOn(), - count: this.liveValidationStack.length, - }) - : wbText.dataCheckOn() - : wbText.dataCheck(); - } - - gotRowValidationResult(physicalRow: number, result: UploadResult): void { + private gotRowValidationResult( + physicalRow: number, + result: UploadResult + ): void { if ( this.validationMode !== 'live' || - this.wbView.hot?.isDestroyed !== false + this.workbench.hot?.isDestroyed !== false ) return; this.uploadResults.ambiguousMatches[physicalRow] = []; - this.wbView.hot.batch(() => + this.workbench.hot.batch(() => this.applyRowValidationResults(physicalRow, result) ); - this.wbView.cells.updateCellInfoStats(); + this.workbench.cells?.updateCellInfoStats(); } - getHeadersFromMappingPath( + private getHeadersFromMappingPath( mappingPathFilter: RA, tryBest = true ): RA { - if (this.wbView.mappings === undefined) return []; + if (this.workbench.mappings === undefined) return []; if (!tryBest) // Find all columns with the shared parent mapping path - return this.wbView.mappings.lines + return this.workbench.mappings.lines .filter(({ mappingPath }) => pathStartsWith(mappingPath, mappingPathFilter) ) .map(({ headerName }) => headerName); return ( mappedFind(mappingPathFilter, (_, index) => { - const columns = this.wbView + const columns = this.workbench .mappings!.lines.filter(({ mappingPath }) => pathStartsWith( mappingPath, @@ -200,25 +184,28 @@ export class WbValidation { ); } - resolveValidationColumns( + private resolveValidationColumns( initialColumns: RA, inferColumnsCallback: (() => RA) | undefined = undefined - ) { + ): RA { // See https://github.com/specify/specify7/issues/810 let columns: RA = initialColumns.filter(Boolean); if (typeof inferColumnsCallback === 'function') { if (columns.length === 0) columns = inferColumnsCallback(); - if (columns.length === 0) columns = this.wbView.dataset.columns; + if (columns.length === 0) columns = this.workbench.dataset.columns; } // Convert to physicalCol and filter out unknown columns return columns - .map((column) => this.wbView.dataset.columns.indexOf(column)) + .map((column) => this.workbench.dataset.columns.indexOf(column)) .filter((physicalCol) => physicalCol !== -1); } - applyRowValidationResults(physicalRow: number, result: UploadResult) { + private applyRowValidationResults( + physicalRow: number, + result: UploadResult + ): void { const rowMeta: WritableArray>> = - this.wbView.dataset.columns.map(() => ({ + this.workbench.dataset.columns.map(() => ({ isNew: false, issues: [], })); @@ -246,32 +233,28 @@ export class WbValidation { if (cellMeta.issues?.length !== 0 && this.validationMode === 'live') cellMeta.isModified = false; Object.entries(cellMeta).map(([key, value]) => - this.wbView.cells.updateCellMeta(physicalRow, physicalCol, key, value) + this.workbench.cells?.updateCellMeta( + physicalRow, + physicalCol, + key, + value + ) ); }); } - parseRowValidationResults( - result: UploadResult, + private resolveUploadStatus( + uploadStatus: keyof UploadResult['UploadResult']['record_result'], + recordResult: UploadResult['UploadResult']['record_result'], + physicalRow: number, + mappingPath: MappingPath, setMetaCallback: ( key: KEY, value: KEY extends 'issues' ? string : WbMeta[KEY], columns: RA, inferColumnsCallback: (() => RA) | undefined - ) => void, - physicalRow: number, - initialMappingPath: MappingPath | undefined = [] - ) { - const uploadResult = result.UploadResult; - const uploadStatus = Object.keys(uploadResult.record_result)[0]; - const statusData = uploadResult.record_result[uploadStatus]; - const data = uploadResult.record_result; - - const isTree = 'info' in statusData && statusData.info?.treeInfo !== null; - const mappingPath = isTree - ? [...initialMappingPath, formatTreeRank(statusData.info.treeInfo.rank)] - : initialMappingPath; - + ) => void + ): void { const resolveColumns = this.getHeadersFromMappingPath.bind( this, mappingPath @@ -280,7 +263,7 @@ export class WbValidation { // Ignore these statuses if (['NullRecord', 'PropagatedFailure', 'Matched'].includes(uploadStatus)) { } else if (uploadStatus === 'ParseFailures') - data.ParseFailures.failures.forEach((line) => { + recordResult.ParseFailures.failures.forEach((line) => { const [issueMessage, payload, column] = line.length === 2 ? [line[0], {}, line[1]] : line; setMetaCallback( @@ -296,7 +279,7 @@ export class WbValidation { setMetaCallback( 'issues', wbText.noMatchErrorMessage(), - data.NoMatch.info.columns, + recordResult.NoMatch.info.columns, resolveColumns ); else if (uploadStatus === 'FailedBusinessRule') @@ -304,55 +287,90 @@ export class WbValidation { 'issues', whitespaceSensitive( resolveValidationMessage( - data.FailedBusinessRule.message, - data.FailedBusinessRule.payload ?? {} + recordResult.FailedBusinessRule.message, + recordResult.FailedBusinessRule.payload ?? {} ) ), - data.FailedBusinessRule.info.columns, + recordResult.FailedBusinessRule.info.columns, resolveColumns ); else if (uploadStatus === 'MatchedMultiple') { this.uploadResults.ambiguousMatches[physicalRow] ??= []; this.uploadResults.ambiguousMatches[physicalRow].push({ physicalCols: this.resolveValidationColumns( - data.MatchedMultiple.info.columns, + recordResult.MatchedMultiple.info.columns, resolveColumns ), mappingPath, - ids: data.MatchedMultiple.ids, - key: data.MatchedMultiple.key, + ids: recordResult.MatchedMultiple.ids, + key: recordResult.MatchedMultiple.key, }); setMetaCallback( 'issues', whitespaceSensitive(wbText.matchedMultipleErrorMessage()), - data.MatchedMultiple.info.columns, + recordResult.MatchedMultiple.info.columns, resolveColumns ); } else if (uploadStatus === 'Uploaded') { - setMetaCallback('isNew', true, data.Uploaded.info.columns, undefined); - const tableName = toLowerCase(data.Uploaded.info.tableName); + setMetaCallback( + 'isNew', + true, + recordResult.Uploaded.info.columns, + undefined + ); + const tableName = toLowerCase(recordResult.Uploaded.info.tableName); this.uploadResults.recordCounts[tableName] ??= 0; this.uploadResults.recordCounts[tableName]! += 1; this.uploadResults.newRecords[physicalRow] ??= []; - this.resolveValidationColumns(data.Uploaded.info.columns, undefined).map( - (physicalCol) => { - this.uploadResults.newRecords[physicalRow]![physicalCol] ??= []; - this.uploadResults.newRecords[physicalRow]![physicalCol].push([ - tableName, - data.Uploaded.id, - data.Uploaded.info?.treeInfo - ? `${data.Uploaded.info.treeInfo.name} (${data.Uploaded.info.treeInfo.rank})` - : '', - ]); - } - ); + this.resolveValidationColumns( + recordResult.Uploaded.info.columns, + undefined + ).forEach((physicalCol) => { + this.uploadResults.newRecords[physicalRow]![physicalCol] ??= []; + this.uploadResults.newRecords[physicalRow]![physicalCol].push([ + tableName, + recordResult.Uploaded.id, + recordResult.Uploaded.info?.treeInfo + ? `${recordResult.Uploaded.info.treeInfo.name} (${recordResult.Uploaded.info.treeInfo.rank})` + : '', + ]); + }); } else raise( new Error( `Trying to parse unknown uploadStatus type "${uploadStatus}" at - row ${this.wbView.hot?.toVisualRow(physicalRow) ?? ''}` + row ${this.workbench.hot?.toVisualRow(physicalRow) ?? ''}` ) ); + } + + private parseRowValidationResults( + result: UploadResult, + setMetaCallback: ( + key: KEY, + value: KEY extends 'issues' ? string : WbMeta[KEY], + columns: RA, + inferColumnsCallback: (() => RA) | undefined + ) => void, + physicalRow: number, + initialMappingPath: MappingPath | undefined = [] + ): void { + const uploadResult = result.UploadResult; + const uploadStatus = Object.keys(uploadResult.record_result)[0]; + const statusData = uploadResult.record_result[uploadStatus]; + + const isTree = 'info' in statusData && statusData.info?.treeInfo !== null; + const mappingPath = isTree + ? [...initialMappingPath, formatTreeRank(statusData.info.treeInfo.rank)] + : initialMappingPath; + + this.resolveUploadStatus( + uploadStatus, + uploadResult.record_result, + physicalRow, + mappingPath, + setMetaCallback + ); Object.entries(uploadResult.toOne).forEach(([fieldName, uploadResult]) => this.parseRowValidationResults( @@ -377,24 +395,24 @@ export class WbValidation { ); } - getValidationResults(): void { - if (this.wbView.actions.status !== undefined || !this.wbView.mappings) - return; + public getValidationResults(): void { + if (this.workbench.mappings === undefined) return; - if (this.wbView.dataset.rowresults === null) { + if (this.workbench.dataset.rowresults === null) { this.validationMode = 'off'; - this.updateValidationButton(); return; } - this.wbView.dataset.rowresults.forEach((result, physicalRow) => { - this.applyRowValidationResults(physicalRow, result); + this.workbench.hot?.batch(() => { + this.workbench.dataset.rowresults?.forEach((result, physicalRow) => { + this.applyRowValidationResults(physicalRow, result); + }); }); - this.wbView.cells.updateCellInfoStats(); + this.workbench.cells?.updateCellInfoStats(); } - stopLiveValidation(): void { + public stopLiveValidation(): void { this.liveValidationStack = []; this.liveValidationActive = false; this.validationMode = 'off'; diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx index 95b328e28f8..cfbba9c157a 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx @@ -12,821 +12,250 @@ import '../../../css/workbench.css'; -import Handsontable from 'handsontable'; +import type { HotTable } from '@handsontable/react'; +import type Handsontable from 'handsontable'; import React from 'react'; +import { useUnloadProtect } from '../../hooks/navigation'; +import { useBooleanState } from '../../hooks/useBooleanState'; +import { useErrorContext } from '../../hooks/useErrorContext'; import { commonText } from '../../localization/common'; -import { LANGUAGE } from '../../localization/utils/config'; import { wbPlanText } from '../../localization/wbPlan'; import { wbText } from '../../localization/workbench'; import { Http } from '../../utils/ajax/definitions'; -import { f } from '../../utils/functools'; -import type { IR, RA, WritableArray } from '../../utils/types'; -import { ensure, overwriteReadOnly, writable } from '../../utils/types'; -import { clamp, throttle } from '../../utils/utils'; +import type { GetSet, RA } from '../../utils/types'; +import { clamp } from '../../utils/utils'; import { Button } from '../Atoms/Button'; -import { iconClassName, legacyNonJsxIcons } from '../Atoms/Icons'; +import { className } from '../Atoms/className'; import { Link } from '../Atoms/Link'; -import { legacyLoadingContext } from '../Core/Contexts'; -import { Backbone } from '../DataModel/backbone'; -import { getTable, strictGetTable } from '../DataModel/tables'; -import { crash, raise } from '../Errors/Crash'; -import { getIcon, unknownIcon } from '../InitialContext/icons'; -import { Dialog } from '../Molecules/Dialog'; +import { ReadOnlyContext } from '../Core/Contexts'; import { hasPermission } from '../Permissions/helpers'; -import { userPreferences } from '../Preferences/userPreferences'; -import type { UploadPlan } from '../WbPlanView/uploadPlanParser'; +import { WbActions } from '../WbActions'; +import { useResults } from '../WbActions/useResults'; import type { Dataset } from '../WbPlanView/Wrapped'; +import { WbToolkit } from '../WbToolkit'; +import { WbUtilsComponent } from '../WbUtils'; +import { WbUtils } from '../WbUtils/Utils'; +import type { WbCellCounts } from './CellMeta'; import { WbCellMeta } from './CellMeta'; -import { DataSetNameView } from './DataSetMeta'; -import { DevShowPlan } from './DevShowPlan'; +import { DataSetName } from './DataSetMeta'; import { Disambiguation } from './DisambiguationLogic'; -import { configureHandsontable, getHotPlugin } from './handsontable'; -import { downloadDataSet } from './helpers'; -import { getHotHooks } from './hooks'; -import { getSelectedRegions } from './hotHelpers'; import type { WbMapping } from './mapping'; import { parseWbMappings } from './mapping'; -import { fetchWbPickLists } from './pickLists'; import { WbUploaded } from './Results'; -import { wbViewTemplate } from './Template'; -import { WbActions } from './WbActions'; -import { WbUtils } from './WbUtils'; +import { useDisambiguationDialog } from './useDisambiguationDialog'; +import { WbSpreadsheet } from './WbSpreadsheet'; import { WbValidation } from './WbValidation'; export type WbStatus = 'unupload' | 'upload' | 'validate'; -// REFACTOR: when rewriting to React, add ErrorBoundaries - -// REFACTOR: rewrite to React -/* eslint-disable functional/no-this-expression */ -export class WbView extends Backbone.View { - public readonly data: RA>; - - public readonly mappings: WbMapping | undefined = undefined; - - // eslint-disable-next-line functional/prefer-readonly-type - public hotIsReady: boolean = false; - - // eslint-disable-next-line functional/prefer-readonly-type - public hotCommentsContainer: HTMLElement | undefined = undefined; - - // eslint-disable-next-line functional/prefer-readonly-type - public undoRedoIsHandled: boolean = false; - - private readonly dataSetMeta: DataSetNameView; - - public readonly wbUtils: WbUtils; - - public readonly cells: WbCellMeta; - - public readonly validation: WbValidation; - - public readonly actions: WbActions; - - public readonly disambiguation: Disambiguation; - - // eslint-disable-next-line functional/prefer-readonly-type - public hot: Handsontable | undefined; - - /* - * If this.isUploaded, render() will: - * Add the "Uploaded" label next to DS Name - * Disable cell editing - * Disable adding/removing rows - * Still allow column sort - * Still allow column move - * - */ - public readonly isUploaded: boolean; - - // Disallow all editing and some tools while this dialog is open - // eslint-disable-next-line functional/prefer-readonly-type - public uploadedView: (() => void) | undefined = undefined; - - // Disallow all editing and all tools while this dialog is open - // eslint-disable-next-line functional/prefer-readonly-type - public coordinateConverterView: (() => void) | undefined = undefined; - - public readonly handleResize: () => void; - - public readonly throttleRate: number; - - // Constructors & Renderers - public constructor( - public readonly dataset: Dataset, - // eslint-disable-next-line functional/prefer-readonly-type - public refreshInitiatedBy: WbStatus | undefined, - // eslint-disable-next-line functional/prefer-readonly-type - public refreshInitiatorAborted: boolean, - public readonly element: HTMLElement, - public readonly options: { - readonly onSetUnloadProtect: (unloadProtect: boolean) => void; - readonly onDeleted: () => void; - readonly onDeletedConfirmation: () => void; - readonly display: ( - jsx: JSX.Element, - element?: HTMLElement, - destructor?: () => void - ) => () => void; - } - ) { - super({ - el: element, - tagName: 'section', - events: { - 'click .wb-upload': 'upload', - 'click .wb-validate': 'upload', - 'click .wb-data-check': 'toggleDataCheck', - 'click .wb-show-plan': 'showPlan', - 'click .wb-revert': 'revertChanges', - 'click .wb-save': 'save', - 'click .wb-export-data-set': 'export', - 'click .wb-change-data-set-owner': 'changeOwner', - - 'click .wb-show-upload-view': 'displayUploadedView', - 'click .wb-unupload': 'unupload', - }, - }); - - this.data = +export type Workbench = { + /* eslint-disable functional/prefer-readonly-type */ + cells: WbCellMeta; + disambiguation: Disambiguation; + validation: WbValidation; + utils: WbUtils; + undoRedoIsHandled: boolean; + /* eslint-enable functional/prefer-readonly-type */ + readonly dataset: Dataset; + readonly data: RA>; + readonly hot: Handsontable | undefined; + readonly throttleRate: number; + readonly mappings: WbMapping | undefined; + readonly cellCounts: GetSet; + readonly spreadsheetChanged: () => void; +}; + +export function WbView({ + dataset, + onDatasetDeleted: handleDatasetDeleted, + triggerDatasetRefresh, +}: { + readonly dataset: Dataset; + readonly onDatasetDeleted: () => void; + readonly triggerDatasetRefresh: () => void; +}): JSX.Element { + const data = React.useMemo>>( + () => dataset.rows.length === 0 ? [Array.from(dataset.columns).fill(null)] - : dataset.rows; - /* - * Throttle cell count update depending on the DS size (between 10ms and 2s) - * Even if throttling may not be needed for small Data Sets, wrapping the - * function in throttle allows to not worry about calling it several - * time in a very short amount of time. - * - */ - this.throttleRate = Math.ceil(clamp(10, this.data.length / 10, 2000)); - this.mappings = parseWbMappings(this.dataset); - - this.dataSetMeta = new DataSetNameView({ - dataset: this.dataset, - el: this.el, - display: this.options.display, - getRowCount: () => - this.hot === undefined - ? this.dataset.rows.length - : this.hot.countRows() - this.hot.countEmptyRows(true), - }); - this.wbUtils = new WbUtils(this); - this.cells = new WbCellMeta(this); - this.validation = new WbValidation(this); - this.actions = new WbActions(this); - this.disambiguation = new Disambiguation(this); - - this.isUploaded = - this.dataset.uploadresult !== null && this.dataset.uploadresult.success; - - this.refreshInitiatedBy = refreshInitiatedBy; - this.refreshInitiatorAborted = refreshInitiatorAborted; - - this.handleResize = throttle(() => this.hot?.render(), this.throttleRate); - } - - private readonly hooks = getHotHooks(this); - - render() { - this.$el.append( - wbViewTemplate( - this.isUploaded, - Boolean(this.dataset.uploadplan), - this.dataset.id - ) - ); - this.$el.attr('aria-label', wbText.workBench()); - - /* - * HOT Comments for last column overflow outside the viewport for a moment - * before getting repositioned by the afterOnCellMouseOver event handler. - * - * Hiding overflow prevents scroll bar from flickering on cell mouse over - * - */ - document.body.classList.add('overflow-x-hidden'); - - this.dataSetMeta.render(); - - if (typeof this.dataset.uploaderstatus === 'string') - this.actions.openStatus(this.dataset.uploaderstatus); - - this.cells.cellMeta = []; - - if (this.refreshInitiatedBy && this.refreshInitiatorAborted) - this.actions.operationAbortedMessage(); - - const initDataModelIntegration = async (): Promise => { - const pickLists = - this.mappings === undefined - ? {} - : await fetchWbPickLists( - this.dataset.columns, - this.mappings.tableNames, - this.mappings.lines - ); - this.hot?.batch(() => { - if ( - !this.isUploaded && - (this.mappings?.lines ?? []).length === 0 && - hasPermission('/workbench/dataset', 'update') - ) { - const dialog = this.options.display( - - {commonText.close()} - - {commonText.create()} - - - } - header={wbPlanText.noUploadPlan()} - onClose={() => dialog()} - > - {wbPlanText.noUploadPlanDescription()} - - ); - this.$('.wb-validate, .wb-data-check') - .prop('disabled', true) - .prop('title', wbText.wbValidateUnavailable()); - } else { - this.$('.wb-validate, .wb-data-check').prop('disabled', false); - this.$('.wb-show-upload-view') - .prop('disabled', false) - .prop('title', undefined); - } - - /* - * These methods update HOT's cells settings, which resets meta data - * Thus, need to run them first - */ - configureHandsontable( - this.hot!, - this.mappings, - this.dataset, - pickLists - ); - - if (this.dataset.rowresults) this.validation.getValidationResults(); - - // CHeck if any column is reordered - if (this.dataset.visualorder?.some((column, index) => column !== index)) - this.hot?.updateSettings({ - manualColumnMove: writable(this.dataset.visualorder), - }); - - this.wbUtils.findLocalityColumns(); - this.wbUtils.render(); - - this.hotIsReady = true; - - this.hotCommentsContainer = (document.getElementsByClassName( - 'htComments' - )[0] ?? undefined) as HTMLElement | undefined; - }); + : dataset.rows, + [dataset] + ); + + const spreadsheetContainerRef = React.useRef(null); + const [hotTable, setHotTable] = React.useState(null); + const hot = hotTable?.hotInstance ?? undefined; + + const isUploaded = + dataset.uploadresult !== null && dataset.uploadresult.success; + const isAlreadyReadOnly = React.useContext(ReadOnlyContext); + + const mappings = React.useMemo( + (): WbMapping | undefined => parseWbMappings(dataset), + [dataset] + ); + useErrorContext('mappings', mappings); + + // Throttle cell count update depending on the DS size (between 10ms and 2s) + const throttleRate = Math.ceil(clamp(10, data.length / 10, 2000)); + + const [hasUnsavedChanges, spreadsheetChanged, spreadsheetUpToDate] = + useBooleanState(); + useUnloadProtect(hasUnsavedChanges, wbText.wbUnloadProtect()); + + const [cellCounts, setCellCounts] = React.useState({ + newCells: 0, + invalidCells: 0, + searchResults: 0, + modifiedCells: 0, + }); + + const workbench = React.useMemo(() => { + const workbench: Workbench = { + data, + dataset, + hot, + mappings, + throttleRate, + cells: undefined!, + disambiguation: undefined!, + validation: undefined!, + utils: undefined!, + cellCounts: [cellCounts, setCellCounts], + undoRedoIsHandled: false, + spreadsheetChanged, }; - - legacyLoadingContext( - this.initHot().then(initDataModelIntegration).catch(crash) - ); - - this.validation.updateValidationButton(); - if (this.validation.validationMode === 'static' && !this.isUploaded) - this.wbUtils.toggleCellTypes('invalidCells', 'remove'); - - this.cells.flushIndexedCellData = true; - globalThis.addEventListener('resize', this.handleResize); - - return this; - } - - // Initialize Handsontable - async initHot(): Promise { - /* - * HOT and Backbone appear to conflict, unless HOT init is wrapped in - * globalThis.setTimeout(...,0) - */ - await new Promise((resolve) => setTimeout(resolve, 0)); - const hot = new Handsontable(this.$('.wb-spreadsheet')[0], { - // eslint-disable-next-line functional/prefer-readonly-type - data: this.data as (string | null)[][], - columns: Array.from( - // Last column is invisible and contains disambiguation metadata - { length: this.dataset.columns.length + 1 }, - (_, physicalCol) => ({ - // Get data from nth column for nth column - data: physicalCol, - }) - ), - colHeaders: (physicalCol) => { - const tableIcon = this.mappings?.mappedHeaders?.[physicalCol]; - const isMapped = tableIcon !== undefined; - const mappingCol = this.physicalColToMappingCol(physicalCol); - const tableName = - (typeof mappingCol === 'number' - ? this.mappings?.tableNames[mappingCol] - : undefined) ?? - tableIcon?.split('/').slice(-1)?.[0]?.split('.')?.[0]; - const tableLabel = isMapped - ? f.maybe(tableName, getTable)?.label ?? tableName ?? '' - : ''; - // REFACTOR: use new table icons - return `
- ${ - isMapped - ? `${tableLabel}` - : `${legacyNonJsxIcons.ban}` - } - - ${this.dataset.columns[physicalCol]} - -
`; - }, - hiddenColumns: { - // Hide the disambiguation column - columns: [this.dataset.columns.length], - indicators: false, - // @ts-expect-error Wrong Handsontable typing? - copyPasteEnabled: false, - }, - hiddenRows: { - rows: [], - indicators: false, - // @ts-expect-error Wrong Handsontable typing? - copyPasteEnabled: false, - }, - /* - * Number of blanks rows at the bottom of the spreadsheet. - * (allows to add new rows easily) - */ - minSpareRows: userPreferences.get('workBench', 'editor', 'minSpareRows'), - comments: { - displayDelay: 100, - }, - /* - * Need htCommentCell to apply default HOT comment box styles - * - * Since comments are only used for invalid cells, comment boxes - * contain the styles of invalid cells - * - */ - commentedCellClassName: 'htCommentCell', - placeholderCellClassName: 'htPlaceholder', - /* - * Disable default styles. The only type of front-end invalid cell - * right now is non-existed value for a read-only picklist. The error - * message for that case is handled separately - */ - invalidCellClassName: '-', - rowHeaders: true, - autoWrapCol: userPreferences.get('workBench', 'editor', 'autoWrapCol'), - autoWrapRow: userPreferences.get('workBench', 'editor', 'autoWrapRow'), - enterBeginsEditing: userPreferences.get( - 'workBench', - 'editor', - 'enterBeginsEditing' - ), - enterMoves: - userPreferences.get('workBench', 'editor', 'enterMoveDirection') === - 'col' - ? { col: 1, row: 0 } - : { col: 0, row: 1 }, - tabMoves: - userPreferences.get('workBench', 'editor', 'tabMoveDirection') === 'col' - ? { col: 1, row: 0 } - : { col: 0, row: 1 }, - manualColumnResize: true, - manualColumnMove: true, - outsideClickDeselects: false, - multiColumnSorting: true, - sortIndicator: true, - language: LANGUAGE, - // @ts-expect-error Wrong Handsontable typing? - contextMenu: { - items: ensure< - IR< - | Handsontable.contextMenu.MenuItemConfig - | Handsontable.contextMenu.PredefinedMenuItemKey - > - >()( - this.isUploaded - ? ({ - // Display uploaded record - upload_results: { - disableSelection: true, - isCommand: false, - renderer: (_hot, wrapper) => { - const { endRow: visualRow, endCol: visualCol } = - getSelectedRegions(hot).at(-1) ?? {}; - const physicalRow = hot.toPhysicalRow(visualRow ?? 0); - const physicalCol = hot.toPhysicalColumn(visualCol ?? 0); - - const createdRecords = - this.validation.uploadResults.newRecords[physicalRow]?.[ - physicalCol - ]; - - if ( - visualRow === undefined || - visualCol === undefined || - createdRecords === undefined || - !this.cells.getCellMeta(physicalRow, physicalCol, 'isNew') - ) { - wrapper.textContent = wbText.noUploadResultsAvailable(); - wrapper.parentElement?.classList.add('htDisabled'); - const span = document.createElement('span'); - span.style.display = 'none'; - return span; - } - - wrapper.setAttribute( - 'class', - `${wrapper.getAttribute('class')} flex flex-col !m-0 - pb-1 wb-uploaded-view-context-menu` - ); - wrapper.innerHTML = createdRecords - .map(([tableName, recordId, label]) => { - const tableLabel = - label === '' - ? strictGetTable(tableName).label - : label; - // REFACTOR: use new table icons - const tableIcon = getIcon(tableName) ?? unknownIcon; - - return ` - - ${tableLabel} - ${legacyNonJsxIcons.link} - `; - }) - .join(''); - - const div = document.createElement('div'); - div.style.display = 'none'; - return div; - }, - }, - } as const) - : ({ - row_above: { - disabled: () => - typeof this.uploadedView === 'function' || - typeof this.coordinateConverterView === 'function' || - !hasPermission('/workbench/dataset', 'update'), - }, - row_below: { - disabled: () => - typeof this.uploadedView === 'function' || - typeof this.coordinateConverterView === 'function' || - !hasPermission('/workbench/dataset', 'update'), - }, - remove_row: { - disabled: () => { - if ( - typeof this.uploadedView === 'function' || - typeof this.coordinateConverterView === 'function' || - !hasPermission('/workbench/dataset', 'update') - ) - return true; - // Or if called on the last row - const selectedRegions = getSelectedRegions(hot); - return ( - selectedRegions.length === 1 && - selectedRegions[0].startRow === this.data.length - 1 && - selectedRegions[0].startRow === selectedRegions[0].endRow - ); - }, - }, - disambiguate: { - name: wbText.disambiguate(), - disabled: (): boolean => - typeof this.uploadedView === 'function' || - typeof this.coordinateConverterView === 'function' || - !this.disambiguation.isAmbiguousCell() || - !hasPermission('/workbench/dataset', 'update'), - callback: () => - this.disambiguation.openDisambiguationDialog(), - }, - separator_1: '---------', - fill_down: this.wbUtils.fillCellsContextMenuItem('down'), - fill_up: this.wbUtils.fillCellsContextMenuItem('up'), - separator_2: '---------', - undo: { - disabled: () => - typeof this.uploadedView === 'function' || - this.hot?.isUndoAvailable() !== true || - !hasPermission('/workbench/dataset', 'update'), - }, - redo: { - disabled: () => - typeof this.uploadedView === 'function' || - this.hot?.isRedoAvailable() !== true || - !hasPermission('/workbench/dataset', 'update'), - }, - } as const) - ), - }, - licenseKey: 'non-commercial-and-evaluation', - stretchH: 'all', - readOnly: - this.isUploaded || !hasPermission('/workbench/dataset', 'update'), - ...Object.fromEntries( - Object.entries(this.hooks).map( - ([name, callback]) => [name, callback.bind(this)] as const - ) - ), + workbench.cells = new WbCellMeta(workbench); + workbench.disambiguation = new Disambiguation(workbench); + workbench.validation = new WbValidation(workbench); + workbench.utils = new WbUtils(workbench, spreadsheetContainerRef); + return workbench; + }, [dataset, hot]); + + useErrorContext('cells', workbench.cells); + useErrorContext('disambiguation', workbench.disambiguation); + useErrorContext('validation', workbench.validation); + useErrorContext('utils', workbench.utils); + + const checkDeletedFail = React.useCallback((statusCode: number): boolean => { + if (statusCode === Http.NOT_FOUND) handleDatasetDeleted(); + return statusCode === Http.NOT_FOUND; + }, []); + + const isMapped = mappings !== undefined; + const canUpdate = hasPermission('/workbench/dataset', 'update'); + + const [showToolkit, _openToolkit, _closeToolkit, toggleToolkit] = + useBooleanState(); + + const { showResults, closeResults, toggleResults } = useResults({ + hot, + workbench, + triggerDatasetRefresh, + }); + + const { openDisambiguationDialog, disambiguationDialogs } = + useDisambiguationDialog({ + hot, + mappings, + data, + validation: workbench.validation, + disambiguation: workbench.disambiguation, }); - this.hot = hot; - } - - remove(): this { - this.hot?.destroy(); - this.hot = undefined; - this.wbUtils.remove(); - this.dataSetMeta.remove(); - this.uploadedView?.(); - this.actions.status?.(); - this.validation.stopLiveValidation(); - globalThis.removeEventListener('resize', this.handleResize); - document.body.classList.remove('overflow-x-hidden'); - Backbone.View.prototype.remove.call(this); - return this; - } - - // Tools - displayUploadedView(event: MouseEvent): void { - const target = event.target as HTMLElement | null; - if (!this.dataset.rowresults || this.hot === undefined) return; - - if (this.uploadedView !== undefined) { - this.uploadedView(); - return; - } - if (this.validation.liveValidationStack.length > 0) { - const dialog = this.options.display( - dialog()} + const searchRef = React.useRef(null); + + return ( + +
+
- {wbText.unavailableWhileValidating()} -
- ); - return; - } - - const effects: WritableArray<() => void> = []; - const effectsCleanup: WritableArray<() => void> = []; - - const elementsToDisable = [ - ...[ - 'wb-data-check', - 'wb-replace-value', - 'wb-convert-coordinates', - 'wb-geolocate', - ].map((className) => this.el.getElementsByClassName(className)[0]), - ...Array.from(document.getElementsByClassName('wb-navigation-section')) - .filter((element) => - ['modifiedCells', 'invalidCells'].includes( - element.getAttribute('data-navigation-type') ?? '' - ) - ) - .flatMap((element) => - Array.from(element.getElementsByClassName('wb-cell-navigation')) - ), - ] - .filter((element) => element !== undefined) - .map( - (element) => - [element as HTMLButtonElement, element.getAttribute('title')] as const - ); - - effects.push(() => - elementsToDisable.forEach(([element]) => { - if (element === undefined || element === null) return; - element.disabled = true; - element.setAttribute('title', wbText.unavailableWhileViewingResults()); - }) - ); - effectsCleanup.push(() => - elementsToDisable.forEach(([element, title]) => { - if (element === undefined || element === null) return; - element.disabled = false; - element.setAttribute('title', title ?? ''); - }) - ); - - const isReadOnly = this.hot.getSettings().readOnly; - effects.push(() => this.hot!.updateSettings({ readOnly: true })); - effectsCleanup.push(() => - this.hot?.updateSettings({ readOnly: isReadOnly }) - ); - - const initialHiddenRows = getHotPlugin( - this.hot, - 'hiddenRows' - ).getHiddenRows(); - const initialHiddenCols = getHotPlugin( - this.hot, - 'hiddenColumns' - ).getHiddenColumns(); - const rowsToInclude = new Set(); - const colsToInclude = new Set(); - Object.entries(this.cells.cellMeta).forEach(([physicalRow, rowMeta]) => - rowMeta.forEach((metaArray, physicalCol) => { - if (!this.cells.getCellMetaFromArray(metaArray, 'isNew')) return; - rowsToInclude.add((physicalRow as unknown as number) | 0); - colsToInclude.add(physicalCol); - }) - ); - const rowsToHide = this.data - .map((_, physicalRow) => physicalRow) - .filter( - (physicalRow) => - !rowsToInclude.has(physicalRow) && - !initialHiddenRows.includes(physicalRow) - ) - .map(this.hot.toVisualRow); - const colsToHide = this.dataset.columns - .map((_, physicalCol) => physicalCol) - .filter( - (physicalCol) => - !colsToInclude.has(physicalCol) && - !initialHiddenCols.includes(physicalCol) - ) - .map(this.hot.toVisualColumn); - - effects.push(() => { - if (this.hot === undefined) return; - getHotPlugin(this.hot, 'hiddenRows').hideRows(rowsToHide); - getHotPlugin(this.hot, 'hiddenColumns').hideColumns(colsToHide); - }); - effectsCleanup.push(() => { - if (this.hot === undefined) return; - getHotPlugin(this.hot, 'hiddenRows').showRows( - rowsToHide.filter((visualRow) => !initialHiddenRows.includes(visualRow)) - ); - getHotPlugin(this.hot, 'hiddenColumns').showColumns( - colsToHide.filter((visualCol) => !initialHiddenCols.includes(visualCol)) - ); - }); - - const newCellsAreHidden = this.el.classList.contains('wb-hide-new-cells'); - effects.push(() => this.wbUtils.toggleCellTypes('newCells', 'remove')); - effectsCleanup.push(() => - newCellsAreHidden - ? this.wbUtils.toggleCellTypes('newCells', 'add') - : undefined - ); - - effects.push(() => { - target?.toggleAttribute('aria-pressed', true); - }); - effectsCleanup.push(() => { - target?.toggleAttribute('aria-pressed', false); - }); - - effects.push(() => this.el.classList.add('wb-show-upload-results')); - effectsCleanup.push(() => - this.el.classList.remove('wb-show-upload-results') - ); - - const uploadedViewWrapper = this.el.getElementsByClassName( - 'wb-uploaded-view-wrapper' - )[0]; - effects.push(() => { - uploadedViewWrapper.classList.remove('hidden'); - uploadedViewWrapper.classList.add('contents'); - }); - effectsCleanup.push(() => { - uploadedViewWrapper.classList.remove('contents'); - uploadedViewWrapper.classList.add('hidden'); - }); - - const runEffects = () => - [...effects, this.hot?.render.bind(this.hot) ?? f.void].forEach( - (effect) => effect() - ); - const runCleanup = () => - // If WbView.remove() was called, this.hot would be undefined here - [...effectsCleanup, this.hot?.render.bind(this.hot)].forEach( - (effectCleanup) => effectCleanup?.() - ); - - const container = document.createElement('div'); - this.uploadedView = this.options.display( - this.uploadedView?.()} - />, - container, - () => { - this.uploadedView = undefined; - runCleanup(); - } - ); - uploadedViewWrapper.append(container); - - runEffects(); - } - - protected changeOwner(): void { - this.dataSetMeta.changeOwner(); - } - - protected upload(event: MouseEvent): void { - this.actions.upload(event); - } - - protected unupload(): void { - this.actions.unupload(); - } - - protected toggleDataCheck(event: MouseEvent): void { - this.validation.toggleDataCheck(event); - } - - protected revertChanges(): void { - this.actions.revertChanges(); - } - - protected save(): void { - this.actions.save().catch(raise); - } - - protected export(): void { - const delimiter = userPreferences.get( - 'workBench', - 'editor', - 'exportFileDelimiter' - ); - - downloadDataSet( - this.dataset.name, - this.dataset.rows, - this.dataset.columns, - delimiter - ).catch(raise); - } - - // Helpers - /* - * MappingCol is the index of the lines line corresponding to - * a particular physicalCol. Since there can be unmapped columns, these - * indexes do not line up and need to be converted like this: - */ - public physicalColToMappingCol(physicalCol: number): number | undefined { - return this.mappings?.lines.findIndex( - ({ headerName }) => headerName === this.dataset.columns[physicalCol] - ); - } - - // Check if AJAX failed because Data Set was deleted - public checkDeletedFail(statusCode: number): boolean { - if (statusCode === Http.NOT_FOUND) this.options.onDeleted(); - return statusCode === Http.NOT_FOUND; - } - - // For debugging only. Used in the constructor in the events section - public showPlan(): void { - const dialog = this.options.display( - { - overwriteReadOnly(this.dataset, 'uploadplan', plan); - this.trigger('refresh'); - }} - onClose={(): void => dialog()} - onDeleted={this.options.onDeleted} - /> - ); - } + + + {commonText.tools()} + + + {canUpdate || isMapped ? ( + + {wbPlanText.dataMapper()} + + ) : undefined} + +
+ {showToolkit && typeof hot === 'object' ? ( + + ) : undefined} +
+ + {showResults && ( + + )} +
+ {disambiguationDialogs} + + + + ); } - -/* eslint-enable functional/no-this-expression */ diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts index 2b0d783a422..0ac6dfe3cda 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts @@ -1,4 +1,5 @@ import type Handsontable from 'handsontable'; +import type { Plugins } from 'handsontable/plugins'; import { getCache } from '../../utils/cache'; import { writable } from '../../utils/types'; @@ -19,7 +20,7 @@ export function configureHandsontable( setSort(hot, dataset); } -function identifyDefaultValues( +export function identifyDefaultValues( hot: Handsontable, mappings: WbMapping | undefined ): void { @@ -73,16 +74,14 @@ const hotPlugins = new WeakMap< Handsontable, // eslint-disable-next-line functional/prefer-readonly-type { - [key in keyof Handsontable.PluginsCollection]?: - | Handsontable.PluginsCollection[key] - | undefined; + [key in keyof Plugins]?: Plugins[key] | undefined; } >(); -export function getHotPlugin( +export function getHotPlugin( hot: Handsontable, pluginName: NAME -): Handsontable.PluginsCollection[NAME] { +): Plugins[NAME] { let plugins = hotPlugins.get(hot); if (plugins === undefined) { plugins = {}; diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/hooks.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/hooks.ts index 087cd2e1a3f..a58971906e9 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/hooks.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/hooks.ts @@ -1,4 +1,7 @@ import type Handsontable from 'handsontable'; +import type { Events } from 'handsontable/pluginHooks'; +import type { Action } from 'handsontable/plugins/undoRedo'; +import React from 'react'; import { backEndText } from '../../localization/backEnd'; import { whitespaceSensitive } from '../../localization/utils'; @@ -7,21 +10,32 @@ import { ping } from '../../utils/ajax/ping'; import { setCache } from '../../utils/cache'; import { f } from '../../utils/functools'; import type { RA } from '../../utils/types'; -import { ensure, overwriteReadOnly } from '../../utils/types'; +import { overwriteReadOnly } from '../../utils/types'; import { sortFunction } from '../../utils/utils'; -import { oneRem } from '../Atoms'; +import { LoadingContext } from '../Core/Contexts'; import { schema } from '../DataModel/schema'; -import { hasPermission } from '../Permissions/helpers'; import { getHotPlugin } from './handsontable'; -import type { WbView } from './WbView'; - -export function getHotHooks(wbView: WbView) { +import type { Workbench } from './WbView'; + +export function useHotHooks({ + workbench, + physicalColToMappingCol, + spreadsheetChanged, + checkDeletedFail, + isReadOnly, + isResultsOpen, +}: { + readonly workbench: Workbench; + readonly physicalColToMappingCol: (physicalCol: number) => number | undefined; + readonly spreadsheetChanged: () => void; + readonly checkDeletedFail: (statusCode: number) => boolean; + readonly isReadOnly: boolean; + readonly isResultsOpen: boolean; +}): Partial { let sortConfigIsSet: boolean = false; - let hotCommentsContainerRepositionTimeout: - | ReturnType - | undefined = undefined; + const loading = React.useContext(LoadingContext); - return ensure>()({ + return { /* * After cell is rendered, we need to reApply metaData classes * NOTE: @@ -31,55 +45,55 @@ export function getHotHooks(wbView: WbView) { * */ afterRenderer: (td, visualRow, visualCol, property, _value) => { - if (wbView.hot === undefined) { + if (workbench.hot === undefined) { td.classList.add('text-gray-500'); return; } - const physicalRow = wbView.hot.toPhysicalRow(visualRow); + const physicalRow = workbench.hot.toPhysicalRow(visualRow); const physicalCol = typeof property === 'number' ? property - : wbView.hot.toPhysicalColumn(visualCol); - if (physicalCol >= wbView.dataset.columns.length) return; - const metaArray = wbView.cells.cellMeta?.[physicalRow]?.[physicalCol]; - if (wbView.cells.getCellMetaFromArray(metaArray, 'isModified')) - wbView.cells.runMetaUpdateEffects( + : workbench.hot.toPhysicalColumn(visualCol); + if (physicalCol >= workbench.dataset.columns.length) return; + const metaArray = workbench.cells.cellMeta?.[physicalRow]?.[physicalCol]; + if (workbench.cells.getCellMetaFromArray(metaArray, 'isModified')) + workbench.cells.runMetaUpdateEffects( td, 'isModified', true, visualRow, visualCol ); - if (wbView.cells.getCellMetaFromArray(metaArray, 'isNew')) - wbView.cells.runMetaUpdateEffects( + if (workbench.cells.getCellMetaFromArray(metaArray, 'isNew')) + workbench.cells.runMetaUpdateEffects( td, 'isNew', true, visualRow, visualCol ); - if (wbView.cells.getCellMetaFromArray(metaArray, 'isSearchResult')) - wbView.cells.runMetaUpdateEffects( + if (workbench.cells.getCellMetaFromArray(metaArray, 'isSearchResult')) + workbench.cells.runMetaUpdateEffects( td, 'isSearchResult', true, visualRow, visualCol ); - if (wbView.mappings?.mappedHeaders?.[physicalCol] === undefined) + if (workbench.mappings?.mappedHeaders?.[physicalCol] === undefined) td.classList.add('text-gray-500'); - if (wbView.mappings?.coordinateColumns?.[physicalCol] !== undefined) + if (workbench.mappings?.coordinateColumns?.[physicalCol] !== undefined) td.classList.add('wb-coordinate-cell'); }, // Make HOT use defaultValues for validation if cell is empty beforeValidate: (value, _visualRow, property) => { - if (Boolean(value) || wbView.hot === undefined) return value; + if (Boolean(value) || workbench.hot === undefined) return value; - const visualCol = wbView.hot.propToCol(property); - const physicalCol = wbView.hot.toPhysicalColumn(visualCol); + const visualCol = workbench.hot.propToCol(property); + const physicalCol = workbench.hot.toPhysicalColumn(visualCol); - return wbView.mappings?.defaultValues[physicalCol] ?? value; + return workbench.mappings?.defaultValues[physicalCol] ?? value; }, afterValidate: ( @@ -88,12 +102,12 @@ export function getHotHooks(wbView: WbView) { visualRow, property ) => { - if (wbView.hot === undefined) return; - const visualCol = wbView.hot.propToCol(property); + if (workbench.hot === undefined) return; + const visualCol = workbench.hot.propToCol(property); - const physicalRow = wbView.hot.toPhysicalRow(visualRow); - const physicalCol = wbView.hot.toPhysicalColumn(visualCol); - const issues = wbView.cells.getCellMeta( + const physicalRow = workbench.hot.toPhysicalRow(visualRow); + const physicalCol = workbench.hot.toPhysicalColumn(visualCol); + const issues = workbench.cells.getCellMeta( physicalRow, physicalCol, 'issues' @@ -124,7 +138,7 @@ export function getHotHooks(wbView: WbView) { ), ]); if (JSON.stringify(issues) !== JSON.stringify(newIssues)) - wbView.cells.updateCellMeta( + workbench.cells.updateCellMeta( physicalRow, physicalCol, 'issues', @@ -132,14 +146,11 @@ export function getHotHooks(wbView: WbView) { ); }, - afterUndo: (data) => afterUndoRedo(wbView, 'undo', data), + afterUndo: (data) => afterUndoRedo(workbench, 'undo', data), - afterRedo: (data) => afterUndoRedo(wbView, 'redo', data), + afterRedo: (data) => afterUndoRedo(workbench, 'redo', data), - beforePaste: () => - !wbView.uploadedView && - !wbView.isUploaded && - hasPermission('/workbench/dataset', 'update'), + beforePaste: () => !isReadOnly, /* * If copying values from a 1x3 area and pasting into the last cell, HOT @@ -155,17 +166,18 @@ export function getHotHooks(wbView: WbView) { if (source !== 'CopyPaste.paste') return true; const filteredChanges = unfilteredChanges.filter( - ([, property]) => property < wbView.dataset.columns.length + ([, property]) => + (property as number) < workbench.dataset.columns.length ); if ( filteredChanges.length === unfilteredChanges.length || - wbView.hot === undefined + workbench.hot === undefined ) return true; - wbView.hot.setDataAtCell( + workbench.hot.setDataAtCell( filteredChanges.map(([visualRow, property, _oldValue, newValue]) => [ visualRow, - wbView.hot!.propToCol(property), + workbench.hot!.propToCol(property as number), newValue, ]), 'CopyPaste.paste' @@ -183,20 +195,21 @@ export function getHotHooks(wbView: WbView) { 'UndoRedo.undo', 'UndoRedo.redo', ].includes(source) || - wbView.hot === undefined || + workbench.hot === undefined || unfilteredChanges === null ) return; - const changes = unfilteredChanges .map(([visualRow, property, oldValue, newValue]) => ({ visualRow, - visualCol: wbView.hot!.propToCol(property), - physicalRow: wbView.hot!.toPhysicalRow(visualRow), + visualCol: workbench.hot!.propToCol(property), + physicalRow: workbench.hot!.toPhysicalRow(visualRow), physicalCol: typeof property === 'number' ? property - : wbView.hot!.toPhysicalColumn(wbView.hot!.propToCol(property)), + : workbench.hot!.toPhysicalColumn( + workbench.hot!.propToCol(property as number | string) + ), oldValue, newValue, })) @@ -211,7 +224,7 @@ export function getHotHooks(wbView: WbView) { // Or where value changed from null to empty (oldValue !== null || newValue !== '') && // Or the column does not exist (that can happen on paste) - visualCol < wbView.dataset.columns.length + visualCol < workbench.dataset.columns.length ); if (changes.length === 0) return; @@ -220,8 +233,7 @@ export function getHotHooks(wbView: WbView) { changes // Ignore changes to unmapped columns .filter( - ({ physicalCol }) => - wbView.physicalColToMappingCol(physicalCol) !== -1 + ({ physicalCol }) => physicalColToMappingCol(physicalCol) !== -1 ) .sort(sortFunction(({ visualRow }) => visualRow)) .map(({ physicalRow }) => physicalRow) @@ -229,12 +241,12 @@ export function getHotHooks(wbView: WbView) { /* * Don't clear disambiguation when afterChange is triggered by - * wbView.hot.undo() from inside of wbView.afterUndoRedo() + * hot.undo() from inside of afterUndoRedo() * FEATURE: consider not clearing disambiguation at all */ - if (!wbView.undoRedoIsHandled) + if (!workbench.undoRedoIsHandled) changedRows.forEach((physicalRow) => - wbView.disambiguation.clearDisambiguation(physicalRow) + workbench.disambiguation.clearDisambiguation(physicalRow) ); changes.forEach( @@ -247,42 +259,42 @@ export function getHotHooks(wbView: WbView) { newValue, }) => { if ( - wbView.cells.getCellMeta( + workbench.cells.getCellMeta( physicalRow, physicalCol, 'originalValue' ) === undefined ) - wbView.cells.setCellMeta( + workbench.cells.setCellMeta( physicalRow, physicalCol, 'originalValue', oldValue ); - wbView.cells.recalculateIsModifiedState(physicalRow, physicalCol, { + workbench.cells.recalculateIsModifiedState(physicalRow, physicalCol, { visualRow, visualCol, }); if ( - wbView.wbUtils.searchPreferences.search.liveUpdate && - wbView.wbUtils.searchQuery !== undefined + workbench.utils.searchPreferences.search.liveUpdate && + workbench.utils.searchQuery !== undefined ) - wbView.cells.updateCellMeta( + workbench.cells.updateCellMeta( physicalRow, physicalCol, 'isSearchResult', - wbView.wbUtils.searchFunction(newValue), + workbench.utils.searchFunction(newValue), { visualRow, visualCol } ); } ); - wbView.actions.spreadSheetChanged(); - wbView.cells.updateCellInfoStats(); + spreadsheetChanged(); + workbench.cells.updateCellInfoStats(); - if (wbView.dataset.uploadplan) + if (workbench.dataset.uploadplan) changedRows.forEach((physicalRow) => - wbView.validation.startValidateRow(physicalRow) + workbench.validation.startValidateRow(physicalRow) ); }, @@ -307,44 +319,44 @@ export function getHotHooks(wbView: WbView) { * If HOT is not yet fully initialized, we can assume that physical row * order and visual row order is the same */ - wbView.hot?.toPhysicalRow(visualRowStart + index) ?? + workbench.hot?.toPhysicalRow(visualRowStart + index) ?? visualRowStart + index // REFACTOR: use sortFunction here ).sort(); - wbView.cells.flushIndexedCellData = true; + workbench.cells.indexedCellMeta = undefined; addedRows - .filter((physicalRow) => physicalRow < wbView.cells.cellMeta.length) + .filter((physicalRow) => physicalRow < workbench.cells.cellMeta.length) .forEach((physicalRow) => - wbView.cells.cellMeta.splice(physicalRow, 0, []) + workbench.cells?.cellMeta.splice(physicalRow, 0, []) ); - if (wbView.hotIsReady && source !== 'auto') - wbView.actions.spreadSheetChanged(); + if (workbench.hot !== undefined && source !== 'auto') + spreadsheetChanged(); return true; }, beforeRemoveRow: (visualRowStart, amount, _, source) => { - if (wbView.hot === undefined) return; + if (workbench.hot === undefined) return; // Get indexes of removed rows in reverse order const removedRows = Array.from({ length: amount }, (_, index) => - wbView.hot!.toPhysicalRow(visualRowStart + index) + workbench.hot!.toPhysicalRow(visualRowStart + index) ) - .filter((physicalRow) => physicalRow < wbView.cells.cellMeta.length) + .filter((physicalRow) => physicalRow < workbench.cells.cellMeta.length) // REFACTOR: use sortFunction here .sort() .reverse(); removedRows.forEach((physicalRow) => { - wbView.cells.cellMeta.splice(physicalRow, 1); - wbView.validation.liveValidationStack.splice(physicalRow, 1); + workbench.cells.cellMeta.splice(physicalRow, 1); + workbench.validation.liveValidationStack.splice(physicalRow, 1); }); - wbView.cells.flushIndexedCellData = true; + workbench.cells.indexedCellMeta = undefined; - if (wbView.hotIsReady && source !== 'auto') { - wbView.actions.spreadSheetChanged(); - wbView.cells.updateCellInfoStats(); + if (source !== 'auto') { + spreadsheetChanged(); + workbench.cells.updateCellInfoStats(); } return true; @@ -356,30 +368,28 @@ export function getHotHooks(wbView: WbView) { * and sorting them in the same direction */ beforeColumnSort: (currentSortConfig, newSortConfig) => { - wbView.cells.flushIndexedCellData = true; - - if (wbView.coordinateConverterView) return false; + workbench.cells.indexedCellMeta = undefined; if ( - wbView.mappings === undefined || + workbench.mappings === undefined || sortConfigIsSet || - wbView.hot === undefined + workbench.hot === undefined ) return true; const findTreeColumns = ( - sortConfig: RA, - deltaSearchConfig: RA + sortConfig: RA, + deltaSearchConfig: RA ) => sortConfig .map(({ column: visualCol, sortOrder }) => ({ sortOrder, visualCol, - physicalCol: wbView.hot!.toPhysicalColumn(visualCol), + physicalCol: workbench.hot!.toPhysicalColumn(visualCol), })) .map(({ physicalCol, ...rest }) => ({ ...rest, - rankGroup: wbView + rankGroup: workbench .mappings!.treeRanks?.map((rankGroup, groupIndex) => ({ rankId: rankGroup.find( (mapping) => mapping.physicalCol === physicalCol @@ -420,11 +430,11 @@ export function getHotHooks(wbView: WbView) { * (lower rankId corresponds to a higher tree rank) * */ - const columnsToSort = wbView.mappings.treeRanks[ + const columnsToSort = workbench.mappings.treeRanks[ changedTreeColumn.rankGroup!.groupIndex ] .filter(({ rankId }) => rankId >= changedTreeColumn!.rankGroup!.rankId!) - .map(({ physicalCol }) => wbView.hot!.toVisualColumn(physicalCol)); + .map(({ physicalCol }) => workbench.hot!.toVisualColumn(physicalCol)); // Filter out columns that are about to be sorted const partialSortConfig = newSortConfig.filter( @@ -442,7 +452,7 @@ export function getHotHooks(wbView: WbView) { ]; sortConfigIsSet = true; - getHotPlugin(wbView.hot, 'multiColumnSorting').sort(fullSortConfig); + getHotPlugin(workbench.hot, 'multiColumnSorting').sort(fullSortConfig); sortConfigIsSet = false; return false; @@ -450,51 +460,46 @@ export function getHotHooks(wbView: WbView) { // Cache sort config to preserve column sort order across sessions afterColumnSort: async (_previousSortConfig, sortConfig) => { - if (wbView.hot === undefined) return; + if (workbench.hot === undefined) return; const physicalSortConfig = sortConfig.map((rest) => ({ ...rest, - physicalCol: wbView.hot!.toPhysicalColumn(rest.column), + physicalCol: workbench.hot!.toPhysicalColumn(rest.column), })); setCache( 'workBenchSortConfig', - `${schema.domainLevelIds.collection}_${wbView.dataset.id}`, + `${schema.domainLevelIds.collection}_${workbench.dataset.id}`, physicalSortConfig ); }, beforeColumnMove: (_columnIndexes, _finalIndex, dropIndex) => - // Don't allow moving columns when isReadOnly - !wbView.uploadedView && - !wbView.coordinateConverterView && - // An ugly fix for jQuery's dialogs conflicting with HOT - (dropIndex !== undefined || !wbView.hotIsReady), + !isResultsOpen && + (dropIndex !== undefined || workbench.hot !== undefined), // Save new visualOrder on the back end afterColumnMove: (_columnIndexes, _finalIndex, dropIndex) => { // An ugly fix for jQuery's dialogs conflicting with HOT - if ( - dropIndex === undefined || - !wbView.hotIsReady || - wbView.hot === undefined - ) - return; - - wbView.cells.flushIndexedCellData = true; + if (dropIndex === undefined || workbench.hot == undefined) return; + workbench.cells.indexedCellMeta = undefined; - const columnOrder = wbView.dataset.columns.map((_, visualCol) => - wbView.hot!.toPhysicalColumn(visualCol) + const columnOrder = workbench.dataset.columns.map((_, visualCol) => + workbench.hot!.toPhysicalColumn(visualCol) ); if ( - wbView.dataset.visualorder === null || - columnOrder.some((i, index) => i !== wbView.dataset.visualorder![index]) + workbench.dataset.visualorder === null || + columnOrder.some( + (i, index) => i !== workbench.dataset.visualorder![index] + ) ) { - overwriteReadOnly(wbView.dataset, 'visualorder', columnOrder); - ping(`/api/workbench/dataset/${wbView.dataset.id}/`, { - method: 'PUT', - body: { visualorder: columnOrder }, - expectedErrors: [Http.NOT_FOUND], - }).then(wbView.checkDeletedFail.bind(wbView)); + overwriteReadOnly(workbench.dataset, 'visualorder', columnOrder); + loading( + ping(`/api/workbench/dataset/${workbench.dataset.id}/`, { + method: 'PUT', + body: { visualorder: columnOrder }, + expectedErrors: [Http.NOT_FOUND], + }).then(checkDeletedFail) + ); } }, @@ -503,87 +508,27 @@ export function getHotHooks(wbView: WbView) { const lastCoords = coords.at(-1); if ( typeof lastCoords === 'object' && - data.some((row) => row.length === wbView.dataset.columns.length) && - wbView.hot !== undefined - ) - wbView.hot.scrollViewportTo(lastCoords.endRow, lastCoords.startCol); - }, - - /* - * Reposition the comment box if it is overflowing - * See https://github.com/specify/specify7/issues/932 - * REFACTOR: https://github.com/specify/specify7/issues/1925 - */ - afterOnCellMouseOver: (_event, coordinates, cell) => { - if (wbView.hot === undefined) return; - const physicalRow = wbView.hot.toPhysicalRow(coordinates.row); - const physicalCol = wbView.hot.toPhysicalColumn(coordinates.col); - - // Make sure cell has comments - if ( - wbView.cells.getCellMeta(physicalRow, physicalCol, 'issues').length === - 0 + data.some((row) => row.length === workbench.dataset.columns.length) && + workbench.hot !== undefined ) - return; - - const cellContainerBoundingBox = cell.getBoundingClientRect(); - - // Make sure box is overflowing horizontally - if (globalThis.innerWidth > cellContainerBoundingBox.right + oneRem * 2) - return; - - wbView.hotCommentsContainer?.style.setProperty( - '--offset-right', - `${Math.round(globalThis.innerWidth - cellContainerBoundingBox.x)}px` - ); - wbView.hotCommentsContainer?.classList.add( - 'right-[var(--offset-right)]', - '!left-[unset]' - ); - if (hotCommentsContainerRepositionTimeout) { - globalThis.clearTimeout(hotCommentsContainerRepositionTimeout); - hotCommentsContainerRepositionTimeout = undefined; - } - }, - - /* - * Revert comment box's position to original state if needed. - * The 10ms delay helps prevent visual artifacts when the mouse pointer - * moves between cells. - */ - afterOnCellMouseOut: () => { - if (hotCommentsContainerRepositionTimeout) - globalThis.clearTimeout(hotCommentsContainerRepositionTimeout); - if ( - wbView.hotCommentsContainer?.style.getPropertyValue( - '--offset-right' - ) !== '' - ) - hotCommentsContainerRepositionTimeout = globalThis.setTimeout( - () => - wbView.hotCommentsContainer?.classList.remove( - 'right-[var(--offset-right)]', - '!left-[unset]' - ), - 10 - ); + workbench.hot.scrollViewportTo(lastCoords.endRow, lastCoords.startCol); }, /* * Disallow user from selecting several times the same cell */ afterSelection: () => { - if (wbView.hot === undefined) return; - const selection = wbView.hot?.getSelected() ?? []; + if (workbench.hot === undefined) return; + const selection = workbench.hot?.getSelected() ?? []; const newSelection = f .unique(selection.map((row) => JSON.stringify(row))) .map((row) => JSON.parse(row)); if (newSelection.length !== selection.length) { - wbView.hot?.deselectCell(); - wbView.hot?.selectCells(newSelection); + workbench.hot?.deselectCell(); + workbench.hot?.selectCells(newSelection); } }, - }); + }; } /** @@ -594,22 +539,22 @@ export function getHotHooks(wbView: WbView) { * */ function afterUndoRedo( - wbView: WbView, + workbench: Workbench, type: 'redo' | 'undo', - data: Handsontable.plugins.UndoRedoAction + data: Action ): void { if ( - wbView.undoRedoIsHandled || + workbench.undoRedoIsHandled || data.actionType !== 'change' || data.changes.length !== 1 || - wbView.hot === undefined + workbench.hot === undefined ) return; const [visualRow, visualCol, newData, oldData] = data.changes[0]; - const physicalRow = wbView.hot.toPhysicalRow(visualRow); - const physicalCol = wbView.hot.toPhysicalColumn(visualCol as number); - if (physicalCol !== wbView.dataset.columns.length) return; + const physicalRow = workbench.hot.toPhysicalRow(visualRow); + const physicalCol = workbench.hot.toPhysicalColumn(visualCol as number); + if (physicalCol !== workbench.dataset.columns.length) return; const newValue = JSON.parse(newData || '{}').disambiguation; const oldValue = JSON.parse(oldData || '{}').disambiguation; @@ -627,10 +572,10 @@ function afterUndoRedo( ) // HOT doesn't seem to like calling undo from inside of afterUndo globalThis.setTimeout(() => { - wbView.undoRedoIsHandled = true; - wbView.hot?.undo(); - wbView.undoRedoIsHandled = false; - wbView.disambiguation.afterChangeDisambiguation(physicalRow); + workbench.undoRedoIsHandled = true; + workbench.hot?.undo(); + workbench.undoRedoIsHandled = false; + workbench.disambiguation.afterChangeDisambiguation(physicalRow); }, 0); - else wbView.disambiguation.afterChangeDisambiguation(physicalRow); + else workbench.disambiguation.afterChangeDisambiguation(physicalRow); } diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/hotProps.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/hotProps.tsx new file mode 100644 index 00000000000..e91763f109c --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/hotProps.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import ReactDOMServer from 'react-dom/server'; + +import { wbPlanText } from '../../localization/wbPlan'; +import { f } from '../../utils/functools'; +import { icons } from '../Atoms/Icons'; +import { getTable } from '../DataModel/tables'; +import { userPreferences } from '../Preferences/userPreferences'; +import type { Dataset } from '../WbPlanView/Wrapped'; +import type { WbMapping } from './mapping'; + +const comments = { displayDelay: 100 }; + +const hiddenRows = { + rows: [], + indicators: false, + // TODO: Typing possibly doesn't match for handsontable 12.1.0, fixed in 14 + copyPasteEnabled: false, +}; + +export function useHotProps({ + dataset, + mappings, + physicalColToMappingCol, +}: { + readonly dataset: Dataset; + readonly mappings: WbMapping | undefined; + readonly physicalColToMappingCol: (physicalCol: number) => number | undefined; +}) { + const [autoWrapCol] = userPreferences.use( + 'workBench', + 'editor', + 'autoWrapCol' + ); + + const [autoWrapRow] = userPreferences.use( + 'workBench', + 'editor', + 'autoWrapRow' + ); + + const columns = React.useMemo( + () => + Array.from( + // Last column is invisible and contains disambiguation metadata + { length: dataset.columns.length + 1 }, + (_, physicalCol) => ({ + // Get data from nth column for nth column + data: physicalCol, + }) + ), + [dataset.columns.length] + ); + + const [enterMovesPref] = userPreferences.use( + 'workBench', + 'editor', + 'enterMoveDirection' + ); + const enterMoves = + enterMovesPref === 'col' ? { col: 1, row: 0 } : { col: 0, row: 1 }; + + const colHeaders = React.useCallback( + (physicalCol: number) => { + const tableIcon = mappings?.mappedHeaders?.[physicalCol]; + const isMapped = tableIcon !== undefined; + const mappingCol = physicalColToMappingCol(physicalCol); + const tableName = + (typeof mappingCol === 'number' + ? mappings?.tableNames[mappingCol] + : undefined) ?? tableIcon?.split('/').slice(-1)?.[0]?.split('.')?.[0]; + const tableLabel = isMapped + ? f.maybe(tableName, getTable)?.label ?? tableName ?? '' + : ''; + // REFACTOR: use new table icons + return ReactDOMServer.renderToString( + + ); + }, + [mappings] + ); + + const [enterBeginsEditing] = userPreferences.use( + 'workBench', + 'editor', + 'enterBeginsEditing' + ); + + const hiddenColumns = React.useMemo( + () => ({ + // Hide the disambiguation column + columns: [dataset.columns.length], + indicators: false, + // TODO: Typing possibly doesn't match for handsontable 12.1.0, fixed in 14 + copyPasteEnabled: false, + }), + [] + ); + + const [minSpareRows] = userPreferences.use( + 'workBench', + 'editor', + 'minSpareRows' + ); + + const [tabMovesPref] = userPreferences.use( + 'workBench', + 'editor', + 'tabMoveDirection' + ); + const tabMoves = + tabMovesPref === 'col' ? { col: 1, row: 0 } : { col: 0, row: 1 }; + + return { + autoWrapCol, + autoWrapRow, + columns, + enterMoves, + colHeaders, + enterBeginsEditing, + hiddenRows, + hiddenColumns, + minSpareRows, + tabMoves, + comments, + }; +} + +function ColumnHeader({ + isMapped, + tableLabel, + tableIcon, + columnName, +}: { + readonly isMapped: boolean; + readonly tableLabel: string; + readonly tableIcon: string | undefined; + readonly columnName: string; +}): JSX.Element { + return ( +
+ {isMapped ? ( + {tableLabel} + ) : ( + + {icons.ban} + + )} + {columnName} +
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx new file mode 100644 index 00000000000..5bb210a296f --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import { useAsyncState } from '../../hooks/useAsyncState'; +import { useBooleanState } from '../../hooks/useBooleanState'; +import { useErrorContext } from '../../hooks/useErrorContext'; +import { wbText } from '../../localization/workbench'; +import { ajax } from '../../utils/ajax'; +import { f } from '../../utils/functools'; +import type { GetSet } from '../../utils/types'; +import { LoadingContext } from '../Core/Contexts'; +import { useMenuItem } from '../Header/MenuContext'; +import { treeRanksPromise } from '../InitialContext/treeRanks'; +import { LoadingScreen } from '../Molecules/Dialog'; +import { NotFoundView } from '../Router/NotFoundView'; +import type { Dataset } from '../WbPlanView/Wrapped'; +import { WbView } from './WbView'; + +export function WorkBench(): JSX.Element { + useMenuItem('workBench'); + + const [treeRanksLoaded = false] = useAsyncState(fetchTreeRanks, true); + const { id } = useParams(); + const datasetId = f.parseInt(id); + + const [dataset, setDataset] = useDataset(datasetId); + useErrorContext('dataSet', dataset); + + const loading = React.useContext(LoadingContext); + const [isDeleted, handleDeleted] = useBooleanState(); + + if (dataset === undefined || !treeRanksLoaded || dataset.id !== datasetId) + return ; + + const triggerDatasetRefresh = () => + loading(fetchDataset(dataset.id).then(setDataset)); + + return datasetId === undefined ? ( + + ) : isDeleted ? ( + <>{wbText.dataSetDeletedOrNotFound()} + ) : ( + + ); +} + +const fetchTreeRanks = async (): Promise => treeRanksPromise.then(f.true); + +// BUG: intercept 403 (if dataset has been transferred to another user) +function useDataset( + datasetId: number | undefined +): GetSet { + return useAsyncState( + React.useCallback( + async () => + typeof datasetId === 'number' ? fetchDataset(datasetId) : undefined, + [datasetId] + ), + true + ); +} + +const fetchDataset = async (datasetId: number): Promise => + ajax(`/api/workbench/dataset/${datasetId}/`, { + headers: { Accept: 'application/json' }, + }).then(({ data }) => data); diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/useDisambiguationDialog.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/useDisambiguationDialog.tsx new file mode 100644 index 00000000000..6272bb7090b --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/useDisambiguationDialog.tsx @@ -0,0 +1,159 @@ +import type Handsontable from 'handsontable'; +import React from 'react'; + +import { useBooleanState } from '../../hooks/useBooleanState'; +import { commonText } from '../../localization/common'; +import { wbText } from '../../localization/workbench'; +import { type RA } from '../../utils/types'; +import { LoadingContext } from '../Core/Contexts'; +import type { AnySchema } from '../DataModel/helperTypes'; +import type { Collection } from '../DataModel/specifyTable'; +import { strictGetTable } from '../DataModel/tables'; +import { Dialog } from '../Molecules/Dialog'; +import { hasTablePermission } from '../Permissions/helpers'; +import type { MappingPath } from '../WbPlanView/Mapper'; +import { mappingPathToString } from '../WbPlanView/mappingHelpers'; +import { getTableFromMappingPath } from '../WbPlanView/navigator'; +import { DisambiguationDialog } from './Disambiguation'; +import type { Disambiguation } from './DisambiguationLogic'; +import { getSelectedLast } from './hotHelpers'; +import type { WbMapping } from './mapping'; +import type { WbValidation } from './WbValidation'; + +type DisambiguationMatches = { + readonly physicalCols: RA; + readonly mappingPath: MappingPath; + readonly ids: RA; + readonly key: string; +}; + +export function useDisambiguationDialog({ + hot, + data, + disambiguation, + mappings, + validation, +}: { + readonly data: RA>; + readonly validation: WbValidation; + readonly disambiguation: Disambiguation; + readonly mappings: WbMapping | undefined; + readonly hot: Handsontable | undefined; +}): { + readonly openDisambiguationDialog: () => void; + readonly disambiguationDialogs: JSX.Element; +} { + const [disambiguationMatches, setDisambiguationMatches] = + React.useState(); + const [disambiguationPhysicalRow, setPhysicalRow] = React.useState(); + const [disambiguationResource, setResource] = + React.useState>(); + const [ + noDisambiguationDialog, + openNoDisambiguationDialog, + closeNoDisambiguationDialog, + ] = useBooleanState(); + + const [disambiguationDialog, openDisambiguation, closeDisambiguation] = + useBooleanState(); + + const loading = React.useContext(LoadingContext); + + const openDisambiguationDialog = React.useCallback(() => { + if (mappings === undefined || hot === undefined) return; + + const [visualRow, visualCol] = getSelectedLast(hot); + const physicalRow = hot.toPhysicalRow(visualRow); + const physicalCol = hot.toPhysicalColumn(visualCol); + + const matches = validation.uploadResults.ambiguousMatches[physicalRow].find( + ({ physicalCols }) => physicalCols.includes(physicalCol) + ); + if (matches === undefined) return; + const tableName = getTableFromMappingPath( + mappings.baseTable.name, + matches.mappingPath + ); + const table = strictGetTable(tableName); + const resources = new table.LazyCollection({ + filters: { id__in: matches.ids.join(',') }, + }) as Collection; + + loading( + (hasTablePermission(table.name, 'read') + ? resources.fetch({ limit: 0 }) + : Promise.resolve(resources) + ).then(({ models }) => { + if (models.length === 0) { + openNoDisambiguationDialog(); + return; + } + setDisambiguationMatches(matches); + setResource(resources); + setPhysicalRow(physicalRow); + openDisambiguation(); + }) + ); + }, [mappings, hot]); + + const disambiguationDialogs = ( + <> + {noDisambiguationDialog && ( + + {wbText.noDisambiguationResultsDescription()} + + )} + {disambiguationDialog && ( + { + disambiguation.setDisambiguation( + disambiguationPhysicalRow!, + disambiguationMatches!.mappingPath, + selected.id + ); + validation.startValidateRow(disambiguationPhysicalRow!); + hot?.render(); + }} + onSelectedAll={(selected): void => + // Loop backwards so the live validation will go from top to bottom + hot?.batch(() => { + for ( + let visualRow = data.length - 1; + visualRow >= 0; + visualRow-- + ) { + const physicalRow = hot.toPhysicalRow(visualRow); + const ambiguousMatchToDisambiguate = + validation.uploadResults.ambiguousMatches[physicalRow]?.find( + ({ key, mappingPath }) => + key === disambiguationMatches!.key && + typeof disambiguation.getDisambiguation(physicalRow)[ + mappingPathToString(mappingPath) + ] !== 'number' + ); + + if (ambiguousMatchToDisambiguate !== undefined) { + disambiguation.setDisambiguation( + physicalRow, + ambiguousMatchToDisambiguate.mappingPath, + selected.id + ); + validation.startValidateRow(physicalRow); + } + } + }) + } + /> + )} + + ); + + return { openDisambiguationDialog, disambiguationDialogs }; +} diff --git a/specifyweb/frontend/js_src/lib/utils/cache/definitions.ts b/specifyweb/frontend/js_src/lib/utils/cache/definitions.ts index ffc8eaf6809..bf23119527b 100644 --- a/specifyweb/frontend/js_src/lib/utils/cache/definitions.ts +++ b/specifyweb/frontend/js_src/lib/utils/cache/definitions.ts @@ -90,11 +90,9 @@ export type CacheDefinitions = { * WorkBench column sort setting in a given dataset * {Collection ID}_{Dataset ID} */ - [key in `${number}_${number}`]: RA< - hot.columnSorting.Config & { - readonly physicalCol: number; - } - >; + [key in `${number}_${number}`]: RA & { + readonly physicalCol: number; + }>; }; readonly sortConfig: { readonly [KEY in keyof SortConfigs]: SortConfig; diff --git a/specifyweb/frontend/js_src/lib/utils/functools.ts b/specifyweb/frontend/js_src/lib/utils/functools.ts index 39c23dd1528..573ec0c14f0 100644 --- a/specifyweb/frontend/js_src/lib/utils/functools.ts +++ b/specifyweb/frontend/js_src/lib/utils/functools.ts @@ -144,6 +144,23 @@ export const f = { const number = Number.parseInt(value); return Number.isNaN(number) ? undefined : number; }, + /** + * This is 10 times faster then Number.parseInt because of a slow + * Babel polyfill. + * + * Some caveats over f.parseInt or Number.parseInt: + * ```ts + * f.fastParseInt(undefined) // 0 + * f.fastParseInt(null) // 0 + * f.fastParseInt('') // 0 + * f.fastParseInt('nonNumber') // 0 + * ``` + */ + fastParseInt(value: string): number { + // TODO: update babel config to not polyfil Number.parseInt and then replace fastParseInt usages with Number.parseInt + // eslint-disable-next-line unicorn/prefer-math-trunc, no-bitwise + return (value as unknown as number) | 0; + }, /** Like f.parseInt, but for floats */ parseFloat(value: string | undefined): number | undefined { if (value === undefined) return undefined; diff --git a/specifyweb/frontend/js_src/package-lock.json b/specifyweb/frontend/js_src/package-lock.json index 205bd49b754..f714e1145d5 100644 --- a/specifyweb/frontend/js_src/package-lock.json +++ b/specifyweb/frontend/js_src/package-lock.json @@ -14,6 +14,7 @@ "@codemirror/language": "^6.2.0", "@codemirror/legacy-modes": "^6.1.0", "@floating-ui/react": "^0.19.1", + "@handsontable/react": "^12.4.0", "@headlessui/react": "^1.6.6", "@shopify/draggable": "^1.1.3", "@uiw/codemirror-theme-okaidia": "^4.10.4", @@ -26,7 +27,7 @@ "d3": "^7.6.1", "dayjs": "^1.10.7", "globals": "^13.19.0", - "handsontable": "9.0.0", + "handsontable": "^12.1.0", "jquery": "~1.12.0", "leaflet": "^1.7.1", "leaflet-gesture-handling": "^1.2.2", @@ -2686,6 +2687,14 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@handsontable/react": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/@handsontable/react/-/react-12.4.0.tgz", + "integrity": "sha512-qCjZq5TuJTvYKIl3B111rw5X3vlmNckW7520izSJKdDOVxlPr6aLCr13yYL+0eFtBIMYl6M3logzkFFh7z72ew==", + "peerDependencies": { + "handsontable": ">=12.0.0" + } + }, "node_modules/@headlessui/react": { "version": "1.6.6", "license": "MIT", @@ -6364,9 +6373,10 @@ } }, "node_modules/core-js": { - "version": "3.23.4", + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.36.0.tgz", + "integrity": "sha512-mt7+TUBbTFg5+GngsAxeKBTl5/VS0guFeJacYge9OmHb+m058UwwIm41SE9T4Den7ClatV57B6TYTuJ0CX1MAw==", "hasInstallScript": true, - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -9257,23 +9267,25 @@ } }, "node_modules/handsontable": { - "version": "9.0.0", - "license": "SEE LICENSE IN LICENSE.txt", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/handsontable/-/handsontable-12.1.0.tgz", + "integrity": "sha512-MdplUt3MNc1Ir6dTIlwHUOjCm0GTvB3RpKvvBgb9Jzp/TYVZ3YbfpS6II6Q5sYpUh40ILgOkDtS9gdZTM/OFsQ==", "dependencies": { "@types/pikaday": "1.7.4", "core-js": "^3.0.0", "dompurify": "^2.1.1", - "moment": "2.24.0", + "moment": "2.29.3", "numbro": "2.1.2", - "pikaday": "1.8.0" + "pikaday": "1.8.2" }, "optionalDependencies": { - "hyperformula": "0.6.2" + "hyperformula": "^2.0.0" } }, "node_modules/handsontable/node_modules/moment": { - "version": "2.24.0", - "license": "MIT", + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.3.tgz", + "integrity": "sha512-c6YRvhEo//6T2Jz/vVtYzqBzwvPT95JBQ+smCytzf7c50oMZRsR/a4w88aD34I+/QVSfnoAnSBFPJHItlOMJVw==", "engines": { "node": "*" } @@ -9492,23 +9504,19 @@ } }, "node_modules/hyperformula": { - "version": "0.6.2", - "license": "SEE LICENSE IN LICENSE.txt", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/hyperformula/-/hyperformula-2.6.2.tgz", + "integrity": "sha512-PtrYbEi+qyXEc1GSN8bhQqdOeDh6wajWpZajAMhJiJT/XRlXNm7LhSbkvi0cCCBGJHu+8zP3Y5qDmrzbdCh0QA==", "optional": true, "dependencies": { "chevrotain": "^6.5.0", - "core-js": "^3.6.4", - "regenerator-runtime": "^0.13.3", - "tiny-emitter": "^2.1.0", - "unorm": "^1.6.0" - }, - "optionalDependencies": { - "gpu.js": "2.3.0" + "tiny-emitter": "^2.1.0" } }, "node_modules/hyperformula/node_modules/chevrotain": { "version": "6.5.0", - "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-6.5.0.tgz", + "integrity": "sha512-BwqQ/AgmKJ8jcMEjaSnfMybnKMgGTrtDKowfTP3pX4jwVy0kNjRsT/AP6h+wC3+3NC+X8X15VWBnTCQlX+wQFg==", "optional": true, "dependencies": { "regexp-to-ast": "0.4.0" @@ -9516,7 +9524,8 @@ }, "node_modules/hyperformula/node_modules/regexp-to-ast": { "version": "0.4.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.4.0.tgz", + "integrity": "sha512-4qf/7IsIKfSNHQXSwial1IFmfM1Cc/whNBQqRwe0V2stPe7KmN1U0tWQiIx6JiirgSrisjE0eECdNf7Tav1Ntw==", "optional": true }, "node_modules/iconv-lite": { @@ -12028,8 +12037,9 @@ } }, "node_modules/moment": { - "version": "2.29.1", - "license": "MIT", + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", "engines": { "node": "*" } @@ -12712,8 +12722,9 @@ } }, "node_modules/pikaday": { - "version": "1.8.0", - "license": "(0BSD OR MIT)" + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/pikaday/-/pikaday-1.8.2.tgz", + "integrity": "sha512-TNtsE+34BIax3WtkB/qqu5uepV1McKYEgvL3kWzU7aqPCpMEN6rBF3AOwu4WCwAealWlBGobXny/9kJb49C1ew==" }, "node_modules/pirates": { "version": "4.0.6", @@ -15573,7 +15584,8 @@ }, "node_modules/tiny-emitter": { "version": "2.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", "optional": true }, "node_modules/tmpl": { @@ -16120,14 +16132,6 @@ "node": ">= 4.0.0" } }, - "node_modules/unorm": { - "version": "1.6.0", - "license": "MIT or GPL-2.0", - "optional": true, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/unset-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", @@ -18622,6 +18626,12 @@ "@floating-ui/dom": "^1.1.1" } }, + "@handsontable/react": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/@handsontable/react/-/react-12.4.0.tgz", + "integrity": "sha512-qCjZq5TuJTvYKIl3B111rw5X3vlmNckW7520izSJKdDOVxlPr6aLCr13yYL+0eFtBIMYl6M3logzkFFh7z72ew==", + "requires": {} + }, "@headlessui/react": { "version": "1.6.6", "requires": {} @@ -21368,7 +21378,9 @@ "dev": true }, "core-js": { - "version": "3.23.4" + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.36.0.tgz", + "integrity": "sha512-mt7+TUBbTFg5+GngsAxeKBTl5/VS0guFeJacYge9OmHb+m058UwwIm41SE9T4Den7ClatV57B6TYTuJ0CX1MAw==" }, "core-js-compat": { "version": "3.23.4", @@ -23372,19 +23384,23 @@ } }, "handsontable": { - "version": "9.0.0", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/handsontable/-/handsontable-12.1.0.tgz", + "integrity": "sha512-MdplUt3MNc1Ir6dTIlwHUOjCm0GTvB3RpKvvBgb9Jzp/TYVZ3YbfpS6II6Q5sYpUh40ILgOkDtS9gdZTM/OFsQ==", "requires": { "@types/pikaday": "1.7.4", "core-js": "^3.0.0", "dompurify": "^2.1.1", - "hyperformula": "0.6.2", - "moment": "2.24.0", + "hyperformula": "^2.0.0", + "moment": "2.29.3", "numbro": "2.1.2", - "pikaday": "1.8.0" + "pikaday": "1.8.2" }, "dependencies": { "moment": { - "version": "2.24.0" + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.3.tgz", + "integrity": "sha512-c6YRvhEo//6T2Jz/vVtYzqBzwvPT95JBQ+smCytzf7c50oMZRsR/a4w88aD34I+/QVSfnoAnSBFPJHItlOMJVw==" } } }, @@ -23543,19 +23559,19 @@ "dev": true }, "hyperformula": { - "version": "0.6.2", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/hyperformula/-/hyperformula-2.6.2.tgz", + "integrity": "sha512-PtrYbEi+qyXEc1GSN8bhQqdOeDh6wajWpZajAMhJiJT/XRlXNm7LhSbkvi0cCCBGJHu+8zP3Y5qDmrzbdCh0QA==", "optional": true, "requires": { "chevrotain": "^6.5.0", - "core-js": "^3.6.4", - "gpu.js": "2.3.0", - "regenerator-runtime": "^0.13.3", - "tiny-emitter": "^2.1.0", - "unorm": "^1.6.0" + "tiny-emitter": "^2.1.0" }, "dependencies": { "chevrotain": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-6.5.0.tgz", + "integrity": "sha512-BwqQ/AgmKJ8jcMEjaSnfMybnKMgGTrtDKowfTP3pX4jwVy0kNjRsT/AP6h+wC3+3NC+X8X15VWBnTCQlX+wQFg==", "optional": true, "requires": { "regexp-to-ast": "0.4.0" @@ -23563,6 +23579,8 @@ }, "regexp-to-ast": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.4.0.tgz", + "integrity": "sha512-4qf/7IsIKfSNHQXSwial1IFmfM1Cc/whNBQqRwe0V2stPe7KmN1U0tWQiIx6JiirgSrisjE0eECdNf7Tav1Ntw==", "optional": true } } @@ -25386,7 +25404,9 @@ } }, "moment": { - "version": "2.29.1" + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" }, "mrmime": { "version": "1.0.1", @@ -25863,7 +25883,9 @@ "dev": true }, "pikaday": { - "version": "1.8.0" + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/pikaday/-/pikaday-1.8.2.tgz", + "integrity": "sha512-TNtsE+34BIax3WtkB/qqu5uepV1McKYEgvL3kWzU7aqPCpMEN6rBF3AOwu4WCwAealWlBGobXny/9kJb49C1ew==" }, "pirates": { "version": "4.0.6", @@ -27720,6 +27742,8 @@ }, "tiny-emitter": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", "optional": true }, "tmpl": { @@ -28106,10 +28130,6 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true }, - "unorm": { - "version": "1.6.0", - "optional": true - }, "unset-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", diff --git a/specifyweb/frontend/js_src/package.json b/specifyweb/frontend/js_src/package.json index e16fd5dc234..3a58b51556a 100644 --- a/specifyweb/frontend/js_src/package.json +++ b/specifyweb/frontend/js_src/package.json @@ -34,6 +34,7 @@ "@codemirror/language": "^6.2.0", "@codemirror/legacy-modes": "^6.1.0", "@floating-ui/react": "^0.19.1", + "@handsontable/react": "^12.4.0", "@headlessui/react": "^1.6.6", "@shopify/draggable": "^1.1.3", "@uiw/codemirror-theme-okaidia": "^4.10.4", @@ -46,7 +47,7 @@ "d3": "^7.6.1", "dayjs": "^1.10.7", "globals": "^13.19.0", - "handsontable": "9.0.0", + "handsontable": "^12.1.0", "jquery": "~1.12.0", "leaflet": "^1.7.1", "leaflet-gesture-handling": "^1.2.2",