From 786ae34521df41885c2458e0275e1cecb9e4d031 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Mon, 17 Apr 2023 14:26:50 -0700 Subject: [PATCH 01/24] Implement attachment viewer gallery in record sets Fixes #2132 --- .../lib/components/Attachments/Gallery.tsx | 8 +- .../Attachments/RecordSetAttachment.tsx | 112 ++ .../lib/components/Attachments/index.tsx | 10 +- .../lib/components/DataModel/resourceApi.js | 1339 +++++++++-------- .../FormSliders/RecordSelectorFromIds.tsx | 29 +- .../lib/components/FormSliders/RecordSet.tsx | 46 +- .../js_src/lib/localization/attachments.ts | 7 + 7 files changed, 903 insertions(+), 648 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx index a83d6b54fcc..664551d341a 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx @@ -28,7 +28,10 @@ export function AttachmentGallery({ readonly onFetchMore: (() => Promise) | undefined; readonly scale: number; readonly isComplete: boolean; - readonly onChange: (attachments: RA>) => void; + readonly onChange: ( + attachment: SerializedResource, + index: number + ) => void; }): JSX.Element { const containerRef = React.useRef(null); @@ -61,6 +64,7 @@ export function AttachmentGallery({ const [openIndex, setOpenIndex] = React.useState( undefined ); + const [related, setRelated] = React.useState< RA | undefined> >([]); @@ -121,7 +125,7 @@ export function AttachmentGallery({ (item): void => setRelated(replaceItem(related, openIndex, item)), ]} onChange={(newAttachment): void => - handleChange(replaceItem(attachments, openIndex, newAttachment)) + handleChange(newAttachment, openIndex) } onClose={(): void => setOpenIndex(undefined)} onNext={ diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx new file mode 100644 index 00000000000..f3eb3f48957 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { RA, filterArray } from '../../utils/types'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import type { AnySchema } from '../DataModel/helperTypes'; +import { Dialog } from '../Molecules/Dialog'; +import { attachmentsText } from '../../localization/attachments'; +import { useAsyncState } from '../../hooks/useAsyncState'; +import { CollectionObjectAttachment } from '../DataModel/types'; +import { serializeResource } from '../DataModel/helpers'; +import { AttachmentGallery } from './Gallery'; +import { useCachedState } from '../../hooks/useCachedState'; +import { defaultScale } from '.'; +import { Button } from '../Atoms/Button'; +import { commonText } from '../../localization/common'; + +export function RecordSetAttachments({ + records, + onClose: handleClose, + onFetch: handleFetch, +}: { + readonly records: RA | undefined>; + readonly onClose: () => void; + readonly onFetch?: ( + index: number + ) => Promise>; +}): JSX.Element { + const recordFetched = React.useRef(0); + + const [attachments] = useAsyncState( + React.useCallback(async () => { + const relatedAttachementRecords = await Promise.all( + records.map((record) => + record + ?.rgetCollection(`${record.specifyModel.name}Attachments`) + .then( + ({ models }) => + models as RA> + ) + ) + ); + + const fetchCount = records.findIndex( + (record) => record?.populated !== true + ); + + recordFetched.current = fetchCount === -1 ? records.length : fetchCount; + + const attachments = await Promise.all( + filterArray(relatedAttachementRecords.flat()).map( + async (collectionObjectAttachment) => ({ + attachment: await collectionObjectAttachment + .rgetPromise('attachment') + .then((resource) => serializeResource(resource)), + related: collectionObjectAttachment, + }) + ) + ); + return attachments; + }, [records]), + true + ); + + const [haltValue, setHaltValue] = React.useState(300); + const halt = attachments?.length === 0 && records.length >= haltValue; + + const [scale = defaultScale] = useCachedState('attachments', 'scale'); + + const children = halt ? ( + haltValue === records.length ? ( + <>{attachmentsText.noAttachments()} + ) : ( + <> + {attachmentsText.attachmentHaltLimit({ halt: haltValue })} + { + if (haltValue * 2 > records.length) { + setHaltValue(records.length); + } else { + setHaltValue(haltValue * 2); + } + }} + > + {attachmentsText.fetchNextAttachments()} + + + ) + ) : ( + attachment) ?? []} + scale={scale} + onChange={(attachment, index): void => + void attachments?.[index].related.set(`attachment`, attachment) + } + onFetchMore={ + attachments === undefined || handleFetch === undefined || halt + ? undefined + : async () => handleFetch?.(recordFetched.current) + } + isComplete={recordFetched.current === records.length} + /> + ); + + return ( + {commonText.close()}} + header={attachmentsText.attachments()} + onClose={handleClose} + > + {children} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx index 3e270462416..27737b138ac 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx @@ -26,6 +26,7 @@ import { ProtectedTable } from '../Permissions/PermissionDenied'; import { OrderPicker } from '../Preferences/Renderers'; import { attachmentSettingsPromise } from './attachments'; import { AttachmentGallery } from './Gallery'; +import { replaceItem } from '../../utils/utils'; export const attachmentRelatedTables = f.store(() => Object.keys(schema.models).filter((tableName) => @@ -47,7 +48,7 @@ export const tablesWithAttachments = f.store(() => ) ); -const defaultScale = 10; +export const defaultScale = 10; const minScale = 4; const maxScale = 50; const defaultSortOrder = '-timestampCreated'; @@ -242,10 +243,13 @@ function Attachments(): JSX.Element { } key={`${order}_${JSON.stringify(filter)}`} scale={scale} - onChange={(records): void => + onChange={(attachment, index): void => collection === undefined ? undefined - : setCollection({ records, totalCount: collection.totalCount }) + : setCollection({ + records: replaceItem(collection.records, index, attachment), + totalCount: collection.totalCount, + }) } onFetchMore={collection === undefined ? undefined : fetchMore} /> diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js b/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js index 21676af4076..3f0b235d86c 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js +++ b/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js @@ -1,655 +1,750 @@ import _ from 'underscore'; -import {hijackBackboneAjax} from '../../utils/ajax/backboneAjax'; -import {Http} from '../../utils/ajax/definitions'; -import {removeKey} from '../../utils/utils'; -import {assert} from '../Errors/assert'; -import {softFail} from '../Errors/Crash'; -import {Backbone} from './backbone'; -import {attachBusinessRules} from './businessRules'; -import {initializeResource} from './domain'; +import { hijackBackboneAjax } from '../../utils/ajax/backboneAjax'; +import { Http } from '../../utils/ajax/definitions'; +import { removeKey } from '../../utils/utils'; +import { assert } from '../Errors/assert'; +import { softFail } from '../Errors/Crash'; +import { Backbone } from './backbone'; +import { attachBusinessRules } from './businessRules'; +import { initializeResource } from './domain'; import { - getFieldsToNotClone, - getResourceApiUrl, - getResourceViewUrl, - resourceEvents, - resourceFromUrl + getFieldsToNotClone, + getResourceApiUrl, + getResourceViewUrl, + resourceEvents, + resourceFromUrl, } from './resource'; function eventHandlerForToOne(related, field) { - return function(event) { - const args = _.toArray(arguments); - - switch (event) { - case 'saverequired': { - this.handleChanged(); - this.trigger.apply(this, args); - return; - } - case 'change:id': { - this.set(field.name, related.url()); - return; - } - case 'changing': { - this.trigger.apply(this, args); - return; - } - } - - // Pass change:field events up the tree, updating fields with dot notation - const match = /^r?(change):(.*)$/.exec(event); - if (match) { - args[0] = `r${ match[1] }:${ field.name.toLowerCase() }.${ match[2]}`; - this.trigger.apply(this, args); - } - }; + return function (event) { + const args = _.toArray(arguments); + + switch (event) { + case 'saverequired': { + this.handleChanged(); + this.trigger.apply(this, args); + return; + } + case 'change:id': { + this.set(field.name, related.url()); + return; + } + case 'changing': { + this.trigger.apply(this, args); + return; + } } - function eventHandlerForToMany(_related, field) { - return function(event) { - const args = _.toArray(arguments); - switch (event) { - case 'changing': { - this.trigger.apply(this, args); - break; - } - case 'saverequired': { - this.handleChanged(); - this.trigger.apply(this, args); - break; - } - case 'add': - case 'remove': { - // Annotate add and remove events with the field in which they occured - args[0] = `${event }:${ field.name.toLowerCase()}`; - this.trigger.apply(this, args); - break; - } - }}; + // Pass change:field events up the tree, updating fields with dot notation + const match = /^r?(change):(.*)$/.exec(event); + if (match) { + args[0] = `r${match[1]}:${field.name.toLowerCase()}.${match[2]}`; + this.trigger.apply(this, args); } + }; +} +function eventHandlerForToMany(_related, field) { + return function (event) { + const args = _.toArray(arguments); + switch (event) { + case 'changing': { + this.trigger.apply(this, args); + break; + } + case 'saverequired': { + this.handleChanged(); + this.trigger.apply(this, args); + break; + } + case 'add': + case 'remove': { + // Annotate add and remove events with the field in which they occured + args[0] = `${event}:${field.name.toLowerCase()}`; + this.trigger.apply(this, args); + break; + } + } + }; +} - export const ResourceBase = Backbone.Model.extend({ - __name__: "ResourceBase", - populated: false, // Indicates if this resource has data - _fetch: null, // Stores reference to the ajax deferred while the resource is being fetched - needsSaved: false, // Set when a local field is changed - _save: null, // Stores reference to the ajax deferred while the resource is being saved - - constructor() { - this.specifyModel = this.constructor.specifyModel; - this.dependentResources = {}; // References to related objects referred to by field in this resource - Reflect.apply(Backbone.Model, this, arguments); // TEST: check if this is necessary - }, - initialize(attributes, options) { - this.noBusinessRules = options && options.noBusinessRules; - this.noValidation = options && options.noValidation; - - /* - * If initialized with some attributes that include a resource_uri, - * assume that represents all the fields for the resource - */ - if (attributes && _(attributes).has('resource_uri')) this.populated = true; - - /* - * The resource needs to be saved if any of its fields change - * unless they change because the resource is being fetched - * or updated during a save - */ - this.on('change', function() { - if (!this._fetch && !this._save) { - this.handleChanged(); - this.trigger('saverequired'); - } - }); - - if(!this.noBusinessRules) - attachBusinessRules(this); - if(this.isNew()) - initializeResource(this); - /* - * Business rules may set some fields on resource creation - * Those default values should not trigger unload protect - */ - this.needsSaved = false; - }, - /* - * This is encapsulated into a separate function so that can set a - * breakpoint in a single place - */ - handleChanged(){ - this.needsSaved = true; - }, - async clone(cloneAll = false) { - const self = this; - - const exemptFields = getFieldsToNotClone(this.specifyModel, cloneAll).map(fieldName=>fieldName.toLowerCase()); - - const newResource = new this.constructor( - removeKey( - this.attributes, - 'resource_uri', - 'id', - ...exemptFields - ) - ); - - newResource.needsSaved = self.needsSaved; - - await Promise.all(Object.entries(self.dependentResources).map(async ([fieldName,related])=>{ - if(exemptFields.includes(fieldName)) return; - const field = self.specifyModel.getField(fieldName); - switch (field.type) { - case 'many-to-one': { - /* - * Many-to-one wouldn't ordinarily be dependent, but - * this is the case for paleocontext. really more like - * a one-to-one. - */ - newResource.set(fieldName, await related?.clone(cloneAll)); - break; - } - case 'one-to-many': { - await newResource.rget(fieldName).then(async (newCollection)=> - Promise.all(related.models.map(async (resource)=>newCollection.add(await resource?.clone(cloneAll)))) - ); - break; - } - case 'zero-to-one': { - newResource.set(fieldName, await related?.clone(cloneAll)); - break; - } - default: { - throw new Error('unhandled relationship type'); - } - } - })); - return newResource; - }, - url() { - return getResourceApiUrl(this.specifyModel.name, this.id); - }, - viewUrl() { - // Returns the url for viewing this resource in the UI - if (!_.isNumber(this.id)) softFail(new Error("viewUrl called on resource without id"), this); - return getResourceViewUrl(this.specifyModel.name, this.id); - }, - get(attribute) { - if(attribute.toLowerCase() === this.specifyModel.idField.name.toLowerCase()) - return this.id; - // Case insensitive - return Backbone.Model.prototype.get.call(this, attribute.toLowerCase()); - }, - storeDependent(field, related) { - assert(field.isDependent()); - const setter = (field.type === 'one-to-many') ? "_setDependentToMany" : "_setDependentToOne"; - this[setter](field, related); - }, - _setDependentToOne(field, related) { - const oldRelated = this.dependentResources[field.name.toLowerCase()]; - if (!related) { - if (oldRelated) { - oldRelated.off("all", null, this); - this.trigger('saverequired'); - } - this.dependentResources[field.name.toLowerCase()] = null; - return; - } - - if (oldRelated && oldRelated.cid === related.cid) return; - - oldRelated && oldRelated.off("all", null, this); - - related.on('all', eventHandlerForToOne(related, field), this); - related.parent = this; // REFACTOR: this doesn't belong here +export const ResourceBase = Backbone.Model.extend({ + __name__: 'ResourceBase', + populated: false, // Indicates if this resource has data + _fetch: null, // Stores reference to the ajax deferred while the resource is being fetched + needsSaved: false, // Set when a local field is changed + _save: null, // Stores reference to the ajax deferred while the resource is being saved + + constructor() { + this.specifyModel = this.constructor.specifyModel; + this.dependentResources = {}; // References to related objects referred to by field in this resource + Reflect.apply(Backbone.Model, this, arguments); // TEST: check if this is necessary + }, + initialize(attributes, options) { + this.noBusinessRules = options && options.noBusinessRules; + this.noValidation = options && options.noValidation; + + /* + * If initialized with some attributes that include a resource_uri, + * assume that represents all the fields for the resource + */ + if (attributes && _(attributes).has('resource_uri')) this.populated = true; + + /* + * The resource needs to be saved if any of its fields change + * unless they change because the resource is being fetched + * or updated during a save + */ + this.on('change', function () { + if (!this._fetch && !this._save) { + this.handleChanged(); + this.trigger('saverequired'); + } + }); - switch (field.type) { - case 'one-to-one': + if (!this.noBusinessRules) attachBusinessRules(this); + if (this.isNew()) initializeResource(this); + /* + * Business rules may set some fields on resource creation + * Those default values should not trigger unload protect + */ + this.needsSaved = false; + }, + /* + * This is encapsulated into a separate function so that can set a + * breakpoint in a single place + */ + handleChanged() { + this.needsSaved = true; + }, + async clone(cloneAll = false) { + const self = this; + + const exemptFields = getFieldsToNotClone(this.specifyModel, cloneAll).map( + (fieldName) => fieldName.toLowerCase() + ); + + const newResource = new this.constructor( + removeKey(this.attributes, 'resource_uri', 'id', ...exemptFields) + ); + + newResource.needsSaved = self.needsSaved; + + await Promise.all( + Object.entries(self.dependentResources).map( + async ([fieldName, related]) => { + if (exemptFields.includes(fieldName)) return; + const field = self.specifyModel.getField(fieldName); + switch (field.type) { case 'many-to-one': { - this.dependentResources[field.name.toLowerCase()] = related; - break; + /* + * Many-to-one wouldn't ordinarily be dependent, but + * this is the case for paleocontext. really more like + * a one-to-one. + */ + newResource.set(fieldName, await related?.clone(cloneAll)); + break; + } + case 'one-to-many': { + await newResource + .rget(fieldName) + .then(async (newCollection) => + Promise.all( + related.models.map(async (resource) => + newCollection.add(await resource?.clone(cloneAll)) + ) + ) + ); + break; } case 'zero-to-one': { - this.dependentResources[field.name.toLowerCase()] = related; - related.set(field.otherSideName, this.url()); // REFACTOR: this logic belongs somewhere else. up probably - break; + newResource.set(fieldName, await related?.clone(cloneAll)); + break; } default: { - throw new Error(`setDependentToOne: unhandled field type: ${ field.type}`); - } - } - }, - _setDependentToMany(field, toMany) { - const oldToMany = this.dependentResources[field.name.toLowerCase()]; - oldToMany && oldToMany.off("all", null, this); - - // Cache it and set up event handlers - this.dependentResources[field.name.toLowerCase()] = toMany; - toMany.on('all', eventHandlerForToMany(toMany, field), this); - }, - set(key, value, options) { - // This may get called with "null" or "undefined" - const newValue = value ?? undefined; - const oldValue = typeof key === 'string' - ? this.attributes[key.toLowerCase()] ?? - this.dependentResources[key.toLowerCase()] ?? undefined - : undefined; - // Don't needlessly trigger unload protect if value didn't change - if ( - typeof key === 'string' && - typeof (oldValue??'') !== 'object' && - typeof (newValue??'') !== 'object' - ) { - if (oldValue === newValue) return this; - else if( - /* - * Don't trigger unload protect if: - * - value didn't change - * - value changed from string to number (back-end sends - * decimal numeric fields as string. Front-end converts - * those to numbers) - * - value was trimmed - * REFACTOR: this logic should be moved to this.parse() - * TEST: add test for "5A" case - */ - oldValue?.toString() === newValue?.toString().trim() - ) - options ??= {silent: true}; - } - // Make the keys case insensitive - const attributes = {}; - if (_.isObject(key) || key == null) { - /* - * In the two argument case, so - * "key" is actually an object mapping keys to values - */ - _(key).each((value, key) => { attributes[key.toLowerCase()] = value; }); - // And the options are actually in "value" argument - options = value; - } else { - // Three argument case - attributes[key.toLowerCase()] = value; + throw new Error('unhandled relationship type'); } + } + } + ) + ); + return newResource; + }, + url() { + return getResourceApiUrl(this.specifyModel.name, this.id); + }, + viewUrl() { + // Returns the url for viewing this resource in the UI + if (!_.isNumber(this.id)) + softFail(new Error('viewUrl called on resource without id'), this); + return getResourceViewUrl(this.specifyModel.name, this.id); + }, + get(attribute) { + if ( + attribute.toLowerCase() === this.specifyModel.idField.name.toLowerCase() + ) + return this.id; + // Case insensitive + return Backbone.Model.prototype.get.call(this, attribute.toLowerCase()); + }, + storeDependent(field, related) { + assert(field.isDependent()); + const setter = + field.type === 'one-to-many' + ? '_setDependentToMany' + : '_setDependentToOne'; + this[setter](field, related); + }, + _setDependentToOne(field, related) { + const oldRelated = this.dependentResources[field.name.toLowerCase()]; + if (!related) { + if (oldRelated) { + oldRelated.off('all', null, this); + this.trigger('saverequired'); + } + this.dependentResources[field.name.toLowerCase()] = null; + return; + } - /* - * Need to set the id right away if we have it because - * relationships depend on it - */ - if ('id' in attributes) { - attributes.id = attributes.id && Number.parseInt(attributes.id); - this.id = attributes.id; - } + if (oldRelated && oldRelated.cid === related.cid) return; + + oldRelated && oldRelated.off('all', null, this); + + related.on('all', eventHandlerForToOne(related, field), this); + related.parent = this; // REFACTOR: this doesn't belong here + + switch (field.type) { + case 'one-to-one': + case 'many-to-one': { + this.dependentResources[field.name.toLowerCase()] = related; + break; + } + case 'zero-to-one': { + this.dependentResources[field.name.toLowerCase()] = related; + related.set(field.otherSideName, this.url()); // REFACTOR: this logic belongs somewhere else. up probably + break; + } + default: { + throw new Error( + `setDependentToOne: unhandled field type: ${field.type}` + ); + } + } + }, + _setDependentToMany(field, toMany) { + const oldToMany = this.dependentResources[field.name.toLowerCase()]; + oldToMany && oldToMany.off('all', null, this); + + // Cache it and set up event handlers + this.dependentResources[field.name.toLowerCase()] = toMany; + toMany.on('all', eventHandlerForToMany(toMany, field), this); + }, + set(key, value, options) { + // This may get called with "null" or "undefined" + const newValue = value ?? undefined; + const oldValue = + typeof key === 'string' + ? this.attributes[key.toLowerCase()] ?? + this.dependentResources[key.toLowerCase()] ?? + undefined + : undefined; + // Don't needlessly trigger unload protect if value didn't change + if ( + typeof key === 'string' && + typeof (oldValue ?? '') !== 'object' && + typeof (newValue ?? '') !== 'object' + ) { + if (oldValue === newValue) return this; + else if ( + /* + * Don't trigger unload protect if: + * - value didn't change + * - value changed from string to number (back-end sends + * decimal numeric fields as string. Front-end converts + * those to numbers) + * - value was trimmed + * REFACTOR: this logic should be moved to this.parse() + * TEST: add test for "5A" case + */ + oldValue?.toString() === newValue?.toString().trim() + ) + options ??= { silent: true }; + } + // Make the keys case insensitive + const attributes = {}; + if (_.isObject(key) || key == null) { + /* + * In the two argument case, so + * "key" is actually an object mapping keys to values + */ + _(key).each((value, key) => { + attributes[key.toLowerCase()] = value; + }); + // And the options are actually in "value" argument + options = value; + } else { + // Three argument case + attributes[key.toLowerCase()] = value; + } - const adjustedAttributes = _.reduce(attributes, (accumulator, value, fieldName) => { - const [newFieldName, newValue] = this._handleField(value, fieldName); - return _.isUndefined(newValue) ? accumulator : Object.assign(accumulator, {[newFieldName]: newValue}); - }, {}); - - return Backbone.Model.prototype.set.call(this, adjustedAttributes, options); - }, - _handleField(value, fieldName) { - if(fieldName === '_tablename') return ['_tablename', undefined]; - if (_(['id', 'resource_uri', 'recordset_info']).contains(fieldName)) return [fieldName, value]; // Special fields - - const field = this.specifyModel.getField(fieldName); - if (!field) { - console.warn("setting unknown field", fieldName, "on", - this.specifyModel.name, "value is", value); - return [fieldName, value]; - } + /* + * Need to set the id right away if we have it because + * relationships depend on it + */ + if ('id' in attributes) { + attributes.id = attributes.id && Number.parseInt(attributes.id); + this.id = attributes.id; + } - fieldName = field.name.toLowerCase(); // In case field name is an alias. + const adjustedAttributes = _.reduce( + attributes, + (accumulator, value, fieldName) => { + const [newFieldName, newValue] = this._handleField(value, fieldName); + return _.isUndefined(newValue) + ? accumulator + : Object.assign(accumulator, { [newFieldName]: newValue }); + }, + {} + ); + + return Backbone.Model.prototype.set.call(this, adjustedAttributes, options); + }, + _handleField(value, fieldName) { + if (fieldName === '_tablename') return ['_tablename', undefined]; + if (_(['id', 'resource_uri', 'recordset_info']).contains(fieldName)) + return [fieldName, value]; // Special fields + + const field = this.specifyModel.getField(fieldName); + if (!field) { + console.warn( + 'setting unknown field', + fieldName, + 'on', + this.specifyModel.name, + 'value is', + value + ); + return [fieldName, value]; + } - if (field.isRelationship) { - value = _.isString(value) - ? this._handleUri(value, fieldName) - : (typeof value === 'number' - ? this._handleUri( - // Back-end sends SpPrincipal.scope as a number, rather than as a URL - getResourceApiUrl(field.model.name, value), - fieldName - ) - : this._handleInlineDataOrResource(value, fieldName)); - } - return [fieldName, value]; - }, - _handleInlineDataOrResource(value, fieldName) { - // BUG: check type of value - const field = this.specifyModel.getField(fieldName); - const relatedModel = field.relatedModel; - // BUG: don't do anything for virtual fields - - switch (field.type) { - case 'one-to-many': { - // Should we handle passing in an schema.Model.Collection instance here?? - const collectionOptions = { related: this, field: field.getReverse() }; - - if (field.isDependent()) { - const collection = new relatedModel.DependentCollection(collectionOptions, value); - this.storeDependent(field, collection); - } else { - console.warn("got unexpected inline data for independent collection field",{collection:this,field,value}); - } - - // Because the foreign key is on the other side - this.trigger(`change:${ fieldName}`, this); - this.trigger('change', this); - return undefined; - } - case 'many-to-one': { - if (!value) { // BUG: tighten up this check. - // The FK is null, or not a URI or inlined resource at any rate - field.isDependent() && this.storeDependent(field, null); - return value; - } - - const toOne = (value instanceof ResourceBase) ? value : - new relatedModel.Resource(value, {parse: true}); - - field.isDependent() && this.storeDependent(field, toOne); - this.trigger(`change:${ fieldName}`, this); - this.trigger('change', this); - return toOne.url(); - } // The FK as a URI - case 'zero-to-one': { - /* - * This actually a one-to-many where the related collection is only a single resource - * basically a one-to-one from the 'to' side - */ - const oneTo = _.isArray(value) ? - (value.length === 0 ? null : - new relatedModel.Resource(_.first(value), {parse: true})) - : (value || null); // In case it was undefined - - assert(oneTo == null || oneTo instanceof ResourceBase); - - field.isDependent() && this.storeDependent(field, oneTo); - // Because the FK is on the other side - this.trigger(`change:${ fieldName}`, this); - this.trigger('change', this); - return undefined; - } - } - if(!field.isVirtual) - softFail('Unhandled setting of relationship field', {fieldName,value,resource:this}); - return value; - }, - _handleUri(value, fieldName) { - const field = this.specifyModel.getField(fieldName); - const oldRelated = this.dependentResources[fieldName]; - - if (field.isDependent()) { - console.warn("expected inline data for dependent field", fieldName, "in", this); - } + fieldName = field.name.toLowerCase(); // In case field name is an alias. + + if (field.isRelationship) { + value = _.isString(value) + ? this._handleUri(value, fieldName) + : typeof value === 'number' + ? this._handleUri( + // Back-end sends SpPrincipal.scope as a number, rather than as a URL + getResourceApiUrl(field.model.name, value), + fieldName + ) + : this._handleInlineDataOrResource(value, fieldName); + } + return [fieldName, value]; + }, + _handleInlineDataOrResource(value, fieldName) { + // BUG: check type of value + const field = this.specifyModel.getField(fieldName); + const relatedModel = field.relatedModel; + // BUG: don't do anything for virtual fields + + switch (field.type) { + case 'one-to-many': { + // Should we handle passing in an schema.Model.Collection instance here?? + const collectionOptions = { related: this, field: field.getReverse() }; + + if (field.isDependent()) { + const collection = new relatedModel.DependentCollection( + collectionOptions, + value + ); + this.storeDependent(field, collection); + } else { + console.warn( + 'got unexpected inline data for independent collection field', + { collection: this, field, value } + ); + } - if (oldRelated && field.type === 'many-to-one') { - /* - * Probably should never get here since the presence of an oldRelated - * value implies a dependent field which wouldn't be receiving a URI value - */ - console.warn("unexpected condition"); - if (oldRelated.url() !== value) { - // The reference changed - delete this.dependentResources[fieldName]; - oldRelated.off('all', null, this); - } - } - return value; - }, + // Because the foreign key is on the other side + this.trigger(`change:${fieldName}`, this); + this.trigger('change', this); + return undefined; + } + case 'many-to-one': { + if (!value) { + // BUG: tighten up this check. + // The FK is null, or not a URI or inlined resource at any rate + field.isDependent() && this.storeDependent(field, null); + return value; + } + + const toOne = + value instanceof ResourceBase + ? value + : new relatedModel.Resource(value, { parse: true }); + + field.isDependent() && this.storeDependent(field, toOne); + this.trigger(`change:${fieldName}`, this); + this.trigger('change', this); + return toOne.url(); + } // The FK as a URI + case 'zero-to-one': { /* - * Get the value of the named field where the name may traverse related objects - * using dot notation. if the named field represents a resource or collection, - * then prePop indicates whether to return the named object or the contents of - * the field that represents it + * This actually a one-to-many where the related collection is only a single resource + * basically a one-to-one from the 'to' side */ - async rget(fieldName, prePop) { - return this.getRelated(fieldName, {prePop}); - }, + const oneTo = _.isArray(value) + ? value.length === 0 + ? null + : new relatedModel.Resource(_.first(value), { parse: true }) + : value || null; // In case it was undefined + + assert(oneTo == null || oneTo instanceof ResourceBase); + + field.isDependent() && this.storeDependent(field, oneTo); + // Because the FK is on the other side + this.trigger(`change:${fieldName}`, this); + this.trigger('change', this); + return undefined; + } + } + if (!field.isVirtual) + softFail('Unhandled setting of relationship field', { + fieldName, + value, + resource: this, + }); + return value; + }, + _handleUri(value, fieldName) { + const field = this.specifyModel.getField(fieldName); + const oldRelated = this.dependentResources[fieldName]; + + if (field.isDependent()) { + console.warn( + 'expected inline data for dependent field', + fieldName, + 'in', + this + ); + } + + if (oldRelated && field.type === 'many-to-one') { + /* + * Probably should never get here since the presence of an oldRelated + * value implies a dependent field which wouldn't be receiving a URI value + */ + console.warn('unexpected condition'); + if (oldRelated.url() !== value) { + // The reference changed + delete this.dependentResources[fieldName]; + oldRelated.off('all', null, this); + } + } + return value; + }, + /* + * Get the value of the named field where the name may traverse related objects + * using dot notation. if the named field represents a resource or collection, + * then prePop indicates whether to return the named object or the contents of + * the field that represents it + */ + async rget(fieldName, prePop) { + return this.getRelated(fieldName, { prePop }); + }, + /* + * REFACTOR: remove the need for this + * Like "rget", but returns native promise + */ + async rgetPromise(fieldName, prePop = true) { + return ( + this.getRelated(fieldName, { prePop }) + // GetRelated may return either undefined or null (yuk) + .then((data) => (data === undefined ? null : data)) + ); + }, + // Duplicate definition for purposes of better typing: + async rgetCollection(fieldName) { + return this.getRelated(fieldName, { prePop: true }); + }, + async getRelated(fieldName, options) { + options ||= { + prePop: false, + noBusinessRules: false, + }; + const path = _(fieldName).isArray() ? fieldName : fieldName.split('.'); + + // First make sure we actually have this object. + return this.fetch() + .then((_this) => _this._rget(path, options)) + .then((value) => { /* - * REFACTOR: remove the need for this - * Like "rget", but returns native promise + * If the requested value is fetchable, and prePop is true, + * fetch the value, otherwise return the unpopulated resource + * or collection */ - async rgetPromise(fieldName, prePop = true) { - return this.getRelated(fieldName, {prePop}) - // GetRelated may return either undefined or null (yuk) - .then(data=>data === undefined ? null : data); - }, - // Duplicate definition for purposes of better typing: - async rgetCollection(fieldName) { - return this.getRelated(fieldName, {prePop: true}); - }, - async getRelated(fieldName, options) { - options ||= { - prePop: false, - noBusinessRules: false - }; - const path = _(fieldName).isArray()? fieldName : fieldName.split('.'); - - // First make sure we actually have this object. - return this.fetch().then((_this) => _this._rget(path, options)).then((value) => { - /* - * If the requested value is fetchable, and prePop is true, - * fetch the value, otherwise return the unpopulated resource - * or collection - */ - if (options.prePop) { - if (!value) return value; // Ok if the related resource doesn't exist - else if (typeof value.fetchIfNotPopulated === 'function') - return value.fetchIfNotPopulated(); - else if (typeof value.fetch === 'function') - return value.fetch(); - } - return value; - }); - }, - async _rget(path, options) { - let fieldName = path[0].toLowerCase(); - const field = this.specifyModel.getField(fieldName); - field && (fieldName = field.name.toLowerCase()); // In case fieldName is an alias - let value = this.get(fieldName); - field || console.warn("accessing unknown field", fieldName, "in", - this.specifyModel.name, "value is", - value); - - /* - * If field represents a value, then return that if we are done, - * otherwise we can't traverse any farther... - */ - if (!field || !field.isRelationship) { - if (path.length > 1) { - softFail("expected related field"); - return undefined; - } - return value; - } + if (options.prePop) { + if (!value) return value; // Ok if the related resource doesn't exist + else if (typeof value.fetchIfNotPopulated === 'function') + return value.fetchIfNotPopulated(); + else if (typeof value.fetch === 'function') return value.fetch(); + } + return value; + }); + }, + async _rget(path, options) { + let fieldName = path[0].toLowerCase(); + const field = this.specifyModel.getField(fieldName); + field && (fieldName = field.name.toLowerCase()); // In case fieldName is an alias + let value = this.get(fieldName); + field || + console.warn( + 'accessing unknown field', + fieldName, + 'in', + this.specifyModel.name, + 'value is', + value + ); + + /* + * If field represents a value, then return that if we are done, + * otherwise we can't traverse any farther... + */ + if (!field || !field.isRelationship) { + if (path.length > 1) { + softFail('expected related field'); + return undefined; + } + return value; + } - const _this = this; - const related = field.relatedModel; - switch (field.type) { - case 'one-to-one': - case 'many-to-one': { - // A foreign key field. - if (!value) return value; // No related object - - // Is the related resource cached? - let toOne = this.dependentResources[fieldName]; - if (!toOne) { - _(value).isString() || softFail("expected URI, got", value); - toOne = resourceFromUrl(value, {noBusinessRules: options.noBusinessRules}); - if (field.isDependent()) { - console.warn("expected dependent resource to be in cache"); - this.storeDependent(field, toOne); - } - } - // If we want a field within the related resource then recur - return (path.length > 1) ? toOne.rget(_.tail(path)) : toOne; - } - case 'one-to-many': { - if (path.length !== 1) { - throw "can't traverse into a collection using dot notation"; - } - - // Is the collection cached? - let toMany = this.dependentResources[fieldName]; - if (!toMany) { - const collectionOptions = { field: field.getReverse(), related: this }; - - if (!field.isDependent()) { - return new related.ToOneCollection(collectionOptions); - } - - if (this.isNew()) { - toMany = new related.DependentCollection(collectionOptions, []); - this.storeDependent(field, toMany); - return toMany; - } else { - console.warn("expected dependent resource to be in cache"); - const temporaryCollection = new related.ToOneCollection(collectionOptions); - return temporaryCollection.fetch({ limit: 0 }).then(() => new related.DependentCollection(collectionOptions, temporaryCollection.models)).then((toMany) => { _this.storeDependent(field, toMany); }); - } - } - } - case 'zero-to-one': { - /* - * This is like a one-to-many where the many cannot be more than one - * i.e. the current resource is the target of a FK - */ - - // Is it already cached? - if (!_.isUndefined(this.dependentResources[fieldName])) { - value = this.dependentResources[fieldName]; - if (value == null) return null; - // Recur if we need to traverse more - return (path.length === 1) ? value : value.rget(_.tail(path)); - } - - // If this resource is not yet persisted, the related object can't point to it yet - if (this.isNew()) return undefined; // TEST: this seems iffy - - const collection = new related.ToOneCollection({ field: field.getReverse(), related: this, limit: 1 }); - - // Fetch the collection and pretend like it is a single resource - return collection.fetchIfNotPopulated().then(() => { - const value = collection.isEmpty() ? null : collection.first(); - if (field.isDependent()) { - console.warn("expect dependent resource to be in cache"); - _this.storeDependent(field, value); - } - if (value == null) return null; - return (path.length === 1) ? value : value.rget(_.tail(path)); - }); - } - default: { - softFail(`unhandled relationship type: ${ field.type}`); - throw 'unhandled relationship type'; - } - } - }, - save({onSaveConflict:handleSaveConflict,errorOnAlreadySaving=true}={}) { - const resource = this; - if (resource._save) { - if(errorOnAlreadySaving) - throw new Error('resource is already being saved'); - else return resource._save; - } - const didNeedSaved = resource.needsSaved; - resource.needsSaved = false; - // BUG: should do this for dependent resources too - - let errorHandled = false; - const save = ()=>Backbone.Model.prototype.save.apply(resource, []) - .then(()=>resource.trigger('saved')); - resource._save = - typeof handleSaveConflict === 'function' - ? hijackBackboneAjax([Http.OK, Http.CONFLICT, Http.CREATED], save, (status) =>{ - if(status === Http.CONFLICT) { - handleSaveConflict() - errorHandled = true; - } - }) - : save(); - - resource._save.catch((error) => { - resource._save = null; - resource.needsSaved = didNeedSaved; - didNeedSaved && resource.trigger('saverequired'); - if(typeof handleSaveConflict === 'function' && errorHandled) - Object.defineProperty(error, 'handledBy', { - value: handleSaveConflict, - }); - throw error; - }).then(() => { - resource._save = null; - }); - - return resource._save.then(()=>resource); - }, - async destroy(...args) { - const promise = await Backbone.Model.prototype.destroy.apply(this, ...args); - resourceEvents.trigger('deleted', this); - return promise; - }, - toJSON() { - const self = this; - const json = Backbone.Model.prototype.toJSON.apply(self, arguments); - - _.each(self.dependentResources, (related, fieldName) => { - const field = self.specifyModel.getField(fieldName); - if (field.type === 'zero-to-one') { - json[fieldName] = related ? [related.toJSON()] : []; - } else { - json[fieldName] = related ? related.toJSON() : null; - } - }); - return json; - }, - // Caches a reference to Promise so as not to start fetching twice - async fetch(options) { - if( - // If already populated - this.populated || - // Or if can't be populated by fetching - this.isNew() - ) - return this; - else if (this._fetch) return this._fetch; - else - return this._fetch = Backbone.Model.prototype.fetch.call(this, options).then(()=>{ - this._fetch = null; - // BUG: consider doing this.needsSaved=false here - return this; - }); - }, - parse(_resp) { - // Since we are putting in data, the resource in now populated - this.populated = true; - return Reflect.apply(Backbone.Model.prototype.parse, this, arguments); - }, - async sync(method, resource, options) { - options ||= {}; - if(method === 'delete') - // When deleting we don't send any data so put the version in a header - options.headers = {'If-Match': resource.get('version')}; - return Backbone.sync(method, resource, options); - }, - async placeInSameHierarchy(other) { - const self = this; - const myPath = self.specifyModel.getScopingPath(); - const otherPath = other.specifyModel.getScopingPath(); - if (!myPath || !otherPath) return undefined; - if (myPath.length > otherPath.length) return undefined; - const diff = _(otherPath).rest(myPath.length - 1).reverse(); - // REFACTOR: use mappingPathToString in all places like this - return other.rget(diff.join('.')).then((common) => { - if(common === undefined) return undefined; - self.set(_(diff).last(), common.url()); - return common; - }); - }, - getDependentResource(fieldName){ - return this.dependentResources[fieldName.toLowerCase()]; + const _this = this; + const related = field.relatedModel; + switch (field.type) { + case 'one-to-one': + case 'many-to-one': { + // A foreign key field. + if (!value) return value; // No related object + + // Is the related resource cached? + let toOne = this.dependentResources[fieldName]; + if (!toOne) { + _(value).isString() || softFail('expected URI, got', value); + toOne = resourceFromUrl(value, { + noBusinessRules: options.noBusinessRules, + }); + if (field.isDependent()) { + console.warn('expected dependent resource to be in cache'); + this.storeDependent(field, toOne); + } + } + // If we want a field within the related resource then recur + return path.length > 1 ? toOne.rget(_.tail(path)) : toOne; + } + case 'one-to-many': { + if (path.length !== 1) { + throw "can't traverse into a collection using dot notation"; } + + // Is the collection cached? + let toMany = this.dependentResources[fieldName]; + if (!toMany) { + const collectionOptions = { + field: field.getReverse(), + related: this, + }; + + if (!field.isDependent()) { + return new related.ToOneCollection(collectionOptions); + } + + if (this.isNew()) { + toMany = new related.DependentCollection(collectionOptions, []); + this.storeDependent(field, toMany); + return toMany; + } else { + console.warn('expected dependent resource to be in cache'); + const temporaryCollection = new related.ToOneCollection( + collectionOptions + ); + return temporaryCollection + .fetch({ limit: 0 }) + .then( + () => + new related.DependentCollection( + collectionOptions, + temporaryCollection.models + ) + ) + .then((toMany) => { + _this.storeDependent(field, toMany); + }); + } + } + } + case 'zero-to-one': { + /* + * This is like a one-to-many where the many cannot be more than one + * i.e. the current resource is the target of a FK + */ + + // Is it already cached? + if (!_.isUndefined(this.dependentResources[fieldName])) { + value = this.dependentResources[fieldName]; + if (value == null) return null; + // Recur if we need to traverse more + return path.length === 1 ? value : value.rget(_.tail(path)); + } + + // If this resource is not yet persisted, the related object can't point to it yet + if (this.isNew()) return undefined; // TEST: this seems iffy + + const collection = new related.ToOneCollection({ + field: field.getReverse(), + related: this, + limit: 1, + }); + + // Fetch the collection and pretend like it is a single resource + return collection.fetchIfNotPopulated().then(() => { + const value = collection.isEmpty() ? null : collection.first(); + if (field.isDependent()) { + console.warn('expect dependent resource to be in cache'); + _this.storeDependent(field, value); + } + if (value == null) return null; + return path.length === 1 ? value : value.rget(_.tail(path)); + }); + } + default: { + softFail(`unhandled relationship type: ${field.type}`); + throw 'unhandled relationship type'; + } + } + }, + save({ + onSaveConflict: handleSaveConflict, + errorOnAlreadySaving = true, + } = {}) { + const resource = this; + if (resource._save) { + if (errorOnAlreadySaving) + throw new Error('resource is already being saved'); + else return resource._save; + } + const didNeedSaved = resource.needsSaved; + resource.needsSaved = false; + // BUG: should do this for dependent resources too + + let errorHandled = false; + const save = () => + Backbone.Model.prototype.save + .apply(resource, []) + .then(() => resource.trigger('saved')); + resource._save = + typeof handleSaveConflict === 'function' + ? hijackBackboneAjax( + [Http.OK, Http.CONFLICT, Http.CREATED], + save, + (status) => { + if (status === Http.CONFLICT) { + handleSaveConflict(); + errorHandled = true; + } + } + ) + : save(); + + resource._save + .catch((error) => { + resource._save = null; + resource.needsSaved = didNeedSaved; + didNeedSaved && resource.trigger('saverequired'); + if (typeof handleSaveConflict === 'function' && errorHandled) + Object.defineProperty(error, 'handledBy', { + value: handleSaveConflict, + }); + throw error; + }) + .then(() => { + resource._save = null; + }); + + return resource._save.then(() => resource); + }, + async destroy(...args) { + const promise = await Backbone.Model.prototype.destroy.apply(this, ...args); + resourceEvents.trigger('deleted', this); + return promise; + }, + toJSON() { + const self = this; + const json = Backbone.Model.prototype.toJSON.apply(self, arguments); + + _.each(self.dependentResources, (related, fieldName) => { + const field = self.specifyModel.getField(fieldName); + if (field.type === 'zero-to-one') { + json[fieldName] = related ? [related.toJSON()] : []; + } else { + json[fieldName] = related ? related.toJSON() : null; + } + }); + return json; + }, + // Caches a reference to Promise so as not to start fetching twice + async fetch(options) { + if ( + // If already populated + this.populated || + // Or if can't be populated by fetching + this.isNew() + ) + return this; + else if (this._fetch) return this._fetch; + else + return (this._fetch = Backbone.Model.prototype.fetch + .call(this, options) + .then(() => { + this._fetch = null; + // BUG: consider doing this.needsSaved=false here + return this; + })); + }, + parse(_resp) { + // Since we are putting in data, the resource in now populated + this.populated = true; + return Reflect.apply(Backbone.Model.prototype.parse, this, arguments); + }, + async sync(method, resource, options) { + options ||= {}; + if (method === 'delete') + // When deleting we don't send any data so put the version in a header + options.headers = { 'If-Match': resource.get('version') }; + return Backbone.sync(method, resource, options); + }, + async placeInSameHierarchy(other) { + const self = this; + const myPath = self.specifyModel.getScopingPath(); + const otherPath = other.specifyModel.getScopingPath(); + if (!myPath || !otherPath) return undefined; + if (myPath.length > otherPath.length) return undefined; + const diff = _(otherPath) + .rest(myPath.length - 1) + .reverse(); + // REFACTOR: use mappingPathToString in all places like this + return other.rget(diff.join('.')).then((common) => { + if (common === undefined) return undefined; + self.set(_(diff).last(), common.url()); + return common; }); + }, + getDependentResource(fieldName) { + return this.dependentResources[fieldName.toLowerCase()]; + }, +}); export function promiseToXhr(promise) { promise.done = function (function_) { diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx index 6629f8a0173..ed3ed3afcb7 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx @@ -20,6 +20,9 @@ import { hasTablePermission } from '../Permissions/helpers'; import { SetUnloadProtectsContext } from '../Router/Router'; import type { RecordSelectorProps } from './RecordSelector'; import { useRecordSelector } from './RecordSelector'; +import { RecordSetAttachments } from '../Attachments/RecordSetAttachment'; +import { attachmentsText } from '../../localization/attachments'; +import { tablesWithAttachments } from '../Attachments'; /** * A Wrapper for RecordSelector that allows to specify list of records by their @@ -45,6 +48,7 @@ export function RecordSelectorFromIds({ onAdd: handleAdd, onClone: handleClone, onDelete: handleDelete, + onFetch: handleFetch, ...rest }: Omit, 'index' | 'records'> & { /* @@ -69,6 +73,9 @@ export function RecordSelectorFromIds({ readonly onClone: | ((newResource: SpecifyResource) => void) | undefined; + readonly onFetch?: ( + index: number + ) => Promise>; }): JSX.Element | null { const [records, setRecords] = React.useState< RA | undefined> @@ -76,6 +83,8 @@ export function RecordSelectorFromIds({ ids.map((id) => (id === undefined ? undefined : new model.Resource({ id }))) ); + const [attachmentState, setAttachmentState] = React.useState(false); + const previousIds = React.useRef(ids); React.useEffect(() => { setRecords((records) => @@ -183,6 +192,9 @@ export function RecordSelectorFromIds({ recordSetTable: schema.models.RecordSet.label, }) : commonText.delete(); + + const hasAttachments = tablesWithAttachments().includes(model); + return ( <> ({
{headerButtons} - - {hasTablePermission( model.name, isDependent ? 'create' : 'read' @@ -209,7 +219,6 @@ export function RecordSelectorFromIds({ onClick={handleAdding} /> ) : undefined} - {typeof handleRemove === 'function' && canRemove ? ( ({ onClick={(): void => handleRemove('minusButton')} /> ) : undefined} - {typeof newResource === 'object' ? (

{formsText.creatingNewRecord()}

) : ( @@ -226,7 +234,18 @@ export function RecordSelectorFromIds({ className={`flex-1 ${dialog === false ? '-ml-2' : '-ml-4'}`} /> )} - + {hasAttachments && ( + setAttachmentState(true)}> + {attachmentsText.attachments()} + + )} + {attachmentState === true ? ( + setAttachmentState(!attachmentState)} + onFetch={handleFetch} + /> + ) : null} {specifyNetworkBadge}
{slider}
diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx index e3942adb90d..34cddb3749e 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx @@ -198,7 +198,7 @@ function RecordSet({ replace: boolean = false ): void => recordId === undefined - ? handleFetch(index) + ? handleFetchMore(index) : navigate( getResourceViewUrl( currentRecord.specifyModel.name, @@ -221,10 +221,10 @@ function RecordSet({ const previousIndex = React.useRef(currentIndex); const [isLoading, handleLoading, handleLoaded] = useBooleanState(); const handleFetch = React.useCallback( - (index: number): void => { - if (index >= totalCount) return; + async (index: number): Promise> => { + if (index >= totalCount) return undefined; handleLoading(); - fetchItems( + return fetchItems( recordSet.id, // If new index is smaller (i.e, going back), fetch previous 40 IDs clamp( @@ -232,28 +232,41 @@ function RecordSet({ previousIndex.current > index ? index - fetchSize + 1 : index, totalCount ) - ) - .then((updates) => - setIds((oldIds = []) => { - handleLoaded(); - const newIds = updateIds(oldIds, updates); - go(index, newIds[index]); - return newIds; - }) - ) - .catch(softFail); + ).then( + (updates) => + new Promise((resolve) => + setIds((oldIds = []) => { + handleLoaded(); + const newIds = updateIds(oldIds, updates); + resolve(newIds); + return newIds; + }) + ) + ); }, [totalCount, recordSet.id, loading, handleLoading, handleLoaded] ); + const handleFetchMore = React.useCallback( + (index: number): void => { + handleFetch(index) + .then((newIds) => { + if (newIds === undefined) return; + go(index, newIds[index]); + }) + .catch(softFail); + }, + [handleFetch] + ); + // Fetch ID of record at current index const currentRecordId = ids[currentIndex]; React.useEffect(() => { - if (currentRecordId === undefined) handleFetch(currentIndex); + if (currentRecordId === undefined) handleFetchMore(currentIndex); return (): void => { previousIndex.current = currentIndex; }; - }, [totalCount, currentRecordId, handleFetch, currentIndex]); + }, [totalCount, currentRecordId, handleFetchMore, currentIndex]); const [hasDuplicate, handleHasDuplicate, handleDismissDuplicate] = useBooleanState(); @@ -381,6 +394,7 @@ function RecordSet({ onSlide={(index, replace): void => go(index, ids[index], undefined, replace) } + onFetch={handleFetch} /> {hasDuplicate && ( Date: Tue, 18 Apr 2023 07:08:33 -0700 Subject: [PATCH 02/24] Fix failing tests, change localized strings --- .../js_src/lib/components/Attachments/Gallery.tsx | 4 +++- .../components/Attachments/RecordSetAttachment.tsx | 14 +++++++------- .../lib/components/FormSliders/RecordSet.tsx | 1 + .../js_src/lib/localization/attachments.ts | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx index 664551d341a..feb8719649c 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx @@ -25,7 +25,9 @@ export function AttachmentGallery({ onChange: handleChange, }: { readonly attachments: RA>; - readonly onFetchMore: (() => Promise) | undefined; + readonly onFetchMore: + | (() => Promise>) + | undefined; readonly scale: number; readonly isComplete: boolean; readonly onChange: ( diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index f3eb3f48957..96dc0356f62 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -20,9 +20,9 @@ export function RecordSetAttachments({ }: { readonly records: RA | undefined>; readonly onClose: () => void; - readonly onFetch?: ( - index: number - ) => Promise>; + readonly onFetch?: + | ((index: number) => Promise>) + | undefined; }): JSX.Element { const recordFetched = React.useRef(0); @@ -69,20 +69,20 @@ export function RecordSetAttachments({ haltValue === records.length ? ( <>{attachmentsText.noAttachments()} ) : ( - <> +
{attachmentsText.attachmentHaltLimit({ halt: haltValue })} { - if (haltValue * 2 > records.length) { + if (haltValue + 300 > records.length) { setHaltValue(records.length); } else { - setHaltValue(haltValue * 2); + setHaltValue(haltValue + 300); } }} > {attachmentsText.fetchNextAttachments()} - +
) ) : ( ({ const previousIndex = React.useRef(currentIndex); const [isLoading, handleLoading, handleLoaded] = useBooleanState(); + const handleFetch = React.useCallback( async (index: number): Promise> => { if (index >= totalCount) return undefined; diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index 77ac954c178..155198d30ef 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -89,6 +89,6 @@ export const attachmentsText = createDictionary({ 'No attachments have been found in the first {halt:number} records.', }, fetchNextAttachments: { - 'en-us': 'Try to fetch next attachments', + 'en-us': 'Look for more attachments', }, } as const); From 319ec82ce50d4943a7d36df9c96fa4bf8e6d7080 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Tue, 14 Mar 2023 22:14:13 -0500 Subject: [PATCH 03/24] Allow to create record set from express search results Fixes #3316 --- .../js_src/lib/components/Molecules/AppTitle.tsx | 10 ++++++++-- .../js_src/lib/components/Router/RouterUtils.tsx | 7 ++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Molecules/AppTitle.tsx b/specifyweb/frontend/js_src/lib/components/Molecules/AppTitle.tsx index 4df3c946b73..377bcc0c99a 100644 --- a/specifyweb/frontend/js_src/lib/components/Molecules/AppTitle.tsx +++ b/specifyweb/frontend/js_src/lib/components/Molecules/AppTitle.tsx @@ -9,13 +9,19 @@ import { mainText } from '../../localization/main'; import { userPreferences } from '../Preferences/userPreferences'; import { UnloadProtectsContext } from '../Router/Router'; -export function AppTitle({ title }: { readonly title: LocalizedString }): null { +export function AppTitle({ + title, + source = 'form', +}: { + readonly title: LocalizedString; + readonly source?: 'form' | undefined; +}): null { const [updateTitle] = userPreferences.use( 'form', 'behavior', 'updatePageTitle' ); - useTitle(updateTitle ? title : undefined); + useTitle(source !== 'form' && updateTitle ? title : undefined); return null; } diff --git a/specifyweb/frontend/js_src/lib/components/Router/RouterUtils.tsx b/specifyweb/frontend/js_src/lib/components/Router/RouterUtils.tsx index a7a5dd7835e..ff827ccee17 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/RouterUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/RouterUtils.tsx @@ -9,7 +9,7 @@ import type { LocalizedString } from 'typesafe-i18n'; import type { IR, RA, WritableArray } from '../../utils/types'; import { ErrorBoundary } from '../Errors/ErrorBoundary'; -import { useTitle } from '../Molecules/AppTitle'; +import { AppTitle } from '../Molecules/AppTitle'; import { LoadingScreen } from '../Molecules/Dialog'; /** @@ -95,10 +95,11 @@ export function Async({ readonly Element: React.FunctionComponent; readonly title: LocalizedString | undefined; }): JSX.Element { - useTitle(title); - return ( }> + {typeof title === 'string' && ( + + )} ); From 0dafeaab02dbd6592dbbaad25cb191bde9b1c6ce Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Mon, 10 Apr 2023 20:27:59 +0000 Subject: [PATCH 04/24] Lint code with ESLint and Prettier Triggered by 5e93f97ab6477139a5c0f9e3272b688062961e3c on branch refs/heads/issue-3316 --- .../frontend/js_src/lib/components/Router/RouterUtils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Router/RouterUtils.tsx b/specifyweb/frontend/js_src/lib/components/Router/RouterUtils.tsx index ff827ccee17..d983ed8b8c1 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/RouterUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/RouterUtils.tsx @@ -98,7 +98,7 @@ export function Async({ return ( }> {typeof title === 'string' && ( - + )} From 23e1586aecdf108610441f1b13fb30bd609e0238 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Mon, 17 Apr 2023 14:26:50 -0700 Subject: [PATCH 05/24] Implement attachment viewer gallery in record sets Fixes #2132 --- .../lib/components/Attachments/Gallery.tsx | 12 +- .../Attachments/RecordSetAttachment.tsx | 113 ++++++++++++++++++ .../lib/components/Attachments/index.tsx | 10 +- .../FormSliders/RecordSelectorFromIds.tsx | 29 ++++- .../lib/components/FormSliders/RecordSet.tsx | 47 +++++--- .../js_src/lib/localization/attachments.ts | 7 ++ 6 files changed, 191 insertions(+), 27 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx index a83d6b54fcc..a97873a3b11 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx @@ -25,10 +25,15 @@ export function AttachmentGallery({ onChange: handleChange, }: { readonly attachments: RA>; - readonly onFetchMore: (() => Promise) | undefined; + readonly onFetchMore: + | (() => Promise | void>) + | undefined; readonly scale: number; readonly isComplete: boolean; - readonly onChange: (attachments: RA>) => void; + readonly onChange: ( + attachment: SerializedResource, + index: number + ) => void; }): JSX.Element { const containerRef = React.useRef(null); @@ -61,6 +66,7 @@ export function AttachmentGallery({ const [openIndex, setOpenIndex] = React.useState( undefined ); + const [related, setRelated] = React.useState< RA | undefined> >([]); @@ -121,7 +127,7 @@ export function AttachmentGallery({ (item): void => setRelated(replaceItem(related, openIndex, item)), ]} onChange={(newAttachment): void => - handleChange(replaceItem(attachments, openIndex, newAttachment)) + handleChange(newAttachment, openIndex) } onClose={(): void => setOpenIndex(undefined)} onNext={ diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx new file mode 100644 index 00000000000..34b88e5e01f --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -0,0 +1,113 @@ +import React from 'react'; + +import { useAsyncState } from '../../hooks/useAsyncState'; +import { useCachedState } from '../../hooks/useCachedState'; +import { attachmentsText } from '../../localization/attachments'; +import { commonText } from '../../localization/common'; +import type { RA } from '../../utils/types'; +import { filterArray } from '../../utils/types'; +import { Button } from '../Atoms/Button'; +import { serializeResource } from '../DataModel/helpers'; +import type { AnySchema } from '../DataModel/helperTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import type { CollectionObjectAttachment } from '../DataModel/types'; +import { Dialog } from '../Molecules/Dialog'; +import { defaultScale } from '.'; +import { AttachmentGallery } from './Gallery'; + +export function RecordSetAttachments({ + records, + onClose: handleClose, + onFetch: handleFetch, +}: { + readonly records: RA | undefined>; + readonly onClose: () => void; + readonly onFetch?: + | ((index: number) => Promise | void>) + | undefined; +}): JSX.Element { + const recordFetched = React.useRef(0); + + const [attachments] = useAsyncState( + React.useCallback(async () => { + const relatedAttachementRecords = await Promise.all( + records.map((record) => + record + ?.rgetCollection(`${record.specifyModel.name}Attachments`) + .then( + ({ models }) => + models as RA> + ) + ) + ); + + const fetchCount = records.findIndex( + (record) => record?.populated !== true + ); + + recordFetched.current = fetchCount === -1 ? records.length : fetchCount; + + return Promise.all( + filterArray(relatedAttachementRecords.flat()).map( + async (collectionObjectAttachment) => ({ + attachment: await collectionObjectAttachment + .rgetPromise('attachment') + .then((resource) => serializeResource(resource)), + related: collectionObjectAttachment, + }) + ) + ); + }, [records]), + true + ); + + const [haltValue, setHaltValue] = React.useState(300); + const halt = attachments?.length === 0 && records.length >= haltValue; + + const [scale = defaultScale] = useCachedState('attachments', 'scale'); + + const children = halt ? ( + haltValue === records.length ? ( + <>{attachmentsText.noAttachments()} + ) : ( +
+ {attachmentsText.attachmentHaltLimit({ halt: haltValue })} + { + if (haltValue + 300 > records.length) { + setHaltValue(records.length); + } else { + setHaltValue(haltValue + 300); + } + }} + > + {attachmentsText.fetchNextAttachments()} + +
+ ) + ) : ( + attachment) ?? []} + isComplete={recordFetched.current === records.length} + scale={scale} + onChange={(attachment, index): void => + void attachments?.[index].related.set(`attachment`, attachment) + } + onFetchMore={ + attachments === undefined || handleFetch === undefined || halt + ? undefined + : async () => handleFetch?.(recordFetched.current) + } + /> + ); + + return ( + {commonText.close()}} + header={attachmentsText.attachments()} + onClose={handleClose} + > + {children} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx index 3e270462416..27737b138ac 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx @@ -26,6 +26,7 @@ import { ProtectedTable } from '../Permissions/PermissionDenied'; import { OrderPicker } from '../Preferences/Renderers'; import { attachmentSettingsPromise } from './attachments'; import { AttachmentGallery } from './Gallery'; +import { replaceItem } from '../../utils/utils'; export const attachmentRelatedTables = f.store(() => Object.keys(schema.models).filter((tableName) => @@ -47,7 +48,7 @@ export const tablesWithAttachments = f.store(() => ) ); -const defaultScale = 10; +export const defaultScale = 10; const minScale = 4; const maxScale = 50; const defaultSortOrder = '-timestampCreated'; @@ -242,10 +243,13 @@ function Attachments(): JSX.Element { } key={`${order}_${JSON.stringify(filter)}`} scale={scale} - onChange={(records): void => + onChange={(attachment, index): void => collection === undefined ? undefined - : setCollection({ records, totalCount: collection.totalCount }) + : setCollection({ + records: replaceItem(collection.records, index, attachment), + totalCount: collection.totalCount, + }) } onFetchMore={collection === undefined ? undefined : fetchMore} /> diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx index 6629f8a0173..ed3ed3afcb7 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx @@ -20,6 +20,9 @@ import { hasTablePermission } from '../Permissions/helpers'; import { SetUnloadProtectsContext } from '../Router/Router'; import type { RecordSelectorProps } from './RecordSelector'; import { useRecordSelector } from './RecordSelector'; +import { RecordSetAttachments } from '../Attachments/RecordSetAttachment'; +import { attachmentsText } from '../../localization/attachments'; +import { tablesWithAttachments } from '../Attachments'; /** * A Wrapper for RecordSelector that allows to specify list of records by their @@ -45,6 +48,7 @@ export function RecordSelectorFromIds({ onAdd: handleAdd, onClone: handleClone, onDelete: handleDelete, + onFetch: handleFetch, ...rest }: Omit, 'index' | 'records'> & { /* @@ -69,6 +73,9 @@ export function RecordSelectorFromIds({ readonly onClone: | ((newResource: SpecifyResource) => void) | undefined; + readonly onFetch?: ( + index: number + ) => Promise>; }): JSX.Element | null { const [records, setRecords] = React.useState< RA | undefined> @@ -76,6 +83,8 @@ export function RecordSelectorFromIds({ ids.map((id) => (id === undefined ? undefined : new model.Resource({ id }))) ); + const [attachmentState, setAttachmentState] = React.useState(false); + const previousIds = React.useRef(ids); React.useEffect(() => { setRecords((records) => @@ -183,6 +192,9 @@ export function RecordSelectorFromIds({ recordSetTable: schema.models.RecordSet.label, }) : commonText.delete(); + + const hasAttachments = tablesWithAttachments().includes(model); + return ( <> ({
{headerButtons} - - {hasTablePermission( model.name, isDependent ? 'create' : 'read' @@ -209,7 +219,6 @@ export function RecordSelectorFromIds({ onClick={handleAdding} /> ) : undefined} - {typeof handleRemove === 'function' && canRemove ? ( ({ onClick={(): void => handleRemove('minusButton')} /> ) : undefined} - {typeof newResource === 'object' ? (

{formsText.creatingNewRecord()}

) : ( @@ -226,7 +234,18 @@ export function RecordSelectorFromIds({ className={`flex-1 ${dialog === false ? '-ml-2' : '-ml-4'}`} /> )} - + {hasAttachments && ( + setAttachmentState(true)}> + {attachmentsText.attachments()} + + )} + {attachmentState === true ? ( + setAttachmentState(!attachmentState)} + onFetch={handleFetch} + /> + ) : null} {specifyNetworkBadge}
{slider}
diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx index e3942adb90d..49245a33e7f 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx @@ -198,7 +198,7 @@ function RecordSet({ replace: boolean = false ): void => recordId === undefined - ? handleFetch(index) + ? handleFetchMore(index) : navigate( getResourceViewUrl( currentRecord.specifyModel.name, @@ -220,11 +220,12 @@ function RecordSet({ const previousIndex = React.useRef(currentIndex); const [isLoading, handleLoading, handleLoaded] = useBooleanState(); + const handleFetch = React.useCallback( - (index: number): void => { - if (index >= totalCount) return; + async (index: number): Promise | undefined> => { + if (index >= totalCount) return undefined; handleLoading(); - fetchItems( + return fetchItems( recordSet.id, // If new index is smaller (i.e, going back), fetch previous 40 IDs clamp( @@ -232,28 +233,41 @@ function RecordSet({ previousIndex.current > index ? index - fetchSize + 1 : index, totalCount ) - ) - .then((updates) => - setIds((oldIds = []) => { - handleLoaded(); - const newIds = updateIds(oldIds, updates); - go(index, newIds[index]); - return newIds; - }) - ) - .catch(softFail); + ).then( + async (updates) => + new Promise((resolve) => + setIds((oldIds = []) => { + handleLoaded(); + const newIds = updateIds(oldIds, updates); + resolve(newIds); + return newIds; + }) + ) + ); }, [totalCount, recordSet.id, loading, handleLoading, handleLoaded] ); + const handleFetchMore = React.useCallback( + (index: number): void => { + handleFetch(index) + .then((newIds) => { + if (newIds === undefined) return; + go(index, newIds[index]); + }) + .catch(softFail); + }, + [handleFetch] + ); + // Fetch ID of record at current index const currentRecordId = ids[currentIndex]; React.useEffect(() => { - if (currentRecordId === undefined) handleFetch(currentIndex); + if (currentRecordId === undefined) handleFetchMore(currentIndex); return (): void => { previousIndex.current = currentIndex; }; - }, [totalCount, currentRecordId, handleFetch, currentIndex]); + }, [totalCount, currentRecordId, handleFetchMore, currentIndex]); const [hasDuplicate, handleHasDuplicate, handleDismissDuplicate] = useBooleanState(); @@ -373,6 +387,7 @@ function RecordSet({ } : undefined } + onFetch={handleFetch} onSaved={(resource): void => ids[currentIndex] === resource.id ? undefined diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index eed67c233c8..155198d30ef 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -84,4 +84,11 @@ export const attachmentsText = createDictionary({ 'ru-ru': 'Показать форму', 'uk-ua': 'Показати форму', }, + attachmentHaltLimit: { + 'en-us': + 'No attachments have been found in the first {halt:number} records.', + }, + fetchNextAttachments: { + 'en-us': 'Look for more attachments', + }, } as const); From 69c67d0727eecfea0979171c3f9d6b3e146f5e4b Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Tue, 25 Apr 2023 10:00:42 -0500 Subject: [PATCH 06/24] Change variable name --- .../lib/components/Attachments/RecordSetAttachment.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index 72068e03d97..980195bcd2b 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -26,7 +26,7 @@ export function RecordSetAttachments({ | ((index: number) => Promise | void>) | undefined; }): JSX.Element { - const recordFetched = React.useRef(0); + const fetchedCount = React.useRef(0); const [showAttachments, handleShowAttachments, handleHideAttachments] = useBooleanState(); @@ -48,7 +48,7 @@ export function RecordSetAttachments({ (record) => record?.populated !== true ); - recordFetched.current = fetchCount === -1 ? records.length : fetchCount; + fetchedCount.current = fetchCount === -1 ? records.length : fetchCount; const attachements = await Promise.all( filterArray(relatedAttachementRecords.flat()).map( @@ -122,7 +122,7 @@ export function RecordSetAttachments({ ) : ( void attachments?.related[index].set(`attachment`, attachment) @@ -130,8 +130,7 @@ export function RecordSetAttachments({ onFetchMore={ attachments === undefined || handleFetch === undefined || halt ? undefined - : async () => - handleFetch?.(recordFetched.current).then(f.void) + : async () => handleFetch?.(fetchedCount.current).then(f.void) } /> )} From b596831dddb99cfa319fcebaf1ddf05dca5faf83 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Tue, 25 Apr 2023 10:03:36 -0500 Subject: [PATCH 07/24] Fix failing tests --- .../frontend/js_src/lib/components/FormSliders/RecordSet.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx index 7622aae4f68..49245a33e7f 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx @@ -396,7 +396,6 @@ function RecordSet({ onSlide={(index, replace): void => go(index, ids[index], undefined, replace) } - onFetch={handleFetch} /> {hasDuplicate && ( Date: Wed, 26 Apr 2023 14:58:52 -0500 Subject: [PATCH 08/24] Implement attachment viewer gallery in record sets --- .../lib/components/Attachments/Gallery.tsx | 8 +- .../Attachments/RecordSetAttachment.tsx | 141 ++++++++++++++++++ .../lib/components/Attachments/index.tsx | 12 +- .../FormSliders/RecordSelectorFromIds.tsx | 17 ++- .../lib/components/FormSliders/RecordSet.tsx | 47 ++++-- .../js_src/lib/localization/attachments.ts | 7 + 6 files changed, 205 insertions(+), 27 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx index a83d6b54fcc..664551d341a 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx @@ -28,7 +28,10 @@ export function AttachmentGallery({ readonly onFetchMore: (() => Promise) | undefined; readonly scale: number; readonly isComplete: boolean; - readonly onChange: (attachments: RA>) => void; + readonly onChange: ( + attachment: SerializedResource, + index: number + ) => void; }): JSX.Element { const containerRef = React.useRef(null); @@ -61,6 +64,7 @@ export function AttachmentGallery({ const [openIndex, setOpenIndex] = React.useState( undefined ); + const [related, setRelated] = React.useState< RA | undefined> >([]); @@ -121,7 +125,7 @@ export function AttachmentGallery({ (item): void => setRelated(replaceItem(related, openIndex, item)), ]} onChange={(newAttachment): void => - handleChange(replaceItem(attachments, openIndex, newAttachment)) + handleChange(newAttachment, openIndex) } onClose={(): void => setOpenIndex(undefined)} onNext={ diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx new file mode 100644 index 00000000000..980195bcd2b --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { RA, filterArray } from '../../utils/types'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import type { AnySchema } from '../DataModel/helperTypes'; +import { Dialog } from '../Molecules/Dialog'; +import { attachmentsText } from '../../localization/attachments'; +import { useAsyncState } from '../../hooks/useAsyncState'; +import { CollectionObjectAttachment } from '../DataModel/types'; +import { serializeResource } from '../DataModel/helpers'; +import { AttachmentGallery } from './Gallery'; +import { useCachedState } from '../../hooks/useCachedState'; +import { defaultAttachmentScale } from '.'; +import { Button } from '../Atoms/Button'; +import { commonText } from '../../localization/common'; +import { f } from '../../utils/functools'; +import { useBooleanState } from '../../hooks/useBooleanState'; + +const haltIncrementSize = 300; + +export function RecordSetAttachments({ + records, + onFetch: handleFetch, +}: { + readonly records: RA | undefined>; + readonly onFetch: + | ((index: number) => Promise | void>) + | undefined; +}): JSX.Element { + const fetchedCount = React.useRef(0); + + const [showAttachments, handleShowAttachments, handleHideAttachments] = + useBooleanState(); + + const [attachments] = useAsyncState( + React.useCallback(async () => { + const relatedAttachementRecords = await Promise.all( + records.map((record) => + record + ?.rgetCollection(`${record.specifyModel.name}Attachments`) + .then( + ({ models }) => + models as RA> + ) + ) + ); + + const fetchCount = records.findIndex( + (record) => record?.populated !== true + ); + + fetchedCount.current = fetchCount === -1 ? records.length : fetchCount; + + const attachements = await Promise.all( + filterArray(relatedAttachementRecords.flat()).map( + async (collectionObjectAttachment) => ({ + attachment: await collectionObjectAttachment + .rgetPromise('attachment') + .then((resource) => serializeResource(resource)), + related: collectionObjectAttachment, + }) + ) + ); + + return { + attachments: attachements.map(({ attachment }) => attachment), + related: attachements.map(({ related }) => related), + count: attachements.length, + }; + }, [records]), + true + ); + + //halt value was added to not scraped all the records for attachment in cases where there is more than 300 and no attachments, the user is able to ask for the next 300 if necessary + const [haltValue, setHaltValue] = React.useState(300); + const halt = + attachments?.attachments.length === 0 && records.length >= haltValue; + + const [scale = defaultAttachmentScale] = useCachedState( + 'attachments', + 'scale' + ); + + return ( + <> + handleShowAttachments()} + title="attachments" + > + {showAttachments && ( + {commonText.close()} + } + header={ + attachments?.count !== undefined + ? commonText.countLine({ + resource: attachmentsText.attachments(), + count: attachments.count, + }) + : attachmentsText.attachments() + } + onClose={handleHideAttachments} + > + {halt ? ( + haltValue === records.length ? ( + <>{attachmentsText.noAttachments()} + ) : ( +
+ {attachmentsText.attachmentHaltLimit({ halt: haltValue })} + + setHaltValue( + Math.min(haltValue + haltIncrementSize, records.length) + ) + } + > + {attachmentsText.fetchNextAttachments()} + +
+ ) + ) : ( + + void attachments?.related[index].set(`attachment`, attachment) + } + onFetchMore={ + attachments === undefined || handleFetch === undefined || halt + ? undefined + : async () => handleFetch?.(fetchedCount.current).then(f.void) + } + /> + )} +
+ )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx index 3e270462416..60ae487aacb 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx @@ -26,6 +26,7 @@ import { ProtectedTable } from '../Permissions/PermissionDenied'; import { OrderPicker } from '../Preferences/Renderers'; import { attachmentSettingsPromise } from './attachments'; import { AttachmentGallery } from './Gallery'; +import { replaceItem } from '../../utils/utils'; export const attachmentRelatedTables = f.store(() => Object.keys(schema.models).filter((tableName) => @@ -47,7 +48,7 @@ export const tablesWithAttachments = f.store(() => ) ); -const defaultScale = 10; +export const defaultAttachmentScale = 10; const minScale = 4; const maxScale = 50; const defaultSortOrder = '-timestampCreated'; @@ -124,7 +125,7 @@ function Attachments(): JSX.Element { false ); - const [scale = defaultScale, setScale] = useCachedState( + const [scale = defaultAttachmentScale, setScale] = useCachedState( 'attachments', 'scale' ); @@ -242,10 +243,13 @@ function Attachments(): JSX.Element { } key={`${order}_${JSON.stringify(filter)}`} scale={scale} - onChange={(records): void => + onChange={(attachment, index): void => collection === undefined ? undefined - : setCollection({ records, totalCount: collection.totalCount }) + : setCollection({ + records: replaceItem(collection.records, index, attachment), + totalCount: collection.totalCount, + }) } onFetchMore={collection === undefined ? undefined : fetchMore} /> diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx index 6629f8a0173..fe582f35a8e 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx @@ -20,6 +20,8 @@ import { hasTablePermission } from '../Permissions/helpers'; import { SetUnloadProtectsContext } from '../Router/Router'; import type { RecordSelectorProps } from './RecordSelector'; import { useRecordSelector } from './RecordSelector'; +import { RecordSetAttachments } from '../Attachments/RecordSetAttachment'; +import { tablesWithAttachments } from '../Attachments'; /** * A Wrapper for RecordSelector that allows to specify list of records by their @@ -45,6 +47,7 @@ export function RecordSelectorFromIds({ onAdd: handleAdd, onClone: handleClone, onDelete: handleDelete, + onFetch: handleFetch, ...rest }: Omit, 'index' | 'records'> & { /* @@ -69,6 +72,9 @@ export function RecordSelectorFromIds({ readonly onClone: | ((newResource: SpecifyResource) => void) | undefined; + readonly onFetch?: ( + index: number + ) => Promise>; }): JSX.Element | null { const [records, setRecords] = React.useState< RA | undefined> @@ -183,6 +189,9 @@ export function RecordSelectorFromIds({ recordSetTable: schema.models.RecordSet.label, }) : commonText.delete(); + + const hasAttachments = tablesWithAttachments().includes(model); + return ( <> ({
{headerButtons} - - {hasTablePermission( model.name, isDependent ? 'create' : 'read' @@ -209,7 +216,6 @@ export function RecordSelectorFromIds({ onClick={handleAdding} /> ) : undefined} - {typeof handleRemove === 'function' && canRemove ? ( ({ onClick={(): void => handleRemove('minusButton')} /> ) : undefined} - {typeof newResource === 'object' ? (

{formsText.creatingNewRecord()}

) : ( @@ -226,7 +231,9 @@ export function RecordSelectorFromIds({ className={`flex-1 ${dialog === false ? '-ml-2' : '-ml-4'}`} /> )} - + {hasAttachments && ( + + )} {specifyNetworkBadge}
{slider}
diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx index e3942adb90d..49245a33e7f 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx @@ -198,7 +198,7 @@ function RecordSet({ replace: boolean = false ): void => recordId === undefined - ? handleFetch(index) + ? handleFetchMore(index) : navigate( getResourceViewUrl( currentRecord.specifyModel.name, @@ -220,11 +220,12 @@ function RecordSet({ const previousIndex = React.useRef(currentIndex); const [isLoading, handleLoading, handleLoaded] = useBooleanState(); + const handleFetch = React.useCallback( - (index: number): void => { - if (index >= totalCount) return; + async (index: number): Promise | undefined> => { + if (index >= totalCount) return undefined; handleLoading(); - fetchItems( + return fetchItems( recordSet.id, // If new index is smaller (i.e, going back), fetch previous 40 IDs clamp( @@ -232,28 +233,41 @@ function RecordSet({ previousIndex.current > index ? index - fetchSize + 1 : index, totalCount ) - ) - .then((updates) => - setIds((oldIds = []) => { - handleLoaded(); - const newIds = updateIds(oldIds, updates); - go(index, newIds[index]); - return newIds; - }) - ) - .catch(softFail); + ).then( + async (updates) => + new Promise((resolve) => + setIds((oldIds = []) => { + handleLoaded(); + const newIds = updateIds(oldIds, updates); + resolve(newIds); + return newIds; + }) + ) + ); }, [totalCount, recordSet.id, loading, handleLoading, handleLoaded] ); + const handleFetchMore = React.useCallback( + (index: number): void => { + handleFetch(index) + .then((newIds) => { + if (newIds === undefined) return; + go(index, newIds[index]); + }) + .catch(softFail); + }, + [handleFetch] + ); + // Fetch ID of record at current index const currentRecordId = ids[currentIndex]; React.useEffect(() => { - if (currentRecordId === undefined) handleFetch(currentIndex); + if (currentRecordId === undefined) handleFetchMore(currentIndex); return (): void => { previousIndex.current = currentIndex; }; - }, [totalCount, currentRecordId, handleFetch, currentIndex]); + }, [totalCount, currentRecordId, handleFetchMore, currentIndex]); const [hasDuplicate, handleHasDuplicate, handleDismissDuplicate] = useBooleanState(); @@ -373,6 +387,7 @@ function RecordSet({ } : undefined } + onFetch={handleFetch} onSaved={(resource): void => ids[currentIndex] === resource.id ? undefined diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index eed67c233c8..155198d30ef 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -84,4 +84,11 @@ export const attachmentsText = createDictionary({ 'ru-ru': 'Показать форму', 'uk-ua': 'Показати форму', }, + attachmentHaltLimit: { + 'en-us': + 'No attachments have been found in the first {halt:number} records.', + }, + fetchNextAttachments: { + 'en-us': 'Look for more attachments', + }, } as const); From fbf0c2cd21b1449112fbbf68308e1238889b0f83 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Wed, 26 Apr 2023 20:06:32 +0000 Subject: [PATCH 09/24] Lint code with ESLint and Prettier Triggered by 082e72c99c53f3a195e7cfd174a86f34a069a533 on branch refs/heads/issue-2132 --- .../Attachments/RecordSetAttachment.tsx | 36 ++++++++++--------- .../lib/components/Attachments/index.tsx | 2 +- .../FormSliders/RecordSelectorFromIds.tsx | 6 ++-- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index 980195bcd2b..ff06d7794cc 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -1,19 +1,21 @@ import React from 'react'; -import { RA, filterArray } from '../../utils/types'; -import { SpecifyResource } from '../DataModel/legacyTypes'; -import type { AnySchema } from '../DataModel/helperTypes'; -import { Dialog } from '../Molecules/Dialog'; -import { attachmentsText } from '../../localization/attachments'; + import { useAsyncState } from '../../hooks/useAsyncState'; -import { CollectionObjectAttachment } from '../DataModel/types'; -import { serializeResource } from '../DataModel/helpers'; -import { AttachmentGallery } from './Gallery'; +import { useBooleanState } from '../../hooks/useBooleanState'; import { useCachedState } from '../../hooks/useCachedState'; -import { defaultAttachmentScale } from '.'; -import { Button } from '../Atoms/Button'; +import { attachmentsText } from '../../localization/attachments'; import { commonText } from '../../localization/common'; import { f } from '../../utils/functools'; -import { useBooleanState } from '../../hooks/useBooleanState'; +import type { RA } from '../../utils/types'; +import { filterArray } from '../../utils/types'; +import { Button } from '../Atoms/Button'; +import { serializeResource } from '../DataModel/helpers'; +import type { AnySchema } from '../DataModel/helperTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import type { CollectionObjectAttachment } from '../DataModel/types'; +import { Dialog } from '../Molecules/Dialog'; +import { defaultAttachmentScale } from '.'; +import { AttachmentGallery } from './Gallery'; const haltIncrementSize = 300; @@ -70,7 +72,7 @@ export function RecordSetAttachments({ true ); - //halt value was added to not scraped all the records for attachment in cases where there is more than 300 and no attachments, the user is able to ask for the next 300 if necessary + // Halt value was added to not scraped all the records for attachment in cases where there is more than 300 and no attachments, the user is able to ask for the next 300 if necessary const [haltValue, setHaltValue] = React.useState(300); const halt = attachments?.attachments.length === 0 && records.length >= haltValue; @@ -84,21 +86,21 @@ export function RecordSetAttachments({ <> handleShowAttachments()} title="attachments" - > + onClick={() => handleShowAttachments()} + /> {showAttachments && ( {commonText.close()} } header={ - attachments?.count !== undefined - ? commonText.countLine({ + attachments?.count === undefined + ? attachmentsText.attachments() + : commonText.countLine({ resource: attachmentsText.attachments(), count: attachments.count, }) - : attachmentsText.attachments() } onClose={handleHideAttachments} > diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx index 60ae487aacb..305069b6b98 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx @@ -13,6 +13,7 @@ import { commonText } from '../../localization/common'; import { schemaText } from '../../localization/schema'; import { f } from '../../utils/functools'; import { filterArray } from '../../utils/types'; +import { replaceItem } from '../../utils/utils'; import { Container, H2 } from '../Atoms'; import { className } from '../Atoms/className'; import { Input, Label, Select } from '../Atoms/Form'; @@ -26,7 +27,6 @@ import { ProtectedTable } from '../Permissions/PermissionDenied'; import { OrderPicker } from '../Preferences/Renderers'; import { attachmentSettingsPromise } from './attachments'; import { AttachmentGallery } from './Gallery'; -import { replaceItem } from '../../utils/utils'; export const attachmentRelatedTables = f.store(() => Object.keys(schema.models).filter((tableName) => diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx index fe582f35a8e..b6f2e3278b0 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx @@ -9,6 +9,8 @@ import type { RA } from '../../utils/types'; import { removeItem } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; +import { tablesWithAttachments } from '../Attachments'; +import { RecordSetAttachments } from '../Attachments/RecordSetAttachment'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { schema } from '../DataModel/schema'; @@ -20,8 +22,6 @@ import { hasTablePermission } from '../Permissions/helpers'; import { SetUnloadProtectsContext } from '../Router/Router'; import type { RecordSelectorProps } from './RecordSelector'; import { useRecordSelector } from './RecordSelector'; -import { RecordSetAttachments } from '../Attachments/RecordSetAttachment'; -import { tablesWithAttachments } from '../Attachments'; /** * A Wrapper for RecordSelector that allows to specify list of records by their @@ -74,7 +74,7 @@ export function RecordSelectorFromIds({ | undefined; readonly onFetch?: ( index: number - ) => Promise>; + ) => Promise | undefined>; }): JSX.Element | null { const [records, setRecords] = React.useState< RA | undefined> From f79e691954c7723fcb5eac2f663687225f1722ac Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Wed, 26 Apr 2023 14:58:52 -0500 Subject: [PATCH 10/24] Implement attachment viewer gallery in record sets --- .../lib/components/Attachments/Gallery.tsx | 8 +- .../Attachments/RecordSetAttachment.tsx | 147 ++++++++++++++++++ .../lib/components/Attachments/index.tsx | 12 +- .../FormSliders/RecordSelectorFromIds.tsx | 17 +- .../lib/components/FormSliders/RecordSet.tsx | 47 ++++-- .../js_src/lib/localization/attachments.ts | 7 + 6 files changed, 211 insertions(+), 27 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx index a83d6b54fcc..664551d341a 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx @@ -28,7 +28,10 @@ export function AttachmentGallery({ readonly onFetchMore: (() => Promise) | undefined; readonly scale: number; readonly isComplete: boolean; - readonly onChange: (attachments: RA>) => void; + readonly onChange: ( + attachment: SerializedResource, + index: number + ) => void; }): JSX.Element { const containerRef = React.useRef(null); @@ -61,6 +64,7 @@ export function AttachmentGallery({ const [openIndex, setOpenIndex] = React.useState( undefined ); + const [related, setRelated] = React.useState< RA | undefined> >([]); @@ -121,7 +125,7 @@ export function AttachmentGallery({ (item): void => setRelated(replaceItem(related, openIndex, item)), ]} onChange={(newAttachment): void => - handleChange(replaceItem(attachments, openIndex, newAttachment)) + handleChange(newAttachment, openIndex) } onClose={(): void => setOpenIndex(undefined)} onNext={ diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx new file mode 100644 index 00000000000..edf59aaf1de --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -0,0 +1,147 @@ +import React from 'react'; + +import { useAsyncState } from '../../hooks/useAsyncState'; +import { useBooleanState } from '../../hooks/useBooleanState'; +import { useCachedState } from '../../hooks/useCachedState'; +import { attachmentsText } from '../../localization/attachments'; +import { commonText } from '../../localization/common'; +import { f } from '../../utils/functools'; +import type { RA } from '../../utils/types'; +import { filterArray } from '../../utils/types'; +import { Button } from '../Atoms/Button'; +import { serializeResource } from '../DataModel/helpers'; +import type { AnySchema } from '../DataModel/helperTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import type { CollectionObjectAttachment } from '../DataModel/types'; +import { Dialog } from '../Molecules/Dialog'; +import { defaultAttachmentScale } from '.'; +import { AttachmentGallery } from './Gallery'; + +const haltIncrementSize = 300; + +export function RecordSetAttachments({ + records, + onFetch: handleFetch, +}: { + readonly records: RA | undefined>; + readonly onFetch: + | ((index: number) => Promise | void>) + | undefined; +}): JSX.Element { + const fetchedCount = React.useRef(0); + + const [showAttachments, handleShowAttachments, handleHideAttachments] = + useBooleanState(); + + const [attachments] = useAsyncState( + React.useCallback(async () => { + const relatedAttachmentRecords = await Promise.all( + records.map((record) => + record + ?.rgetCollection(`${record.specifyModel.name}Attachments`) + .then( + ({ models }) => + models as RA> + ) + ) + ); + + const fetchCount = records.findIndex( + (record) => record?.populated !== true + ); + + fetchedCount.current = fetchCount === -1 ? records.length : fetchCount; + + const attachements = await Promise.all( + filterArray(relatedAttachmentRecords.flat()).map( + async (collectionObjectAttachment) => ({ + attachment: await collectionObjectAttachment + .rgetPromise('attachment') + .then((resource) => serializeResource(resource)), + related: collectionObjectAttachment, + }) + ) + ); + + return { + attachments: attachements.map(({ attachment }) => attachment), + related: attachements.map(({ related }) => related), + }; + }, [records]), + true + ); + + /* + * Stop fetching records if the first 300 don't have attachments + * to save computing resources. Ask the user to continue and fetch + * the next haltIncrementSize (300) if desired. + */ + const [haltValue, setHaltValue] = React.useState(300); + const halt = + attachments?.attachments.length === 0 && records.length >= haltValue; + + const [scale = defaultAttachmentScale] = useCachedState( + 'attachments', + 'scale' + ); + + return ( + <> + + {showAttachments && ( + {commonText.close()} + } + header={ + attachments?.attachments === undefined + ? attachmentsText.attachments() + : commonText.countLine({ + resource: attachmentsText.attachments(), + count: attachments.attachments.length, + }) + } + onClose={handleHideAttachments} + > + {halt ? ( + haltValue === records.length ? ( + <>{attachmentsText.noAttachments()} + ) : ( +
+ {attachmentsText.attachmentHaltLimit({ halt: haltValue })} + + setHaltValue( + Math.min(haltValue + haltIncrementSize, records.length) + ) + } + > + {attachmentsText.fetchNextAttachments()} + +
+ ) + ) : ( + + void attachments?.related[index].set(`attachment`, attachment) + } + onFetchMore={ + attachments === undefined || handleFetch === undefined || halt + ? undefined + : async (): Promise => + handleFetch?.(fetchedCount.current).then(f.void) + } + /> + )} +
+ )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx index 3e270462416..305069b6b98 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx @@ -13,6 +13,7 @@ import { commonText } from '../../localization/common'; import { schemaText } from '../../localization/schema'; import { f } from '../../utils/functools'; import { filterArray } from '../../utils/types'; +import { replaceItem } from '../../utils/utils'; import { Container, H2 } from '../Atoms'; import { className } from '../Atoms/className'; import { Input, Label, Select } from '../Atoms/Form'; @@ -47,7 +48,7 @@ export const tablesWithAttachments = f.store(() => ) ); -const defaultScale = 10; +export const defaultAttachmentScale = 10; const minScale = 4; const maxScale = 50; const defaultSortOrder = '-timestampCreated'; @@ -124,7 +125,7 @@ function Attachments(): JSX.Element { false ); - const [scale = defaultScale, setScale] = useCachedState( + const [scale = defaultAttachmentScale, setScale] = useCachedState( 'attachments', 'scale' ); @@ -242,10 +243,13 @@ function Attachments(): JSX.Element { } key={`${order}_${JSON.stringify(filter)}`} scale={scale} - onChange={(records): void => + onChange={(attachment, index): void => collection === undefined ? undefined - : setCollection({ records, totalCount: collection.totalCount }) + : setCollection({ + records: replaceItem(collection.records, index, attachment), + totalCount: collection.totalCount, + }) } onFetchMore={collection === undefined ? undefined : fetchMore} /> diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx index 6629f8a0173..b6f2e3278b0 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx @@ -9,6 +9,8 @@ import type { RA } from '../../utils/types'; import { removeItem } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; +import { tablesWithAttachments } from '../Attachments'; +import { RecordSetAttachments } from '../Attachments/RecordSetAttachment'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { schema } from '../DataModel/schema'; @@ -45,6 +47,7 @@ export function RecordSelectorFromIds({ onAdd: handleAdd, onClone: handleClone, onDelete: handleDelete, + onFetch: handleFetch, ...rest }: Omit, 'index' | 'records'> & { /* @@ -69,6 +72,9 @@ export function RecordSelectorFromIds({ readonly onClone: | ((newResource: SpecifyResource) => void) | undefined; + readonly onFetch?: ( + index: number + ) => Promise | undefined>; }): JSX.Element | null { const [records, setRecords] = React.useState< RA | undefined> @@ -183,6 +189,9 @@ export function RecordSelectorFromIds({ recordSetTable: schema.models.RecordSet.label, }) : commonText.delete(); + + const hasAttachments = tablesWithAttachments().includes(model); + return ( <> ({
{headerButtons} - - {hasTablePermission( model.name, isDependent ? 'create' : 'read' @@ -209,7 +216,6 @@ export function RecordSelectorFromIds({ onClick={handleAdding} /> ) : undefined} - {typeof handleRemove === 'function' && canRemove ? ( ({ onClick={(): void => handleRemove('minusButton')} /> ) : undefined} - {typeof newResource === 'object' ? (

{formsText.creatingNewRecord()}

) : ( @@ -226,7 +231,9 @@ export function RecordSelectorFromIds({ className={`flex-1 ${dialog === false ? '-ml-2' : '-ml-4'}`} /> )} - + {hasAttachments && ( + + )} {specifyNetworkBadge}
{slider}
diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx index e3942adb90d..49245a33e7f 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx @@ -198,7 +198,7 @@ function RecordSet({ replace: boolean = false ): void => recordId === undefined - ? handleFetch(index) + ? handleFetchMore(index) : navigate( getResourceViewUrl( currentRecord.specifyModel.name, @@ -220,11 +220,12 @@ function RecordSet({ const previousIndex = React.useRef(currentIndex); const [isLoading, handleLoading, handleLoaded] = useBooleanState(); + const handleFetch = React.useCallback( - (index: number): void => { - if (index >= totalCount) return; + async (index: number): Promise | undefined> => { + if (index >= totalCount) return undefined; handleLoading(); - fetchItems( + return fetchItems( recordSet.id, // If new index is smaller (i.e, going back), fetch previous 40 IDs clamp( @@ -232,28 +233,41 @@ function RecordSet({ previousIndex.current > index ? index - fetchSize + 1 : index, totalCount ) - ) - .then((updates) => - setIds((oldIds = []) => { - handleLoaded(); - const newIds = updateIds(oldIds, updates); - go(index, newIds[index]); - return newIds; - }) - ) - .catch(softFail); + ).then( + async (updates) => + new Promise((resolve) => + setIds((oldIds = []) => { + handleLoaded(); + const newIds = updateIds(oldIds, updates); + resolve(newIds); + return newIds; + }) + ) + ); }, [totalCount, recordSet.id, loading, handleLoading, handleLoaded] ); + const handleFetchMore = React.useCallback( + (index: number): void => { + handleFetch(index) + .then((newIds) => { + if (newIds === undefined) return; + go(index, newIds[index]); + }) + .catch(softFail); + }, + [handleFetch] + ); + // Fetch ID of record at current index const currentRecordId = ids[currentIndex]; React.useEffect(() => { - if (currentRecordId === undefined) handleFetch(currentIndex); + if (currentRecordId === undefined) handleFetchMore(currentIndex); return (): void => { previousIndex.current = currentIndex; }; - }, [totalCount, currentRecordId, handleFetch, currentIndex]); + }, [totalCount, currentRecordId, handleFetchMore, currentIndex]); const [hasDuplicate, handleHasDuplicate, handleDismissDuplicate] = useBooleanState(); @@ -373,6 +387,7 @@ function RecordSet({ } : undefined } + onFetch={handleFetch} onSaved={(resource): void => ids[currentIndex] === resource.id ? undefined diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index eed67c233c8..155198d30ef 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -84,4 +84,11 @@ export const attachmentsText = createDictionary({ 'ru-ru': 'Показать форму', 'uk-ua': 'Показати форму', }, + attachmentHaltLimit: { + 'en-us': + 'No attachments have been found in the first {halt:number} records.', + }, + fetchNextAttachments: { + 'en-us': 'Look for more attachments', + }, } as const); From 5921aa62814dbbc422912424e9ad51f1597aeadd Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Fri, 28 Apr 2023 14:15:10 +0000 Subject: [PATCH 11/24] Lint code with ESLint and Prettier Triggered by 0edc0501a1a6b4ee5854c597784c8c0353d9833f on branch refs/heads/issue-2132 --- .../Attachments/RecordSetAttachment.tsx | 27 ++++++++++--------- .../lib/components/DataModel/resourceApi.js | 6 +++-- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index f3fdbd06000..edf59aaf1de 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -1,20 +1,21 @@ import React from 'react'; -import { RA, filterArray } from '../../utils/types'; -import { SpecifyResource } from '../DataModel/legacyTypes'; -import type { AnySchema } from '../DataModel/helperTypes'; -import { Dialog } from '../Molecules/Dialog'; -import { attachmentsText } from '../../localization/attachments'; import { useAsyncState } from '../../hooks/useAsyncState'; -import { CollectionObjectAttachment } from '../DataModel/types'; -import { serializeResource } from '../DataModel/helpers'; -import { AttachmentGallery } from './Gallery'; +import { useBooleanState } from '../../hooks/useBooleanState'; import { useCachedState } from '../../hooks/useCachedState'; -import { defaultAttachmentScale } from '.'; -import { Button } from '../Atoms/Button'; +import { attachmentsText } from '../../localization/attachments'; import { commonText } from '../../localization/common'; import { f } from '../../utils/functools'; -import { useBooleanState } from '../../hooks/useBooleanState'; +import type { RA } from '../../utils/types'; +import { filterArray } from '../../utils/types'; +import { Button } from '../Atoms/Button'; +import { serializeResource } from '../DataModel/helpers'; +import type { AnySchema } from '../DataModel/helperTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import type { CollectionObjectAttachment } from '../DataModel/types'; +import { Dialog } from '../Molecules/Dialog'; +import { defaultAttachmentScale } from '.'; +import { AttachmentGallery } from './Gallery'; const haltIncrementSize = 300; @@ -88,9 +89,9 @@ export function RecordSetAttachments({ <> + onClick={handleShowAttachments} + /> {showAttachments && ( Date: Fri, 28 Apr 2023 15:36:28 -0500 Subject: [PATCH 12/24] Allow to make changes to atatchment --- .../frontend/js_src/lib/components/Attachments/Dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Dialog.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Dialog.tsx index 0194b17cf1f..a1c754b959a 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Dialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Dialog.tsx @@ -59,7 +59,7 @@ export function AttachmentDialog({ {form !== null && ( { handleChange(serializeResource(resource)); From a3fb90aa5526e17a9799c723e0b459251ab8dfea Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Fri, 28 Apr 2023 15:38:11 -0500 Subject: [PATCH 13/24] Remove obstrusive loading dialog --- .../js_src/lib/components/Attachments/RecordSetAttachment.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index edf59aaf1de..7bdf59cf933 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -68,7 +68,7 @@ export function RecordSetAttachments({ related: attachements.map(({ related }) => related), }; }, [records]), - true + false ); /* From 8c43fb0fddbb57f892c34ae4995f4bb82f5f3ede Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Mon, 1 May 2023 08:53:13 -0700 Subject: [PATCH 14/24] Display previous fetched attachments while new ones are being fetched --- .../Attachments/RecordSetAttachment.tsx | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index 7bdf59cf933..53efb6d1d23 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -10,9 +10,12 @@ import type { RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { serializeResource } from '../DataModel/helpers'; -import type { AnySchema } from '../DataModel/helperTypes'; +import type { AnySchema, SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; -import type { CollectionObjectAttachment } from '../DataModel/types'; +import type { + Attachment, + CollectionObjectAttachment, +} from '../DataModel/types'; import { Dialog } from '../Molecules/Dialog'; import { defaultAttachmentScale } from '.'; import { AttachmentGallery } from './Gallery'; @@ -33,6 +36,11 @@ export function RecordSetAttachments({ const [showAttachments, handleShowAttachments, handleHideAttachments] = useBooleanState(); + const attachmentsRef = React.useRef<{ + attachments: RA>; + related: RA>; + }>(); + const [attachments] = useAsyncState( React.useCallback(async () => { const relatedAttachmentRecords = await Promise.all( @@ -63,10 +71,18 @@ export function RecordSetAttachments({ ) ); - return { - attachments: attachements.map(({ attachment }) => attachment), - related: attachements.map(({ related }) => related), + const newAttachments = { + attachments: attachements.map(({ attachment }) => attachment) as RA< + SerializedResource + >, + related: attachements.map(({ related }) => related) as RA< + SpecifyResource + >, }; + + attachmentsRef.current = newAttachments; + + return newAttachments; }, [records]), false ); @@ -85,6 +101,8 @@ export function RecordSetAttachments({ 'scale' ); + const currentAttachments = attachments ?? attachmentsRef.current; + return ( <> ({ {commonText.close()} } header={ - attachments?.attachments === undefined + currentAttachments?.attachments === undefined ? attachmentsText.attachments() : commonText.countLine({ resource: attachmentsText.attachments(), - count: attachments.attachments.length, + count: currentAttachments.attachments.length, }) } onClose={handleHideAttachments} @@ -126,7 +144,7 @@ export function RecordSetAttachments({ ) ) : ( From 0b58ab2d422a485fe5c15da2d6cf037cc5c71f85 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Mon, 1 May 2023 15:57:54 +0000 Subject: [PATCH 15/24] Lint code with ESLint and Prettier Triggered by 8c43fb0fddbb57f892c34ae4995f4bb82f5f3ede on branch refs/heads/issue-2132 --- .../js_src/lib/components/Attachments/RecordSetAttachment.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index 53efb6d1d23..ea27930792a 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -37,8 +37,8 @@ export function RecordSetAttachments({ useBooleanState(); const attachmentsRef = React.useRef<{ - attachments: RA>; - related: RA>; + readonly attachments: RA>; + readonly related: RA>; }>(); const [attachments] = useAsyncState( From bdf456bba79a911019c561b2f74b7a677211c663 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Mon, 1 May 2023 12:36:40 -0700 Subject: [PATCH 16/24] Simplify code --- .../Attachments/RecordSetAttachment.tsx | 33 ++++++------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index ea27930792a..0f568b8b47c 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -10,12 +10,9 @@ import type { RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { serializeResource } from '../DataModel/helpers'; -import type { AnySchema, SerializedResource } from '../DataModel/helperTypes'; +import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; -import type { - Attachment, - CollectionObjectAttachment, -} from '../DataModel/types'; +import type { CollectionObjectAttachment } from '../DataModel/types'; import { Dialog } from '../Molecules/Dialog'; import { defaultAttachmentScale } from '.'; import { AttachmentGallery } from './Gallery'; @@ -36,11 +33,6 @@ export function RecordSetAttachments({ const [showAttachments, handleShowAttachments, handleHideAttachments] = useBooleanState(); - const attachmentsRef = React.useRef<{ - readonly attachments: RA>; - readonly related: RA>; - }>(); - const [attachments] = useAsyncState( React.useCallback(async () => { const relatedAttachmentRecords = await Promise.all( @@ -72,20 +64,17 @@ export function RecordSetAttachments({ ); const newAttachments = { - attachments: attachements.map(({ attachment }) => attachment) as RA< - SerializedResource - >, - related: attachements.map(({ related }) => related) as RA< - SpecifyResource - >, + attachments: attachements.map(({ attachment }) => attachment), + related: attachements.map(({ related }) => related), }; - attachmentsRef.current = newAttachments; - return newAttachments; }, [records]), false ); + const attachmentsRef = React.useRef(attachments); + + if (typeof attachments === 'object') attachmentsRef.current = attachments; /* * Stop fetching records if the first 300 don't have attachments @@ -101,8 +90,6 @@ export function RecordSetAttachments({ 'scale' ); - const currentAttachments = attachments ?? attachmentsRef.current; - return ( <> ({ {commonText.close()} } header={ - currentAttachments?.attachments === undefined + attachmentsRef.current?.attachments === undefined ? attachmentsText.attachments() : commonText.countLine({ resource: attachmentsText.attachments(), - count: currentAttachments.attachments.length, + count: attachmentsRef.current.attachments.length, }) } onClose={handleHideAttachments} @@ -144,7 +131,7 @@ export function RecordSetAttachments({ ) ) : ( From a277044c3a6a74bda4e9a0250f887888e670ec5e Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Mon, 1 May 2023 19:42:24 +0000 Subject: [PATCH 17/24] Lint code with ESLint and Prettier Triggered by bdf456bba79a911019c561b2f74b7a677211c663 on branch refs/heads/issue-2132 --- .../js_src/lib/components/Attachments/RecordSetAttachment.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index 0f568b8b47c..ec5fc5f5560 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -63,12 +63,10 @@ export function RecordSetAttachments({ ) ); - const newAttachments = { + return { attachments: attachements.map(({ attachment }) => attachment), related: attachements.map(({ related }) => related), }; - - return newAttachments; }, [records]), false ); From d3f995f5515c5b307c6eacaf4c63d94abbbb3dd2 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 4 May 2023 10:01:06 -0700 Subject: [PATCH 18/24] Change shadows in attachment gallery --- .../frontend/js_src/lib/components/Attachments/Gallery.tsx | 2 +- .../frontend/js_src/lib/components/Attachments/Preview.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx index 664551d341a..dd869028404 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx @@ -74,7 +74,7 @@ export function AttachmentGallery({ <> Date: Thu, 4 May 2023 10:39:53 -0700 Subject: [PATCH 19/24] Change the minimum width value for attachment dialog --- .../lib/components/Attachments/RecordSetAttachment.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index ec5fc5f5560..78c3292a6fa 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -13,7 +13,7 @@ import { serializeResource } from '../DataModel/helpers'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import type { CollectionObjectAttachment } from '../DataModel/types'; -import { Dialog } from '../Molecules/Dialog'; +import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import { defaultAttachmentScale } from '.'; import { AttachmentGallery } from './Gallery'; @@ -109,6 +109,9 @@ export function RecordSetAttachments({ }) } onClose={handleHideAttachments} + className={{ + container: dialogClassNames.wideContainer, + }} > {halt ? ( haltValue === records.length ? ( From 772178abdd5d4b7fd4fb3617a2a970bbdeda2a4e Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 4 May 2023 17:45:30 +0000 Subject: [PATCH 20/24] Lint code with ESLint and Prettier Triggered by 7053ebdb8506c78e001dc37df2c38bfcec967ebc on branch refs/heads/issue-2132 --- .../lib/components/Attachments/RecordSetAttachment.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index 78c3292a6fa..759bf07c2bb 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -100,6 +100,9 @@ export function RecordSetAttachments({ buttons={ {commonText.close()} } + className={{ + container: dialogClassNames.wideContainer, + }} header={ attachmentsRef.current?.attachments === undefined ? attachmentsText.attachments() @@ -109,9 +112,6 @@ export function RecordSetAttachments({ }) } onClose={handleHideAttachments} - className={{ - container: dialogClassNames.wideContainer, - }} > {halt ? ( haltValue === records.length ? ( From 86203e13b45b2a859b54d6c127abc1d05d8b2869 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Fri, 19 May 2023 20:44:54 +0000 Subject: [PATCH 21/24] Lint code with ESLint and Prettier Triggered by dab39e45ba39f54f636b4719ca1703d3caa6b3bc on branch refs/heads/issue-2132 --- .../lib/components/Attachments/RecordSetAttachment.tsx | 2 +- .../js_src/lib/components/FormFields/QueryComboBox.tsx | 2 +- specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx | 2 +- .../frontend/js_src/lib/components/FormSliders/RecordSet.tsx | 2 +- .../js_src/lib/components/Forms/BaseResourceView.tsx | 2 +- .../js_src/lib/components/InitialContext/treeRanks.ts | 2 +- specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts | 2 +- .../lib/components/Leaflet/localityRecordDataExtractor.ts | 2 +- .../lib/components/Preferences/CollectionDefinitions.tsx | 3 ++- .../frontend/js_src/lib/components/Preferences/Editor.tsx | 4 ++-- .../frontend/js_src/lib/components/Preferences/Renderers.tsx | 4 ++-- .../js_src/lib/components/QueryBuilder/AuditLogFormatter.tsx | 2 +- .../frontend/js_src/lib/components/QueryBuilder/Results.tsx | 4 ++-- .../js_src/lib/components/QueryBuilder/ResultsTable.tsx | 2 +- .../frontend/js_src/lib/components/Router/OverlayRoutes.tsx | 5 +++-- specifyweb/frontend/js_src/lib/components/Security/User.tsx | 4 ++-- .../js_src/lib/components/SpecifyNetwork/Overlay.tsx | 2 +- .../frontend/js_src/lib/components/SpecifyNetwork/hooks.tsx | 2 +- .../frontend/js_src/lib/components/SpecifyNetwork/index.tsx | 2 +- .../js_src/lib/components/SpecifyNetwork/overlays.ts | 2 +- .../js_src/lib/components/SpecifyNetworkCollection/Map.tsx | 2 +- .../frontend/js_src/lib/components/Statistics/Pages.tsx | 2 +- .../lib/components/WbPlanView/__tests__/automapper.test.ts | 2 +- .../frontend/js_src/lib/components/WorkBench/Template.tsx | 2 +- 24 files changed, 31 insertions(+), 29 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index b83f1f56066..e78c372cee4 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -36,7 +36,7 @@ export function RecordSetAttachments({ const [attachments] = useAsyncState( React.useCallback(async () => { const relatedAttachmentRecords = await Promise.all( - records.map((record) => + records.map(async (record) => record ?.rgetCollection(`${record.specifyModel.name}Attachments`) .then( diff --git a/specifyweb/frontend/js_src/lib/components/FormFields/QueryComboBox.tsx b/specifyweb/frontend/js_src/lib/components/FormFields/QueryComboBox.tsx index 900e74be09c..9f1f82b83d2 100644 --- a/specifyweb/frontend/js_src/lib/components/FormFields/QueryComboBox.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormFields/QueryComboBox.tsx @@ -166,7 +166,7 @@ export function QueryComboBox({ typeof resource.getDependentResource(field.name) === 'object') ? resource .rgetPromise(field.name) - .then((resource) => + .then(async (resource) => resource === undefined || resource === null ? { label: '' as LocalizedString, diff --git a/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx b/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx index c948e0ff0ed..48d82b3568a 100644 --- a/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx @@ -18,6 +18,7 @@ import { PrintOnSave } from '../FormFields/Checkbox'; import type { ViewDescription } from '../FormParse'; import { SubViewContext } from '../Forms/SubView'; import { isTreeResource } from '../InitialContext/treeRanks'; +import { interactionTables } from '../Interactions/config'; import { Dialog } from '../Molecules/Dialog'; import { ProtectedAction, @@ -33,7 +34,6 @@ import { QueryTreeUsages } from './QueryTreeUsages'; import { ReadOnlyMode } from './ReadOnlyMode'; import { ShareRecord } from './ShareRecord'; import { SubViewMeta } from './SubViewMeta'; -import { interactionTables } from '../Interactions/config'; /** * Form preferences host context aware user preferences and other meta-actions. diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx index 34224309864..3cbdc5ce892 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx @@ -372,7 +372,7 @@ function RecordSet({ }).then(({ totalCount }) => totalCount !== 0), }) ) - ).then((results) => { + ).then(async (results) => { const [nonDuplicates, duplicates] = split( results, ({ isDuplicate }) => isDuplicate diff --git a/specifyweb/frontend/js_src/lib/components/Forms/BaseResourceView.tsx b/specifyweb/frontend/js_src/lib/components/Forms/BaseResourceView.tsx index 019cebbdeef..73a2e382475 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/BaseResourceView.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/BaseResourceView.tsx @@ -10,6 +10,7 @@ import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { resourceOn } from '../DataModel/resource'; import { softFail } from '../Errors/Crash'; +import { ErrorBoundary } from '../Errors/ErrorBoundary'; import { FormMeta } from '../FormMeta'; import type { FormMode } from '../FormParse'; import { LoadingScreen } from '../Molecules/Dialog'; @@ -19,7 +20,6 @@ import { displaySpecifyNetwork, SpecifyNetworkBadge } from '../SpecifyNetwork'; import { format } from './dataObjFormatters'; import { SpecifyForm } from './SpecifyForm'; import { useViewDefinition } from './useViewDefinition'; -import { ErrorBoundary } from '../Errors/ErrorBoundary'; export type ResourceViewProps = { readonly isLoading?: boolean; diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/treeRanks.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/treeRanks.ts index db43a60d0fe..ffbf628c434 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/treeRanks.ts +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/treeRanks.ts @@ -62,7 +62,7 @@ export const treeRanksPromise = Promise.all([ import('../DataModel/schema').then(async ({ fetchContext }) => fetchContext), fetchDomain, ]) - .then(([{ hasTreeAccess, hasTablePermission }]) => + .then(async ([{ hasTreeAccess, hasTablePermission }]) => hasTablePermission('Discipline', 'read') ? getDomainResource('discipline') ?.fetch() diff --git a/specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts b/specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts index fef07d0daa1..64d6ec5c840 100644 --- a/specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts +++ b/specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts @@ -199,7 +199,7 @@ const layersPromise: Promise> = { headers: { Accept: 'text/plain' } }, { strict: false, expectedResponseCodes: [Http.OK, Http.NO_CONTENT] } ) - .then(({ data, status }) => + .then(async ({ data, status }) => status === Http.NO_CONTENT ? ajax>( cachableUrl(leafletLayersEndpoint), diff --git a/specifyweb/frontend/js_src/lib/components/Leaflet/localityRecordDataExtractor.ts b/specifyweb/frontend/js_src/lib/components/Leaflet/localityRecordDataExtractor.ts index c797488a9d9..9245f74cc93 100644 --- a/specifyweb/frontend/js_src/lib/components/Leaflet/localityRecordDataExtractor.ts +++ b/specifyweb/frontend/js_src/lib/components/Leaflet/localityRecordDataExtractor.ts @@ -17,6 +17,7 @@ import { treeRanksPromise, } from '../InitialContext/treeRanks'; import { hasTablePermission, hasTreeAccess } from '../Permissions/helpers'; +import { deflateLocalityData } from '../SpecifyNetwork/utils'; import { pathStartsWith } from '../WbPlanView/helpers'; import type { MappingPath } from '../WbPlanView/Mapper'; import { @@ -36,7 +37,6 @@ import { formatCoordinate, getLocalityData, } from './helpers'; -import { deflateLocalityData } from '../SpecifyNetwork/utils'; const splitMappingPath = ( mappingPath: MappingPath, diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx index e84cca4429f..f2a12e78e3e 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx @@ -5,7 +5,8 @@ import { f } from '../../utils/functools'; import type { RA } from '../../utils/types'; import { ensure } from '../../utils/types'; import type { StatLayout } from '../Statistics/types'; -import { GenericPreferences, defineItem } from './types'; +import type { GenericPreferences } from './types'; +import { defineItem } from './types'; export const collectionPreferenceDefinitions = { statistics: { diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx index b530c261058..ef95e189ebc 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx @@ -1,11 +1,11 @@ import React from 'react'; +import { useLiveState } from '../../hooks/useLiveState'; +import type { AppResourceTab } from '../AppResources/TabDefinitions'; import { PreferencesContent } from '../Preferences'; import { BasePreferences } from '../Preferences/BasePreferences'; import { userPreferenceDefinitions } from '../Preferences/UserDefinitions'; import { userPreferences } from '../Preferences/userPreferences'; -import { AppResourceTab } from '../AppResources/TabDefinitions'; -import { useLiveState } from '../../hooks/useLiveState'; export const UserPreferencesEditor: AppResourceTab = function ({ isReadOnly, diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx index ef782a81ecc..c20f4f0a773 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx @@ -31,9 +31,9 @@ import { rawMenuItemsPromise } from '../Header/menuItemDefinitions'; import { useMenuItems, useUserTools } from '../Header/menuItemProcessing'; import { AttachmentPicker } from '../Molecules/AttachmentPicker'; import { AutoComplete } from '../Molecules/AutoComplete'; -import { userPreferences } from './userPreferences'; import { ListEdit } from '../Toolbar/QueryTablesEdit'; -import { PreferenceItem, PreferenceItemComponent } from './types'; +import type { PreferenceItem, PreferenceItemComponent } from './types'; +import { userPreferences } from './userPreferences'; export const ColorPickerPreferenceItem: PreferenceItemComponent = function ColorPickerPreferenceItem({ diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/AuditLogFormatter.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/AuditLogFormatter.tsx index 6b83c151873..63863bce893 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/AuditLogFormatter.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/AuditLogFormatter.tsx @@ -75,7 +75,7 @@ export function getAuditRecordFormatter( Promise.all( resultRow .filter((_, index) => index !== queryIdField) - .map((value, index, row) => { + .map(async (value, index, row) => { if (value === null || value === '') return ''; const stringValue = value.toString(); if (fields[index]?.name === 'fieldName') { diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx index 4fa61b5e4d0..153ed36cee9 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx @@ -102,7 +102,7 @@ export function QueryResults(props: Props): JSX.Element { async () => // Fetch all pick lists so that they are accessible synchronously later Promise.all( - fieldSpecs.map((fieldSpec) => + fieldSpecs.map(async (fieldSpec) => typeof fieldSpec.parser.pickListName === 'string' ? fetchPickList(fieldSpec.parser.pickListName) : undefined @@ -404,7 +404,7 @@ export function useFetchQueryResults({ // Prevent concurrent fetching in different places fetchersRef.current[fetchIndex] ??= fetchResults(fetchIndex) - .then((newResults) => { + .then(async (newResults) => { if ( process.env.NODE_ENV === 'development' && newResults.length > fetchSize diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsTable.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsTable.tsx index b4b8209957f..567eecd969e 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsTable.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsTable.tsx @@ -100,7 +100,7 @@ function Row({ ); const [formattedValues] = useAsyncState( React.useCallback( - () => recordFormatter?.(result), + async () => recordFormatter?.(result), [result, recordFormatter] ), false diff --git a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx index d742743aced..27f9f09001c 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx @@ -1,3 +1,5 @@ +import React from 'react'; + import { commonText } from '../../localization/common'; import { headerText } from '../../localization/header'; import { interactionsText } from '../../localization/interactions'; @@ -8,9 +10,8 @@ import { userText } from '../../localization/user'; import { welcomeText } from '../../localization/welcome'; import { wbText } from '../../localization/workbench'; import type { RA } from '../../utils/types'; -import type { EnhancedRoute } from './RouterUtils'; import { Redirect } from './Redirect'; -import React from 'react'; +import type { EnhancedRoute } from './RouterUtils'; /* eslint-disable @typescript-eslint/promise-function-async */ /** diff --git a/specifyweb/frontend/js_src/lib/components/Security/User.tsx b/specifyweb/frontend/js_src/lib/components/Security/User.tsx index e343515cfe6..398ffc343f9 100644 --- a/specifyweb/frontend/js_src/lib/components/Security/User.tsx +++ b/specifyweb/frontend/js_src/lib/components/Security/User.tsx @@ -468,7 +468,7 @@ function UserView({ status: Http.NO_CONTENT, }) ) - .then(({ data, status }) => + .then(async ({ data, status }) => status === Http.BAD_REQUEST ? setState({ type: 'SettingAgents', @@ -515,7 +515,7 @@ function UserView({ }) : true ) - .then((canContinue) => + .then(async (canContinue) => canContinue === true ? Promise.all([ typeof password === 'string' && password !== '' diff --git a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Overlay.tsx b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Overlay.tsx index 4ffe4cb8c31..b1642a17b4b 100644 --- a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Overlay.tsx +++ b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Overlay.tsx @@ -90,7 +90,7 @@ export function useMapData( ): BrokerData { const [speciesName] = useAsyncState( React.useCallback( - () => + async () => brokerData?.speciesName ?? (typeof taxonId === 'number' ? fetchResource('Taxon', taxonId).then( diff --git a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/hooks.tsx b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/hooks.tsx index 568d85e7842..76e7f880d5d 100644 --- a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/hooks.tsx +++ b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/hooks.tsx @@ -22,7 +22,7 @@ export function useSpecies( ): RA | undefined { return useAsyncState( React.useCallback( - () => (speciesName === undefined ? [] : fetchName(speciesName)), + async () => (speciesName === undefined ? [] : fetchName(speciesName)), [speciesName] ), false diff --git a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/index.tsx b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/index.tsx index 86c64c9308c..f9f1c0888f1 100644 --- a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/index.tsx @@ -20,8 +20,8 @@ import type { CollectionObject, Taxon } from '../DataModel/types'; import { Dialog } from '../Molecules/Dialog'; import { TableIcon } from '../Molecules/TableIcon'; import { hasTablePermission } from '../Permissions/helpers'; -import { SpecifyNetworkOverlays } from './Overlay'; import { userPreferences } from '../Preferences/userPreferences'; +import { SpecifyNetworkOverlays } from './Overlay'; export const displaySpecifyNetwork = ( resource: SpecifyResource | undefined diff --git a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/overlays.ts b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/overlays.ts index 20224551a70..aa15e30d0c4 100644 --- a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/overlays.ts +++ b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/overlays.ts @@ -108,7 +108,7 @@ export function useIdbLayers( scientificName: string | undefined ): BrokerOverlay | undefined { const [layers] = useAsyncState( - React.useCallback(() => { + React.useCallback(async () => { const idbScientificName = extractBrokerField(occurrence ?? [], 'idb', 'dwc:scientificName') ?? scientificName; diff --git a/specifyweb/frontend/js_src/lib/components/SpecifyNetworkCollection/Map.tsx b/specifyweb/frontend/js_src/lib/components/SpecifyNetworkCollection/Map.tsx index 0320160d7f6..44ba7de38fd 100644 --- a/specifyweb/frontend/js_src/lib/components/SpecifyNetworkCollection/Map.tsx +++ b/specifyweb/frontend/js_src/lib/components/SpecifyNetworkCollection/Map.tsx @@ -6,13 +6,13 @@ import { useAsyncState } from '../../hooks/useAsyncState'; import { useTriggerState } from '../../hooks/useTriggerState'; import { ajax } from '../../utils/ajax'; import type { IR } from '../../utils/types'; +import { getLayerPaneZindex } from '../Leaflet'; import type { LeafletInstance } from '../Leaflet/addOns'; import { LeafletMap } from '../Leaflet/Map'; import { loadingGif } from '../Molecules'; import { Range } from '../Molecules/Range'; import { formatUrl } from '../Router/queryString'; import { getGbifLayer } from '../SpecifyNetwork/overlays'; -import { getLayerPaneZindex } from '../Leaflet'; const rangeDefaults = [0, new Date().getFullYear()]; const debounceRate = 500; diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/Pages.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/Pages.tsx index 11a72c4c737..f4f53d3ceae 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/Pages.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/Pages.tsx @@ -1,6 +1,6 @@ import { specifyNetworkText } from '../../localization/specifyNetwork'; +import type { IR } from '../../utils/types'; import { SpecifyNetworkCollection } from '../SpecifyNetworkCollection'; -import { IR } from '../../utils/types'; // FIXME: add these pages to the stats page side bar export const extraStatsPages: IR<() => JSX.Element | null> = { diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/automapper.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/automapper.test.ts index 0e7101bd1b7..7aa22c6671a 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/automapper.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/automapper.test.ts @@ -3,8 +3,8 @@ import { theories } from '../../../tests/utils'; import type { RA } from '../../../utils/types'; import type { AutoMapperResults } from '../autoMapper'; import { - AutoMapper as AutoMapperConstructor, type AutoMapperConstructorParameters, + AutoMapper as AutoMapperConstructor, circularTables, } from '../autoMapper'; diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/Template.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/Template.tsx index 74a1035d50e..b3a99c10351 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/Template.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/Template.tsx @@ -171,8 +171,8 @@ function WbView({ {commonText.save()} From 86b120ac8dbefe801dfd577fc561d18d6d785e5e Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Mon, 22 May 2023 09:01:48 -0700 Subject: [PATCH 22/24] Delete loading gif for attachment thumbnail --- .../frontend/js_src/lib/components/Attachments/Preview.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx index e3bca720843..a311c2951d8 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { useAsyncState } from '../../hooks/useAsyncState'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { Attachment } from '../DataModel/types'; -import { loadingGif } from '../Molecules'; import type { AttachmentThumbnail } from './attachments'; import { fetchThumbnail } from './attachments'; @@ -40,10 +39,8 @@ function Thumbnail({ }: { readonly attachment: SerializedResource; readonly thumbnail: AttachmentThumbnail | undefined; -}): JSX.Element { - return thumbnail === undefined ? ( - loadingGif - ) : ( +}): JSX.Element | null { + return thumbnail === undefined ? null : ( { 0 From d347557d09f45530cd5fbd8cd5725c3c3f3aaabe Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Mon, 25 Sep 2023 11:42:52 -0700 Subject: [PATCH 23/24] Disable attachment set until attachments are defined --- .../js_src/lib/components/Attachments/Preview.tsx | 3 +++ .../lib/components/Attachments/RecordSetAttachment.tsx | 9 +++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx index 3a144df934a..074980288b6 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx @@ -36,9 +36,11 @@ export function AttachmentPreview({ export function Thumbnail({ attachment, thumbnail, + className, }: { readonly attachment: SerializedResource; readonly thumbnail: AttachmentThumbnail | undefined; + readonly className?: string; }): JSX.Element | null { return thumbnail === undefined ? null : ( ({ fetchedCount.current = fetchCount === -1 ? records.length : fetchCount; - const attachements = await Promise.all( + const attachments = await Promise.all( filterArray(relatedAttachmentRecords.flat()).map( async (collectionObjectAttachment) => ({ attachment: await collectionObjectAttachment @@ -64,8 +64,8 @@ export function RecordSetAttachments({ ); return { - attachments: attachements.map(({ attachment }) => attachment), - related: attachements.map(({ related }) => related), + attachments: attachments.map(({ attachment }) => attachment), + related: attachments.map(({ related }) => related), }; }, [records]), false @@ -79,7 +79,7 @@ export function RecordSetAttachments({ * to save computing resources. Ask the user to continue and fetch * the next haltIncrementSize (300) if desired. */ - const [haltValue, setHaltValue] = React.useState(300); + const [haltValue, setHaltValue] = React.useState(39); const halt = attachments?.attachments.length === 0 && records.length >= haltValue; @@ -94,6 +94,7 @@ export function RecordSetAttachments({ icon="photos" title="attachments" onClick={handleShowAttachments} + disabled={attachments === undefined} /> {showAttachments && ( Date: Mon, 25 Sep 2023 18:48:09 +0000 Subject: [PATCH 24/24] Lint code with ESLint and Prettier Triggered by d347557d09f45530cd5fbd8cd5725c3c3f3aaabe on branch refs/heads/issue-2132 --- .../js_src/lib/components/Attachments/RecordSetAttachment.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index 3f5e9c7c983..15c45c3427f 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -91,10 +91,10 @@ export function RecordSetAttachments({ return ( <> {showAttachments && (