diff --git a/specifyweb/export/dwca.py b/specifyweb/export/dwca.py index 50b3c5330a5..d647f616cbc 100644 --- a/specifyweb/export/dwca.py +++ b/specifyweb/export/dwca.py @@ -12,8 +12,8 @@ from xml.etree import ElementTree as ET from xml.dom import minidom -from specifyweb.stored_queries.execution import EphemeralField, query_to_csv -from specifyweb.stored_queries.queryfield import QueryField +from specifyweb.stored_queries.execution import query_to_csv +from specifyweb.stored_queries.queryfield import QueryField, EphemeralField from specifyweb.stored_queries.models import session_context logger = logging.getLogger(__name__) diff --git a/specifyweb/express_search/related.py b/specifyweb/express_search/related.py index b09c1a7fe32..d811a673316 100644 --- a/specifyweb/express_search/related.py +++ b/specifyweb/express_search/related.py @@ -5,67 +5,79 @@ import logging from ..specify.models import datamodel -from ..stored_queries.execution import build_query +from ..stored_queries.execution import BuildQueryProps, build_query from ..stored_queries.query_ops import QueryOps from ..stored_queries.queryfield import QueryField from ..stored_queries.queryfieldspec import QueryFieldSpec logger = logging.getLogger(__name__) + class F(str): pass + class RelatedSearchMeta(type): def __new__(cls, name, bases, dict): Rs = super(RelatedSearchMeta, cls).__new__(cls, name, bases, dict) if Rs.definitions is None: return Rs - root_table_name = Rs.definitions[0].split('.')[0] + root_table_name = Rs.definitions[0].split(".")[0] Rs.root = datamodel.get_table(root_table_name, strict=True) def col_to_fs(col, add_id=False): - return QueryFieldSpec.from_path( [root_table_name] + col.split('.'), add_id ) + return QueryFieldSpec.from_path([root_table_name] + col.split("."), add_id) Rs.display_fields = [ - QueryField(fieldspec=col_to_fs(col), - op_num=1, - value="", - negate=False, - display=True, - format_name=None, - sort_type=0, - strict=False) - for col in Rs.columns] - - if Rs.link: - Rs.display_fields.append(QueryField( - fieldspec=col_to_fs(Rs.link, add_id=True), + QueryField( + fieldspec=col_to_fs(col), op_num=1, value="", negate=False, display=True, format_name=None, sort_type=0, - strict=False)) + strict=False + ) + for col in Rs.columns + ] + + if Rs.link: + Rs.display_fields.append( + QueryField( + fieldspec=col_to_fs(Rs.link, add_id=True), + op_num=1, + value="", + negate=False, + display=True, + format_name=None, + sort_type=0, + strict=False + ) + ) def make_filter(f, negate): field, op, val = f - return QueryField(fieldspec=col_to_fs(field), - op_num=QueryOps.OPERATIONS.index(op.__name__), - value=col_to_fs(val) if isinstance(val, F) else val, - negate=negate, - display=False, - format_name=None, - sort_type=0, - strict=False) - - Rs.filter_fields = [make_filter(f, False) for f in Rs.filters] + \ - [make_filter(f, True) for f in Rs.excludes] + return QueryField( + fieldspec=col_to_fs(field), + op_num=QueryOps.OPERATIONS.index(op.__name__), + value=col_to_fs(val) if isinstance(val, F) else val, + negate=negate, + display=False, + format_name=None, + sort_type=0, + strict=False + ) + + Rs.filter_fields = [make_filter(f, False) for f in Rs.filters] + [ + make_filter(f, True) for f in Rs.excludes + ] return Rs + class RelatedSearch(object, metaclass=RelatedSearchMeta): distinct = False filters = [] @@ -76,9 +88,14 @@ class RelatedSearch(object, metaclass=RelatedSearchMeta): @classmethod def execute(cls, session, config, terms, collection, user, limit, offset): - queries = [_f for _f in ( - cls(defn).build_related_query(session, config, terms, collection, user) - for defn in cls.definitions) if _f] + queries = [ + _f + for _f in ( + cls(defn).build_related_query(session, config, terms, collection, user) + for defn in cls.definitions + ) + if _f + ] if len(queries) > 0: query = queries[0].union(*queries[1:]) @@ -89,53 +106,68 @@ def execute(cls, session, config, terms, collection, user, limit, offset): results = [] return { - 'totalCount': count, - 'results': results, - 'definition': { - 'name': cls.__name__, - 'root': cls.root.name, - 'link': cls.link, - 'columns': cls.columns, - 'fieldSpecs': [{'stringId': fs.to_stringid(), 'isRelationship': fs.is_relationship()} - for field in cls.display_fields - for fs in [field.fieldspec]]}} + "totalCount": count, + "results": results, + "definition": { + "name": cls.__name__, + "root": cls.root.name, + "link": cls.link, + "columns": cls.columns, + "fieldSpecs": [ + { + "stringId": fs.to_stringid(), + "isRelationship": fs.is_relationship(), + } + for field in cls.display_fields + for fs in [field.fieldspec] + ], + }, + } def __init__(self, definition): self.definition = definition def build_related_query(self, session, config, terms, collection, user): - logger.info('%s: building related query using definition: %s', - self.__class__.__name__, self.definition) + logger.info( + "%s: building related query using definition: %s", + self.__class__.__name__, + self.definition, + ) from .views import build_primary_query - primary_fieldspec = QueryFieldSpec.from_path(self.definition.split('.'), add_id=True) + primary_fieldspec = QueryFieldSpec.from_path( + self.definition.split("."), add_id=True + ) pivot = primary_fieldspec.table - logger.debug('pivoting on: %s', pivot) - for searchtable in config.findall('tables/searchtable'): - if searchtable.find('tableName').text == pivot.name: + logger.debug("pivoting on: %s", pivot) + for searchtable in config.findall("tables/searchtable"): + if searchtable.find("tableName").text == pivot.name: break else: return None - logger.debug('using %s for primary search', searchtable.find('tableName').text) - primary_query = build_primary_query(session, searchtable, terms, collection, user, as_scalar=True) + logger.debug("using %s for primary search", searchtable.find("tableName").text) + primary_query = build_primary_query( + session, searchtable, terms, collection, user, as_scalar=True + ) if primary_query is None: return None - logger.debug('primary query: %s', primary_query) + logger.debug("primary query: %s", primary_query) primary_field = QueryField( fieldspec=primary_fieldspec, - op_num=QueryOps.OPERATIONS.index('op_in'), + op_num=QueryOps.OPERATIONS.index("op_in"), value=primary_query, negate=False, display=False, format_name=None, sort_type=0, - strict=False) + strict=False + ) logger.debug("primary queryfield: %s", primary_field) logger.debug("display queryfields: %s", self.display_fields) @@ -143,10 +175,17 @@ def build_related_query(self, session, config, terms, collection, user): queryfields = self.display_fields + self.filter_fields + [primary_field] - related_query, _ = build_query(session, collection, user, self.root.tableId, queryfields, implicit_or=False) + related_query, _ = build_query( + session, + collection, + user, + self.root.tableId, + queryfields, + props=BuildQueryProps(implicit_or=True), + ) if self.distinct: related_query = related_query.distinct() - logger.debug('related query: %s', related_query) + logger.debug("related query: %s", related_query) return related_query diff --git a/specifyweb/frontend/js_src/css/main.css b/specifyweb/frontend/js_src/css/main.css index 985f7723388..1cd3266028e 100644 --- a/specifyweb/frontend/js_src/css/main.css +++ b/specifyweb/frontend/js_src/css/main.css @@ -150,6 +150,7 @@ /* Make spinner buttons larger */ [type='number']:not([readonly], .no-arrows)::-webkit-outer-spin-button, [type='number']:not([readonly], .no-arrows)::-webkit-inner-spin-button { + -webkit-appearance: inner-spin-button !important; @apply absolute right-0 top-0 h-full w-2; } @@ -255,10 +256,16 @@ --invalid-cell: theme('colors.red.300'); --modified-cell: theme('colors.yellow.250'); --search-result: theme('colors.green.300'); - @apply dark:[--invalid-cell:theme('colors.red.900')] + --updated-cell: theme('colors.cyan.200'); + --deleted-cell: theme('colors.amber.500'); + --matched-and-changed-cell: theme('colors.blue.200'); + @apply dark:[--deleted-cell:theme('colors.amber.600')] + dark:[--invalid-cell:theme('colors.red.900')] + dark:[--matched-and-changed-cell:theme('colors.fuchsia.700')] dark:[--modified-cell:theme('colors.yellow.900')] dark:[--new-cell:theme('colors.indigo.900')] - dark:[--search-result:theme('colors.green.900')]; + dark:[--search-result:theme('colors.green.900')] + dark:[--updated-cell:theme('colors.cyan.800')]; } .custom-select { diff --git a/specifyweb/frontend/js_src/css/workbench.css b/specifyweb/frontend/js_src/css/workbench.css index 43c39e0ac9f..79089c3d122 100644 --- a/specifyweb/frontend/js_src/css/workbench.css +++ b/specifyweb/frontend/js_src/css/workbench.css @@ -38,7 +38,10 @@ } /* CONTENT styles */ -.wbs-form.wb-show-upload-results .wb-no-match-cell, +.wbs-form.wb-show-upload-results .wb-no-match-cell +.wbs-form.wb-show-upload-results .wb-updated-cell +.wbs-form.wb-show-upload-results .wb-deleted-cell +.wbs-form.wb-show-upload-results .wb-matched-and-changed-cell .wbs-form.wb-focus-coordinates .wb-coordinate-cell { @apply text-black dark:text-white; } @@ -54,7 +57,10 @@ .wb-no-match-cell, .wb-modified-cell, .htCommentCell, - .wb-search-match-cell + .wb-search-match-cell, + .wb-updated-cell, + .wb-deleted-cell, + .wb-matched-and-changed-cell ), .wb-navigation-section { @apply !bg-[color:var(--accent-color)]; @@ -62,6 +68,21 @@ /* The order here determines the priority of the states * From the lowest till the highest */ +.wbs-form:not(.wb-hide-new-cells) .wb-updated-cell, +.wb-navigation-section[data-navigation-type='updatedCells'] { + --accent-color: var(--updated-cell); +} + +.wbs-form:not(.wb-hide-new-cells) .wb-deleted-cell, +.wb-navigation-section[data-navigation-type='deletedCells'] { + --accent-color: var(--deleted-cell); +} + +.wbs-form:not(.wb-hide-new-cells) .wb-matched-and-changed-cell, +.wb-navigation-section[data-navigation-type='matchedAndChangedCells'] { + --accent-color: var(--matched-and-changed-cell); +} + .wbs-form:not(.wb-hide-new-cells) .wb-no-match-cell, .wb-navigation-section[data-navigation-type='newCells'] { --accent-color: var(--new-cell); diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx index 2507bddd7c2..04bd779c4a0 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx @@ -78,7 +78,7 @@ export const icons = { documentReport: , documentSearch: , dotsVertical: , - download: , + download: , duplicate: , exclamation: , exclamationCircle: , diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx index be75e27484e..fda1cfda79d 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx @@ -98,7 +98,7 @@ function ModifyDataset({ } const createEmpty = async (name: LocalizedString) => - createEmptyDataSet('/attachment_gw/dataset/', name, { + createEmptyDataSet('bulkAttachment', name, { uploadplan: { staticPathKey: undefined }, uploaderstatus: 'main', }); @@ -241,7 +241,7 @@ const getNamePromise = async () => date: new Date().toDateString(), }), undefined, - '/attachment_gw/dataset/' + 'bulkAttachment' ); function NewDataSet(): JSX.Element | null { diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/PerformAttachmentTask.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/PerformAttachmentTask.ts index d70bd2e1777..9383b0fdfca 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/PerformAttachmentTask.ts +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/PerformAttachmentTask.ts @@ -69,8 +69,8 @@ export function PerformAttachmentTask({ uploaded: (nextIndex === currentIndex ? 0 : 1) + progress.uploaded, })); workRef.current.mappedFiles = workRef.current.mappedFiles.map( - (uploadble, postIndex) => - postIndex === currentIndex ? postUpload : uploadble + (uploadable, postIndex) => + postIndex === currentIndex ? postUpload : uploadable ); }; diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/RenameDataSet.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/RenameDataSet.tsx index 1c9980d459d..1595be31bbc 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/RenameDataSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/RenameDataSet.tsx @@ -28,7 +28,7 @@ export function AttachmentDatasetMeta({ return ( diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx index 6a32fe1d960..276b19add08 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx @@ -14,6 +14,7 @@ import { getResourceViewUrl } from '../DataModel/resource'; import type { Tables } from '../DataModel/types'; import { GenericSortedDataViewer } from '../Molecules/GenericSortedDataViewer'; import { useDragDropFiles } from '../Molecules/useDragDropFiles'; +import { datasetVariants } from '../WbUtils/datasetVariants'; import type { PartialAttachmentUploadSpec } from './Import'; import { ResourceDisambiguationDialog } from './ResourceDisambiguation'; import type { PartialUploadableFileSpec } from './types'; @@ -230,7 +231,7 @@ function StartUploadDescription(): JSX.Element {
  • {attachmentsText.chooseFilesToGetStarted()}
  • {attachmentsText.selectIdentifier()}
  • - + {headerText.documentation()} diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx new file mode 100644 index 00000000000..af43b365209 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -0,0 +1,330 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { batchEditText } from '../../localization/batchEdit'; +import { commonText } from '../../localization/common'; +import { ajax } from '../../utils/ajax'; +import { f } from '../../utils/functools'; +import type { RA } from '../../utils/types'; +import { defined, filterArray } from '../../utils/types'; +import { group, keysToLowerCase, sortFunction } from '../../utils/utils'; +import { H2, H3 } from '../Atoms'; +import { Button } from '../Atoms/Button'; +import { dialogIcons } from '../Atoms/Icons'; +import { LoadingContext } from '../Core/Contexts'; +import type { + AnyTree, + FilterTablesByEndsWith, + SerializedResource, +} from '../DataModel/helperTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import { schema } from '../DataModel/schema'; +import { serializeResource } from '../DataModel/serializers'; +import type { LiteralField, Relationship } from '../DataModel/specifyField'; +import type { SpecifyTable } from '../DataModel/specifyTable'; +import { strictGetTable } from '../DataModel/tables'; +import type { SpQuery, Tables } from '../DataModel/types'; +import { + isTreeTable, + strictGetTreeDefinitionItems, + treeRanksPromise, +} from '../InitialContext/treeRanks'; +import { Dialog } from '../Molecules/Dialog'; +import { TableIcon } from '../Molecules/TableIcon'; +import { userPreferences } from '../Preferences/userPreferences'; +import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; +import type { QueryField } from '../QueryBuilder/helpers'; +import { uniquifyDataSetName } from '../WbImport/helpers'; +import { + anyTreeRank, + relationshipIsToMany, +} from '../WbPlanView/mappingHelpers'; +import { generateMappingPathPreview } from '../WbPlanView/mappingPreview'; + +const queryFieldSpecHeader = (queryFieldSpec: QueryFieldSpec) => + generateMappingPathPreview( + queryFieldSpec.baseTable.name, + queryFieldSpec.toMappingPath() + ); + +export function BatchEditFromQuery({ + query, + fields, + baseTableName, + recordSetId, +}: { + readonly query: SpecifyResource; + readonly fields: RA; + readonly baseTableName: keyof Tables; + readonly recordSetId?: number; +}) { + const navigate = useNavigate(); + const post = async (dataSetName: string) => + ajax<{ readonly id: number }>('/stored_query/batch_edit/', { + method: 'POST', + errorMode: 'dismissible', + headers: { Accept: 'application/json' }, + body: keysToLowerCase({ + ...serializeResource(query), + captions: fields + .filter(({ isDisplay }) => isDisplay) + .map(({ mappingPath }) => + generateMappingPathPreview(baseTableName, mappingPath) + ), + name: dataSetName, + recordSetId, + limit: userPreferences.get('batchEdit', 'query', 'limit'), + }), + }); + const [errors, setErrors] = React.useState(undefined); + const loading = React.useContext(LoadingContext); + + const queryFieldSpecs = React.useMemo( + () => + filterArray( + fields.map((field) => + field.isDisplay + ? QueryFieldSpec.fromPath(baseTableName, field.mappingPath) + : undefined + ) + ), + [fields] + ); + + return ( + <> + { + loading( + treeRanksPromise.then(async () => { + const missingRanks = findAllMissing(queryFieldSpecs); + const invalidFields = queryFieldSpecs.filter((fieldSpec) => + filters.some((filter) => filter(fieldSpec)) + ); + const hasErrors = + Object.values(missingRanks).some((ranks) => ranks.length > 0) || + invalidFields.length > 0; + + if (hasErrors) { + setErrors({ + missingRanks, + invalidFields: invalidFields.map(queryFieldSpecHeader), + }); + return; + } + + const newName = batchEditText.datasetName({ + queryName: query.get('name'), + datePart: new Date().toDateString(), + }); + return uniquifyDataSetName(newName, undefined, 'batchEdit').then( + async (name) => + post(name).then(({ data }) => + navigate(`/specify/workbench/${data.id}`) + ) + ); + }) + ); + }} + > + <>{batchEditText.batchEdit()} + + {errors !== undefined && ( + setErrors(undefined)} /> + )} + + ); +} + +type QueryError = { + readonly missingRanks: { + // Query can contain relationship to multiple trees + readonly [KEY in AnyTree['tableName']]: RA; + }; + readonly invalidFields: RA; +}; + +function containsFaultyNestedToMany(queryFieldSpec: QueryFieldSpec): boolean { + const joinPath = queryFieldSpec.joinPath; + if (joinPath.length <= 1) return false; + const nestedToManyCount = joinPath.filter( + (relationship) => + relationship.isRelationship && relationshipIsToMany(relationship) + ); + return nestedToManyCount.length > 1; +} + +const containsSystemTables = (queryFieldSpec: QueryFieldSpec) => + queryFieldSpec.joinPath.some((field) => field.table.isSystem); + +const hasHierarchyBaseTable = (queryFieldSpec: QueryFieldSpec) => + Object.keys(schema.domainLevelIds).includes( + queryFieldSpec.baseTable.name.toLowerCase() as 'collection' + ); + +const containsTreeTableOrSpecificRank = (queryFieldSpec: QueryFieldSpec) => + isTreeTable(queryFieldSpec.baseTable.name) || + (typeof queryFieldSpec.treeRank === 'string' && + queryFieldSpec.treeRank !== '-any'); + +const filters = [containsFaultyNestedToMany, containsSystemTables]; + +const getTreeDefFromName = ( + rankName: string, + treeDefItems: RA>> +) => + defined( + treeDefItems.find( + (treeRank) => treeRank.name.toLowerCase() === rankName.toLowerCase() + ) + ); + +function findAllMissing( + queryFieldSpecs: RA +): QueryError['missingRanks'] { + const treeFieldSpecs = group( + filterArray( + queryFieldSpecs.map((fieldSpec) => + isTreeTable(fieldSpec.table.name) && + fieldSpec.treeRank !== anyTreeRank && + fieldSpec.treeRank !== undefined + ? [ + fieldSpec.table, + { rank: fieldSpec.treeRank, field: fieldSpec.getField() }, + ] + : undefined + ) + ) + ); + + return Object.fromEntries( + treeFieldSpecs.map(([treeTable, treeRanks]) => [ + treeTable.name, + findMissingRanks(treeTable, treeRanks), + ]) + ); +} + +// TODO: discuss if we need to add more of them, and if we need to add more of them for other table. +const requiredTreeFields: RA = ['name'] as const; + +function findMissingRanks( + treeTable: SpecifyTable, + treeRanks: RA< + | { readonly rank: string; readonly field?: LiteralField | Relationship } + | undefined + > +): RA { + const allTreeDefItems = strictGetTreeDefinitionItems( + treeTable.name as 'Geography', + false + ); + + // Duplicates don't affect any logic here + const currentTreeRanks = filterArray( + treeRanks.map((treeRank) => + f.maybe(treeRank, ({ rank, field }) => ({ + specifyRank: getTreeDefFromName(rank, allTreeDefItems), + field, + })) + ) + ); + + const currentRanksSorted = Array.from(currentTreeRanks).sort( + sortFunction(({ specifyRank: { rankId } }) => rankId) + ); + + const highestRank = currentRanksSorted[0]; + + return allTreeDefItems.flatMap(({ rankId, name }) => + rankId < highestRank.specifyRank.rankId + ? [] + : filterArray( + requiredTreeFields.map((requiredField) => + currentTreeRanks.some( + (rank) => + rank.specifyRank.name === name && + rank.field !== undefined && + requiredField === rank.field.name + ) + ? undefined + : `${name} ${ + defined( + strictGetTable(treeTable.name).getField(requiredField) + ).label + }` + ) + ) + ); +} + +function ErrorsDialog({ + errors, + onClose: handleClose, +}: { + readonly errors: QueryError; + readonly onClose: () => void; +}): JSX.Element { + return ( + + + + + ); +} + +function ShowInvalidFields({ + error, +}: { + readonly error: QueryError['invalidFields']; +}) { + const hasErrors = error.length > 0; + return hasErrors ? ( +
    +
    +

    {batchEditText.removeField()}

    +
    + {error.map((singleError) => ( +

    {singleError}

    + ))} +
    + ) : null; +} + +function ShowMissingRanks({ + error, +}: { + readonly error: QueryError['missingRanks']; +}) { + const hasMissing = Object.values(error).some((rank) => rank.length > 0); + return hasMissing ? ( +
    +
    +

    {batchEditText.addTreeRank()}

    +
    + {Object.entries(error).map(([treeTable, ranks]) => ( +
    +
    + +

    {strictGetTable(treeTable).label}

    +
    +
    + {ranks.map((rank) => ( +

    {rank}

    + ))} +
    +
    + ))} +
    + ) : null; +} diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts index 787ed89f2cf..f2a4bf8a466 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts @@ -31,6 +31,8 @@ import type { CollectionObject } from '../types'; const { getCarryOverPreference, getFieldsToClone } = exportsForTests; +import uniqueFields from '../uniqueFields.json'; + mockTime(); requireContext(); @@ -248,39 +250,18 @@ describe('getCarryOverPreference', () => { }); }); -describe('getUniqueFields', () => { - test('CollectionObject', () => - expect(getUniqueFields(tables.CollectionObject)).toEqual([ - 'catalogNumber', - 'uniqueIdentifier', - 'guid', - 'collectionObjectAttachments', - 'timestampCreated', - 'version', - 'timestampModified', - ])); - test('Locality', () => - expect(getUniqueFields(tables.Locality)).toEqual([ - 'uniqueIdentifier', - 'localityAttachments', - 'guid', - 'timestampCreated', - 'version', - 'timestampModified', - ])); - test('AccessionAttachment', () => - expect(getUniqueFields(tables.AccessionAttachment)).toEqual([ - 'attachment', - 'timestampCreated', - 'version', - 'timestampModified', - ])); - test('AccessionAgent', () => - expect(getUniqueFields(tables.AccessionAgent)).toEqual([ - 'timestampCreated', - 'version', - 'timestampModified', - ])); +/** + * If this test breaks, uniqueFields.json needs to be regenerated. + * 1. Go to the dev console on the browser + * 2. Run the function _getUniqueFields() + * 3. Paste the text into uniqueFields.json and format with prettier + */ +test('checkUniqueFields', () => { + Object.values(tables).map((table) => + expect(getUniqueFields(table, false)).toEqual( + uniqueFields[table.name.toLowerCase() as keyof typeof uniqueFields] + ) + ); }); test('getFieldsToNotClone', () => { diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts b/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts index e41f70e92b7..f415260d004 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts @@ -4,7 +4,7 @@ import { ping } from '../../utils/ajax/ping'; import { eventListener } from '../../utils/events'; import { f } from '../../utils/functools'; import type { DeepPartial, RA, RR } from '../../utils/types'; -import { defined, filterArray } from '../../utils/types'; +import { defined, filterArray, setDevelopmentGlobal } from '../../utils/types'; import { keysToLowerCase, removeKey } from '../../utils/utils'; import type { InteractionWithPreps } from '../Interactions/helpers'; import { @@ -25,8 +25,8 @@ import type { import type { SpecifyResource } from './legacyTypes'; import { schema } from './schema'; import { serializeResource } from './serializers'; -import type { SpecifyTable } from './specifyTable'; -import { genericTables, getTable } from './tables'; +import { SpecifyTable } from './specifyTable'; +import { genericTables, getTable, tables } from './tables'; import type { Tables } from './types'; import { getUniquenessRules } from './uniquenessRules'; @@ -281,22 +281,27 @@ const uniqueFields = [ 'timestampModified', ]; -export const getUniqueFields = (table: SpecifyTable): RA => +const getUniqueFieldsFromRules = (table: SpecifyTable) => + (getUniquenessRules(table.name) ?? []) + .filter(({ rule: { scopes } }) => + scopes.every( + (fieldPath) => + ( + getFieldsFromPath(table, fieldPath).at(-1)?.name ?? '' + ).toLowerCase() in schema.domainLevelIds + ) + ) + .flatMap(({ rule: { fields } }) => + fields.flatMap((field) => table.getField(field)?.name) + ); + +// WARNING: Changing the behaviour here will also change how batch-edit clones records. +export const getUniqueFields = ( + table: SpecifyTable, + schemaAware: boolean = true +): RA => f.unique([ - ...filterArray( - (getUniquenessRules(table.name) ?? []) - .filter(({ rule: { scopes } }) => - scopes.every( - (fieldPath) => - ( - getFieldsFromPath(table, fieldPath).at(-1)?.name ?? '' - ).toLowerCase() in schema.domainLevelIds - ) - ) - .flatMap(({ rule: { fields } }) => - fields.flatMap((field) => table.getField(field)?.name) - ) - ), + ...filterArray(schemaAware ? getUniqueFieldsFromRules(table) : []), /* * Each attachment is assumed to refer to a unique attachment file * See https://github.com/specify/specify7/issues/1754#issuecomment-1157796585 @@ -321,6 +326,12 @@ export const getUniqueFields = (table: SpecifyTable): RA => ) ) .map(({ name }) => name), + // Don't clone specifyuser. + ...(table.name === 'Agent' + ? table.relationships + .filter(({ relatedTable }) => relatedTable.name === 'SpecifyUser') + .map(({ name }) => name) + : []), ...filterArray( uniqueFields.map((fieldName) => table.getField(fieldName)?.name) ), @@ -330,3 +341,16 @@ export const exportsForTests = { getCarryOverPreference, getFieldsToClone, }; + +setDevelopmentGlobal('_getUniqueFields', (): void => { + // Batch-editor clones records in independent-to-one no-match cases. It needs to be aware of the fields to not clone. It's fine if it doesn't respect user preferences (for now), but needs to be replicate + // front-end logic. So, the "fields to not clone" must be identical. This is done by storing them as a static file, which frontend and backend both access + a unit test to make sure the file is up-to-date. + // In the case where the user is really doesn't want to carry-over some fields, they can simply add those fields in batch-edit query (and then set them to null) so it handles general use case pretty well. + const allTablesResult = Object.fromEntries( + Object.values(tables).map((table) => [ + table.name.toLowerCase(), + getUniqueFields(table, false), + ]) + ); + document.body.textContent = JSON.stringify(allTablesResult); +}); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json b/specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json new file mode 100644 index 00000000000..34288704470 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json @@ -0,0 +1,692 @@ +{ + "accession": [ + "accessionAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "accessionagent": ["timestampCreated", "version", "timestampModified"], + "accessionattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "accessionauthorization": [ + "timestampCreated", + "version", + "timestampModified" + ], + "accessioncitation": ["timestampCreated", "version", "timestampModified"], + "address": [ + "timestampCreated", + "version", + "isCurrent", + "isPrimary", + "timestampModified" + ], + "addressofrecord": ["timestampCreated", "version", "timestampModified"], + "agent": [ + "agentAttachments", + "specifyUser", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "agentattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "agentgeography": ["timestampCreated", "version", "timestampModified"], + "agentidentifier": ["timestampCreated", "version", "timestampModified"], + "agentspecialty": ["timestampCreated", "version", "timestampModified"], + "agentvariant": ["timestampCreated", "version", "timestampModified"], + "appraisal": ["timestampCreated", "version", "timestampModified"], + "attachment": [ + "accessionAttachments", + "agentAttachments", + "borrowAttachments", + "collectingEventAttachments", + "collectingTripAttachments", + "collectionObjectAttachments", + "conservDescriptionAttachments", + "conservEventAttachments", + "deaccessionAttachments", + "disposalAttachments", + "dnaSequenceAttachments", + "dnaSequencingRunAttachments", + "exchangeInAttachments", + "exchangeOutAttachments", + "fieldNotebookAttachments", + "fieldNotebookPageAttachments", + "fieldNotebookPageSetAttachments", + "giftAttachments", + "loanAttachments", + "localityAttachments", + "permitAttachments", + "preparationAttachments", + "referenceWorkAttachments", + "repositoryAgreementAttachments", + "storageAttachments", + "taxonAttachments", + "treatmentEventAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "attachmentimageattribute": [ + "attachments", + "timestampCreated", + "version", + "timestampModified" + ], + "attachmentmetadata": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "attachmenttag": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "attributedef": ["timestampCreated", "version", "timestampModified"], + "author": ["timestampCreated", "version", "timestampModified"], + "autonumberingscheme": ["timestampCreated", "version", "timestampModified"], + "borrow": [ + "borrowAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "borrowagent": ["timestampCreated", "version", "timestampModified"], + "borrowattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "borrowmaterial": ["timestampCreated", "version", "timestampModified"], + "borrowreturnmaterial": ["timestampCreated", "version", "timestampModified"], + "collectingevent": [ + "collectingEventAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "collectingeventattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "collectingeventattr": ["timestampCreated", "version", "timestampModified"], + "collectingeventattribute": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collectingeventauthorization": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collectingtrip": [ + "collectingTripAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "collectingtripattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "collectingtripattribute": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collectingtripauthorization": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collection": ["guid", "timestampCreated", "version", "timestampModified"], + "collectionobject": [ + "collectionObjectAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "collectionobjectattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "collectionobjectattr": ["timestampCreated", "version", "timestampModified"], + "collectionobjectattribute": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collectionobjectcitation": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collectionobjectproperty": [ + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "collectionreltype": ["timestampCreated", "version", "timestampModified"], + "collectionrelationship": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collector": [ + "timestampCreated", + "version", + "isPrimary", + "timestampModified" + ], + "commonnametx": ["timestampCreated", "version", "timestampModified"], + "commonnametxcitation": ["timestampCreated", "version", "timestampModified"], + "conservdescription": [ + "conservDescriptionAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "conservdescriptionattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "conservevent": [ + "conservEventAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "conserveventattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "container": ["timestampCreated", "version", "timestampModified"], + "dnaprimer": ["timestampCreated", "version", "timestampModified"], + "dnasequence": [ + "attachments", + "timestampCreated", + "version", + "timestampModified" + ], + "dnasequenceattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "dnasequencingrun": [ + "attachments", + "timestampCreated", + "version", + "timestampModified" + ], + "dnasequencingrunattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "dnasequencingruncitation": [ + "timestampCreated", + "version", + "timestampModified" + ], + "datatype": ["timestampCreated", "version", "timestampModified"], + "deaccession": [ + "deaccessionAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "deaccessionagent": ["timestampCreated", "version", "timestampModified"], + "deaccessionattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "determination": [ + "guid", + "timestampCreated", + "version", + "isCurrent", + "timestampModified" + ], + "determinationcitation": ["timestampCreated", "version", "timestampModified"], + "determiner": [ + "timestampCreated", + "version", + "isPrimary", + "timestampModified" + ], + "discipline": ["timestampCreated", "version", "timestampModified"], + "disposal": [ + "disposalAttachments", + "disposalPreparations", + "timestampCreated", + "version", + "timestampModified" + ], + "disposalagent": ["timestampCreated", "version", "timestampModified"], + "disposalattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "disposalpreparation": ["timestampCreated", "version", "timestampModified"], + "division": ["timestampCreated", "version", "timestampModified"], + "exchangein": [ + "exchangeInAttachments", + "exchangeInPreps", + "timestampCreated", + "version", + "timestampModified" + ], + "exchangeinattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "exchangeinprep": ["timestampCreated", "version", "timestampModified"], + "exchangeout": [ + "exchangeOutAttachments", + "exchangeOutPreps", + "timestampCreated", + "version", + "timestampModified" + ], + "exchangeoutattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "exchangeoutprep": ["timestampCreated", "version", "timestampModified"], + "exsiccata": ["timestampCreated", "version", "timestampModified"], + "exsiccataitem": ["timestampCreated", "version", "timestampModified"], + "extractor": ["timestampCreated", "version", "timestampModified"], + "fieldnotebook": [ + "attachments", + "timestampCreated", + "version", + "timestampModified" + ], + "fieldnotebookattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "fieldnotebookpage": [ + "attachments", + "timestampCreated", + "version", + "timestampModified" + ], + "fieldnotebookpageattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "fieldnotebookpageset": [ + "attachments", + "timestampCreated", + "version", + "timestampModified" + ], + "fieldnotebookpagesetattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "fundingagent": [ + "timestampCreated", + "version", + "isPrimary", + "timestampModified" + ], + "geocoorddetail": ["timestampCreated", "version", "timestampModified"], + "geography": [ + "guid", + "timestampCreated", + "version", + "isCurrent", + "timestampModified" + ], + "geographytreedef": ["timestampCreated", "version", "timestampModified"], + "geographytreedefitem": ["timestampCreated", "version", "timestampModified"], + "geologictimeperiod": [ + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "geologictimeperiodtreedef": [ + "timestampCreated", + "version", + "timestampModified" + ], + "geologictimeperiodtreedefitem": [ + "timestampCreated", + "version", + "timestampModified" + ], + "gift": [ + "giftAttachments", + "giftPreparations", + "timestampCreated", + "version", + "timestampModified" + ], + "giftagent": ["timestampCreated", "version", "timestampModified"], + "giftattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "giftpreparation": ["timestampCreated", "version", "timestampModified"], + "groupperson": ["timestampCreated", "version", "timestampModified"], + "inforequest": ["timestampCreated", "version", "timestampModified"], + "institution": ["guid", "timestampCreated", "version", "timestampModified"], + "institutionnetwork": ["timestampCreated", "version", "timestampModified"], + "journal": ["guid", "timestampCreated", "version", "timestampModified"], + "latlonpolygon": ["timestampCreated", "version", "timestampModified"], + "latlonpolygonpnt": [], + "lithostrat": ["guid", "timestampCreated", "version", "timestampModified"], + "lithostrattreedef": ["timestampCreated", "version", "timestampModified"], + "lithostrattreedefitem": ["timestampCreated", "version", "timestampModified"], + "loan": [ + "loanAttachments", + "loanPreparations", + "timestampCreated", + "version", + "timestampModified" + ], + "loanagent": ["timestampCreated", "version", "timestampModified"], + "loanattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "loanpreparation": ["timestampCreated", "version", "timestampModified"], + "loanreturnpreparation": ["timestampCreated", "version", "timestampModified"], + "locality": [ + "localityAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "localityattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "localitycitation": ["timestampCreated", "version", "timestampModified"], + "localitydetail": ["timestampCreated", "version", "timestampModified"], + "localitynamealias": ["timestampCreated", "version", "timestampModified"], + "materialsample": [ + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "morphbankview": ["timestampCreated", "version", "timestampModified"], + "otheridentifier": ["timestampCreated", "version", "timestampModified"], + "paleocontext": ["timestampCreated", "version", "timestampModified"], + "pcrperson": ["timestampCreated", "version", "timestampModified"], + "permit": [ + "permitAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "permitattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "picklist": ["timestampCreated", "version", "timestampModified"], + "picklistitem": ["timestampCreated", "version", "timestampModified"], + "preptype": ["timestampCreated", "version", "timestampModified"], + "preparation": [ + "preparationAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "preparationattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "preparationattr": ["timestampCreated", "version", "timestampModified"], + "preparationattribute": ["timestampCreated", "version", "timestampModified"], + "preparationproperty": [ + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "project": ["timestampCreated", "version", "timestampModified"], + "recordset": ["timestampCreated", "version", "timestampModified"], + "recordsetitem": [], + "referencework": [ + "referenceWorkAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "referenceworkattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "repositoryagreement": [ + "repositoryAgreementAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "repositoryagreementattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "shipment": ["timestampCreated", "version", "timestampModified"], + "spappresource": ["timestampCreated", "version"], + "spappresourcedata": ["timestampCreated", "version", "timestampModified"], + "spappresourcedir": ["timestampCreated", "version", "timestampModified"], + "spauditlog": ["timestampCreated", "version", "timestampModified"], + "spauditlogfield": ["timestampCreated", "version", "timestampModified"], + "spexportschema": ["timestampCreated", "version", "timestampModified"], + "spexportschemaitem": ["timestampCreated", "version", "timestampModified"], + "spexportschemaitemmapping": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spexportschemamapping": ["timestampCreated", "version", "timestampModified"], + "spfieldvaluedefault": ["timestampCreated", "version", "timestampModified"], + "splocalecontainer": ["timestampCreated", "version", "timestampModified"], + "splocalecontaineritem": ["timestampCreated", "version", "timestampModified"], + "splocaleitemstr": ["timestampCreated", "version", "timestampModified"], + "sppermission": [], + "spprincipal": ["timestampCreated", "version", "timestampModified"], + "spquery": ["timestampCreated", "version", "timestampModified"], + "spqueryfield": ["timestampCreated", "version", "timestampModified"], + "spreport": ["timestampCreated", "version", "timestampModified"], + "spsymbiotainstance": ["timestampCreated", "version", "timestampModified"], + "sptasksemaphore": ["timestampCreated", "version", "timestampModified"], + "spversion": ["timestampCreated", "version", "timestampModified"], + "spviewsetobj": ["timestampCreated", "version", "timestampModified"], + "spvisualquery": ["timestampCreated", "version", "timestampModified"], + "specifyuser": ["timestampCreated", "version", "timestampModified"], + "storage": [ + "storageAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "storageattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "storagetreedef": ["timestampCreated", "version", "timestampModified"], + "storagetreedefitem": ["timestampCreated", "version", "timestampModified"], + "taxon": [ + "taxonAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "taxonattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "taxonattribute": ["timestampCreated", "version", "timestampModified"], + "taxoncitation": ["timestampCreated", "version", "timestampModified"], + "taxontreedef": ["timestampCreated", "version", "timestampModified"], + "taxontreedefitem": ["timestampCreated", "version", "timestampModified"], + "treatmentevent": [ + "treatmentEventAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "treatmenteventattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "voucherrelationship": ["timestampCreated", "version", "timestampModified"], + "workbench": ["timestampCreated", "version", "timestampModified"], + "workbenchdataitem": [], + "workbenchrow": [], + "workbenchrowexportedrelationship": [ + "timestampCreated", + "version", + "timestampModified" + ], + "workbenchrowimage": [], + "workbenchtemplate": ["timestampCreated", "version", "timestampModified"], + "workbenchtemplatemappingitem": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spuserexternalid": [], + "spattachmentdataset": ["timestampcreated", "timestampmodified"], + "uniquenessrule": [], + "uniquenessrulefield": [], + "message": ["timestampcreated"], + "spmerging": ["timestampcreated", "timestampmodified"], + "localityupdate": ["timestampcreated", "timestampmodified"], + "localityupdaterowresult": [], + "userpolicy": [], + "role": [], + "libraryrole": [], + "userrole": [], + "rolepolicy": [], + "libraryrolepolicy": [], + "spdataset": ["timestampcreated", "timestampmodified"], + "collectionobjecttype": ["timestampCreated", "version", "timestampModified"], + "collectionobjectgroup": [ + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "collectionobjectgroupjoin": [ + "timestampCreated", + "version", + "isPrimary", + "timestampModified" + ], + "collectionobjectgrouptype": [ + "timestampCreated", + "version", + "timestampModified" + ], + "absoluteage": [ + "absoluteAgeAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "relativeage": [ + "relativeAgeAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "absoluteageattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "relativeageattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "absoluteagecitation": ["timestampCreated", "version", "timestampModified"], + "relativeagecitation": ["timestampCreated", "version", "timestampModified"], + "tectonicunittreedef": ["timestampCreated", "version", "timestampModified"], + "tectonicunittreedefitem": [ + "timestampCreated", + "version", + "timestampModified" + ], + "tectonicunit": ["guid", "timestampCreated", "version", "timestampModified"] +} diff --git a/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts index f4aaaaebd3d..390ed36feba 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts @@ -3,6 +3,7 @@ */ import { attachmentsText } from '../../localization/attachments'; +import { batchEditText } from '../../localization/batchEdit'; import { commonText } from '../../localization/common'; import { headerText } from '../../localization/header'; import { interactionsText } from '../../localization/interactions'; @@ -109,6 +110,11 @@ const rawMenuItems = ensure>>()({ icon: icons.chartBar, enabled: () => hasPermission('/querybuilder/query', 'execute'), }, + batchEdit: { + url: '/specify/overlay/batch-edit', + title: batchEditText.batchEdit(), + icon: icons.table, + } } as const); export type MenuItemName = keyof typeof rawMenuItems | 'search'; diff --git a/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts b/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts index 68bca10b572..f2ec817812f 100644 --- a/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts @@ -1,24 +1,20 @@ export const tableActions = ['read', 'create', 'update', 'delete'] as const; export const collectionAccessResource = '/system/sp7/collection'; export const operationPolicies = { + '/system/sp7/collection': ['access'], + '/admin/user/password': ['update'], '/admin/user/agents': ['update'], + '/admin/user/sp6/is_admin': ['update'], + '/record/merge': ['update', 'delete'], '/admin/user/invite_link': ['create'], '/admin/user/oic_providers': ['read'], - '/admin/user/password': ['update'], '/admin/user/sp6/collection_access': ['read', 'update'], - '/admin/user/sp6/is_admin': ['update'], - '/attachment_import/dataset': [ - 'create', - 'update', - 'delete', - 'upload', - 'rollback', - ], + '/report': ['execute'], '/export/dwca': ['execute'], '/export/feed': ['force_update'], - '/permissions/library/roles': ['read', 'create', 'update', 'delete'], '/permissions/list_admins': ['read'], '/permissions/policies/user': ['read', 'update'], + '/permissions/user/roles': ['read', 'update'], '/permissions/roles': [ 'read', 'create', @@ -26,16 +22,8 @@ export const operationPolicies = { 'delete', 'copy_from_library', ], - '/permissions/user/roles': ['read', 'update'], - '/querybuilder/query': [ - 'execute', - 'export_csv', - 'export_kml', - 'create_recordset', - ], - '/record/merge': ['update', 'delete'], - '/report': ['execute'], - '/system/sp7/collection': ['access'], + '/permissions/library/roles': ['read', 'create', 'update', 'delete'], + '/tree/edit/taxon': ['merge', 'move', 'synonymize', 'desynonymize', 'repair'], '/tree/edit/geography': [ 'merge', 'move', @@ -43,29 +31,28 @@ export const operationPolicies = { 'desynonymize', 'repair', ], - '/tree/edit/geologictimeperiod': [ + '/tree/edit/storage': [ 'merge', 'move', 'synonymize', 'desynonymize', 'repair', + 'bulk_move', ], - '/tree/edit/lithostrat': [ + '/tree/edit/geologictimeperiod': [ 'merge', 'move', 'synonymize', 'desynonymize', 'repair', ], - '/tree/edit/storage': [ + '/tree/edit/lithostrat': [ 'merge', 'move', - 'bulk_move', 'synonymize', 'desynonymize', 'repair', ], - '/tree/edit/taxon': ['merge', 'move', 'synonymize', 'desynonymize', 'repair'], '/tree/edit/tectonicunit': [ 'merge', 'move', @@ -73,6 +60,12 @@ export const operationPolicies = { 'desynonymize', 'repair', ], + '/querybuilder/query': [ + 'execute', + 'export_csv', + 'export_kml', + 'create_recordset', + ], '/workbench/dataset': [ 'create', 'update', @@ -83,6 +76,25 @@ export const operationPolicies = { 'transfer', 'create_recordset', ], + '/attachment_import/dataset': [ + 'create', + 'update', + 'delete', + 'upload', + 'rollback', + ], + '/batch_edit/dataset': [ + 'create', + 'update', + 'delete', + 'commit', + 'rollback', + 'validate', + 'transfer', + 'create_recordset', + 'delete_dependents', + 'edit_multiple_tables', + ], } as const; /** diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx index 78e891d0dff..180d6b65a24 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx @@ -6,6 +6,7 @@ import React from 'react'; import type { LocalizedString } from 'typesafe-i18n'; import { attachmentsText } from '../../localization/attachments'; +import { batchEditText } from '../../localization/batchEdit'; import { commonText } from '../../localization/common'; import { formsText } from '../../localization/forms'; import { headerText } from '../../localization/header'; @@ -2023,6 +2024,39 @@ export const userPreferenceDefinitions = { }, }, }, + batchEdit: { + title: batchEditText.batchEdit(), + subCategories: { + query: { + title: queryText.query(), + items: { + limit: definePref({ + title: batchEditText.numberOfRecords(), + requiresReload: false, + visible: true, + defaultValue: 5000, + type: 'java.lang.Double', + parser: { + min: 0, + }, + }), + }, + }, + editor: { + title: preferencesText.general(), + items: { + showRollback: definePref({ + title: batchEditText.showRollback(), + requiresReload: false, + defaultValue: true, + type: 'java.lang.Boolean', + visible: true, + description: batchEditText.showRollbackDescription(), + }), + }, + }, + }, + }, } as const; // Use tree table labels as titles for the tree editor sections diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 1c1b1c82095..236eeebda75 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -17,6 +17,7 @@ import { Container } from '../Atoms'; import { Button } from '../Atoms/Button'; import { Form } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; +import { BatchEditFromQuery } from '../BatchEdit'; import { ReadOnlyContext } from '../Core/Contexts'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; @@ -40,6 +41,7 @@ import { } from '../WbPlanView/mappingHelpers'; import { getMappingLineData } from '../WbPlanView/navigator'; import { navigatorSpecs } from '../WbPlanView/navigatorSpecs'; +import { datasetVariants } from '../WbUtils/datasetVariants'; import { CheckReadAccess } from './CheckReadAccess'; import { MakeRecordSetButton } from './Components'; import { IsQueryBasicContext, useQueryViewPref } from './Context'; @@ -588,17 +590,27 @@ function Wrapped({ ) : undefined } extraButtons={ - query.countOnly ? undefined : ( - - ) + <> + {datasetVariants.batchEdit.canCreate() && ( + + )} + {query.countOnly ? undefined : ( + + )} + } fields={state.fields} forceCollection={forceCollection} diff --git a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx index 08769efeb6f..6516db38c46 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx @@ -15,6 +15,7 @@ import { wbText } from '../../localization/workbench'; import type { RA } from '../../utils/types'; import { Redirect } from './Redirect'; import type { EnhancedRoute } from './RouterUtils'; +import { batchEditText } from '../../localization/batchEdit'; /* eslint-disable @typescript-eslint/promise-function-async */ /** @@ -239,6 +240,15 @@ export const overlayRoutes: RA = [ ({ TableUniquenessRules }) => TableUniquenessRules ), }, + { + // There's no physical difference between a workbench and batch-edit dataset, but separating them out helps UI. + path: 'batch-edit', + title: batchEditText.batchEdit(), + element: () => + import('../Toolbar/WbsDialog').then( + ({ BatchEditDataSetsOverlay }) => BatchEditDataSetsOverlay + ), + }, ], }, ]; diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx index 38f04d7ff6d..c8c1d7e8d41 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx @@ -10,7 +10,7 @@ import type { LocalizedString } from 'typesafe-i18n'; import { useAsyncState } from '../../hooks/useAsyncState'; import { commonText } from '../../localization/common'; -import { wbPlanText } from '../../localization/wbPlan'; +import { headerText } from '../../localization/header'; import { wbText } from '../../localization/workbench'; import { ajax } from '../../utils/ajax'; import type { RA } from '../../utils/types'; @@ -27,15 +27,16 @@ import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import type { SortConfig } from '../Molecules/Sorting'; import { SortIndicator, useSortConfig } from '../Molecules/Sorting'; import { TableIcon } from '../Molecules/TableIcon'; -import { hasPermission } from '../Permissions/helpers'; +import { formatUrl } from '../Router/queryString'; import { OverlayContext } from '../Router/Router'; import { uniquifyDataSetName } from '../WbImport/helpers'; import type { Dataset, DatasetBriefPlan } from '../WbPlanView/Wrapped'; +import { datasetVariants } from '../WbUtils/datasetVariants'; import { WbDataSetMeta } from '../WorkBench/DataSetMeta'; const createWorkbenchDataSet = async () => createEmptyDataSet( - '/api/workbench/dataset/', + 'workbench', wbText.newDataSetName({ date: new Date().toDateString() }), { importedfilename: '', @@ -46,14 +47,14 @@ const createWorkbenchDataSet = async () => export const createEmptyDataSet = async < DATASET extends AttachmentDataSet | Dataset, >( - datasetUrl: string, + datasetVariant: keyof typeof datasetVariants, name: LocalizedString, props?: Partial ): Promise => - ajax(datasetUrl, { + ajax(datasetVariants[datasetVariant].fetchUrl, { method: 'POST', body: { - name: await uniquifyDataSetName(name, undefined, datasetUrl), + name: await uniquifyDataSetName(name, undefined, datasetVariant), rows: [], ...props, }, @@ -129,31 +130,43 @@ function TableHeader({ ); } -/** Render a dialog for choosing a data set */ -export function DataSetsDialog({ +type WB_VARIANT = keyof Omit; + +export type WbVariantLocalization = + typeof datasetVariants.workbench.localization.viewer; + +export function GenericDataSetsDialog({ onClose: handleClose, - showTemplates, onDataSetSelect: handleDataSetSelect, + wbVariant, }: { - readonly showTemplates: boolean; + readonly wbVariant: WB_VARIANT; readonly onClose: () => void; readonly onDataSetSelect?: (id: number) => void; }): JSX.Element | null { + const { + fetchUrl, + sortConfig: sortConfigSpec, + canEdit, + localization, + route, + metaRoute, + canImport, + documentationUrl, + } = datasetVariants[wbVariant]; const [unsortedDatasets] = useAsyncState( React.useCallback( async () => - ajax>( - `/api/workbench/dataset/${showTemplates ? '?with_plan' : ''}`, - { headers: { Accept: 'application/json' } } - ).then(({ data }) => data), - [showTemplates] + ajax>(formatUrl(fetchUrl, {}), { + headers: { Accept: 'application/json' }, + }).then(({ data }) => data), + [wbVariant] ), true ); - const [sortConfig, handleSort, applySortConfig] = useSortConfig( - 'listOfDataSets', - 'dateCreated', + sortConfigSpec.key, + sortConfigSpec.field, false ); @@ -169,16 +182,15 @@ export function DataSetsDialog({ ) : undefined; - const canImport = - hasPermission('/workbench/dataset', 'create') && !showTemplates; const navigate = useNavigate(); const loading = React.useContext(LoadingContext); + return Array.isArray(datasets) ? ( {commonText.cancel()} - {canImport && ( + {canImport() && ( <> {wbText.importFile()} @@ -202,25 +214,17 @@ export function DataSetsDialog({ container: dialogClassNames.wideContainer, }} dimensionsKey="DataSetsDialog" - header={ - showTemplates - ? wbPlanText.copyPlan() - : commonText.countLine({ - resource: wbText.dataSets(), - count: datasets.length, - }) - } + header={localization.datasetsDialog.header(datasets.length)} icon={icons.table} onClose={handleClose} > {datasets.length === 0 ? ( -

    - {showTemplates - ? wbPlanText.noPlansToCopyFrom() - : `${wbText.wbsDialogEmpty()} ${ - canImport ? wbText.createDataSetInstructions() : '' - }`} -

    +
    +

    {localization.datasetsDialog.empty()}

    + + {headerText.documentation()} + +
    ) : (