diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/EditorWrapper.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/EditorWrapper.tsx index 46074422104..b06c5b5de14 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/EditorWrapper.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/EditorWrapper.tsx @@ -8,6 +8,7 @@ import { fetchResource } from '../DataModel/resource'; import type { SpAppResource, SpViewSetObj } from '../DataModel/types'; import { NotFoundView } from '../Router/NotFoundView'; import { locationToState, useStableLocation } from '../Router/RouterState'; +import { AppResourceSkeleton } from '../SkeletonLoaders/AppResource'; import { findAppResourceDirectory } from './Create'; import { AppResourceEditor } from './Editor'; import type { AppResourceMode } from './helpers'; @@ -15,7 +16,7 @@ import { getAppResourceMode } from './helpers'; import type { AppResources } from './hooks'; import { useResourcesTree } from './hooks'; import type { AppResourcesOutlet } from './index'; -import { ScopedAppResourceDir } from './types'; +import type { ScopedAppResourceDir } from './types'; export function AppResourceView(): JSX.Element { return ; @@ -43,8 +44,9 @@ export function Wrapper({ const baseHref = `/specify/resources/${ mode === 'appResources' ? 'app-resource' : 'view-set' }`; - return initialData === undefined ? null : resource === undefined || - directory === undefined ? ( + return initialData === undefined ? ( + + ) : resource === undefined || directory === undefined ? ( ) : ( ))} - {isComplete - ? attachments.length === 0 &&

{attachmentsText.noAttachments()}

- : loadingGif} + {isComplete ? ( + attachments.length === 0 &&

{attachmentsText.noAttachments()}

+ ) : ( + + )} {typeof viewRecord === 'object' && ( diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Plugin.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Plugin.tsx index cbd6b70847e..81ae091c9af 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Plugin.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Plugin.tsx @@ -24,6 +24,7 @@ import { loadingBar } from '../Molecules'; import { Dialog } from '../Molecules/Dialog'; import { FilePicker } from '../Molecules/FilePicker'; import { ProtectedTable } from '../Permissions/PermissionDenied'; +import { AttachmentPluginSkeleton } from '../SkeletonLoaders/AttachmentPlugin'; import { attachmentSettingsPromise, uploadFile } from './attachments'; import { AttachmentViewer } from './Viewer'; @@ -52,7 +53,7 @@ export function useAttachment( false, [resource] ), - true + false ); } @@ -64,13 +65,16 @@ function ProtectedAttachmentsPlugin({ readonly mode: FormMode; }): JSX.Element | null { const [attachment, setAttachment] = useAttachment(resource); + useErrorContext('attachment', attachment); const filePickerContainer = React.useRef(null); const related = useTriggerState( resource?.specifyModel.name === 'Attachment' ? undefined : resource ); - return attachment === undefined ? null : ( + return attachment === undefined ? ( + + ) : (
{formsText.noData()}

) : ( - loadingGif + ); } diff --git a/specifyweb/frontend/js_src/lib/components/Forms/SpecifyForm.tsx b/specifyweb/frontend/js_src/lib/components/Forms/SpecifyForm.tsx index e8b2d021360..c313c7dd136 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/SpecifyForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/SpecifyForm.tsx @@ -19,6 +19,7 @@ import { attachmentView } from '../FormParse/webOnlyViews'; import { loadingGif } from '../Molecules'; import { userPreferences } from '../Preferences/userPreferences'; import { unsafeTriggerNotFound } from '../Router/Router'; +import { FormSkeleton } from '../SkeletonLoaders/Form'; const FormLoadingContext = React.createContext(false); FormLoadingContext.displayName = 'FormLoadingContext'; @@ -74,8 +75,7 @@ export function SpecifyForm({ // If parent resource is loading, don't duplicate the loading bar in children const isAlreadyLoading = React.useContext(FormLoadingContext); - const showLoading = - !isAlreadyLoading && (!formIsLoaded || isLoading || isShowingOldResource); + const showLoading = !isAlreadyLoading && (isLoading || isShowingOldResource); const [flexibleColumnWidth] = userPreferences.use( 'form', 'definition', @@ -97,21 +97,21 @@ export function SpecifyForm({ {showLoading && (
{loadingGif}
)} - {formIsLoaded && ( + {formIsLoaded ? ( ({ ))} + ) : ( + )}
diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index d1a57ee2060..85aa323e4cb 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -25,6 +25,7 @@ import { isTreeModel, treeRanksPromise } from '../InitialContext/treeRanks'; import { useTitle } from '../Molecules/AppTitle'; import { hasPermission, hasToolPermission } from '../Permissions/helpers'; import { userPreferences } from '../Preferences/userPreferences'; +import { QueryBuilderSkeleton } from '../SkeletonLoaders/QueryBuilder'; import { getMappedFields, mappingPathIsComplete } from '../WbPlanView/helpers'; import { getMappingLineProps } from '../WbPlanView/LineComponents'; import { MappingView } from '../WbPlanView/MapperComponents'; @@ -92,7 +93,7 @@ export function QueryBuilder({ 'queryBuilder', queryResource.isNew() ? 'create' : 'update' ); - const [treeRanksLoaded = false] = useAsyncState(fetchTreeRanks, true); + const [treeRanksLoaded = false] = useAsyncState(fetchTreeRanks, false); const [query, setQuery] = useResource(queryResource); useErrorContext('query', query); @@ -557,5 +558,7 @@ export function QueryBuilder({ )} - ) : null; + ) : ( + + ); } diff --git a/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/AppResource.tsx b/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/AppResource.tsx new file mode 100644 index 00000000000..bcf2902b2c5 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/AppResource.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { Skeleton } from './Skeleton'; + +export function AppResourceSkeleton() { + return ( + +
+
+ + + +
+
+ + + +
+
+
+ {Array.from({ length: 15 }, (_, index) => ( + + ))} +
+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/AttachmentGallery.tsx b/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/AttachmentGallery.tsx new file mode 100644 index 00000000000..534f12b2bba --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/AttachmentGallery.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { DEFAULT_FETCH_LIMIT } from '../DataModel/collection'; +import { Skeleton } from './Skeleton'; + +export function AttachmentGallerySkeleton() { + return ( + + {Array.from({ length: DEFAULT_FETCH_LIMIT }, (_, index) => ( + + ))} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/AttachmentPlugin.tsx b/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/AttachmentPlugin.tsx new file mode 100644 index 00000000000..a68439bb6ac --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/AttachmentPlugin.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { Skeleton } from './Skeleton'; + +export function AttachmentPluginSkeleton() { + return ( + + +
+
+ + +
+ {Array.from({ length: 6 }, (_, index) => ( +
+ + +
+ ))} +
+ + +
+
+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/DialogList.tsx b/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/DialogList.tsx new file mode 100644 index 00000000000..42f292caf47 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/DialogList.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { Skeleton } from './Skeleton'; + +export function DialogListSkeleton() { + return ( + +
+ +
+ {Array.from({ length: 5 }, (_, index) => ( +
+
+ + +
+
+ + + +
+
+ ))} +
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/Form.tsx b/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/Form.tsx new file mode 100644 index 00000000000..b59feb082e1 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/Form.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import { Skeleton } from './Skeleton'; + +export function FormSkeleton() { + return ( + +
+
+
+ + +
+
+ + + + + +
+
+ + +
+
+
+ + + +
+
+ +
+
+ +
+
+
+ + + +
+
+ + +
+
+ + +
+
+
+
+ + + +
+
+ + + + + +
+
+
+ +
+
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + +
+
+
+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/QueryBuilder.tsx b/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/QueryBuilder.tsx new file mode 100644 index 00000000000..fe9cbf7902c --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/QueryBuilder.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { Skeleton } from './Skeleton'; + +export function QueryBuilderSkeleton() { + return ( + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+ +
+ +
+
+
+
+ + +
+
+ + + + +
+
+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/Skeleton.tsx b/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/Skeleton.tsx new file mode 100644 index 00000000000..8139f69a08d --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SkeletonLoaders/Skeleton.tsx @@ -0,0 +1,39 @@ +import { commonText } from '../../localization/common'; +import { wrap } from '../Atoms/wrapper'; + +const skeleton = `bg-gray-200 dark:bg-neutral-600 [body:not(.reduce-motion)_&]:animate-pulse`; + +export const Skeleton = { + Root: wrap('Skeleton.Root', 'div', 'flex gap-4', { + 'aria-label': commonText.loading(), + }), + Line: wrap('Skeletons.Line', 'div', `${skeleton} h-4 w-14 rounded`), + LongLine: wrap('Skeletons.LongLine', 'div', `${skeleton} h-4 rounded`), + Square: wrap('Skeletons.Square', 'div', `${skeleton} h-32 rounded`), + SmallSquare: wrap( + 'Skeletons.SmallSquare', + 'div', + `${skeleton} h-6 w-6 rounded` + ), + Rectangle: wrap('Skeletons.Rectangle', 'div', `${skeleton} h-6 w-64 rounded`), + SmallRectangle: wrap( + 'Skeletons.SmallRectangle', + 'div', + `${skeleton} h-6 w-16 rounded` + ), + TallRectangle: wrap( + 'Skeletons.TallRectangle', + 'div', + `${skeleton} w-32 rounded` + ), + ThinRectangle: wrap( + 'Skeletons.ThinRectangle', + 'div', + `${skeleton} w-32 w-6 rounded` + ), + SmallCircle: wrap( + 'Skeletons.SmallCircle', + 'div', + `${skeleton} w-6 rounded-full` + ), +}; diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx index b80380b35ac..e155298c604 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx @@ -28,6 +28,7 @@ import { hasPermission, hasToolPermission } from '../Permissions/helpers'; import { QueryEditButton } from '../QueryBuilder/Edit'; import { OverlayContext } from '../Router/Router'; import { SafeOutlet } from '../Router/RouterUtils'; +import { DialogListSkeleton } from '../SkeletonLoaders/DialogList'; import { QueryTablesWrapper } from './QueryTablesWrapper'; export function QueriesOverlay(): JSX.Element { @@ -66,7 +67,7 @@ export function useQueries( }).then(({ records }) => records), [spQueryFilter] ), - true + false ); React.useEffect( () => @@ -91,7 +92,16 @@ export function QueryListDialog({ getQuerySelectUrl, isReadOnly, }: QueryListContextType): JSX.Element | null { - return Array.isArray(queries) ? ( + return queries === undefined ? ( + {commonText.cancel()}} + header={queryText.queries()} + icon={{icons.documentSearch}} + onClose={handleClose} + > + + + ) : Array.isArray(queries) ? ( diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/RecordSets.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/RecordSets.tsx index ce7ce2a66e3..aa5e6fc556c 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/RecordSets.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/RecordSets.tsx @@ -23,6 +23,7 @@ import { SortIndicator, useSortConfig } from '../Molecules/Sorting'; import { TableIcon } from '../Molecules/TableIcon'; import { hasToolPermission } from '../Permissions/helpers'; import { OverlayContext } from '../Router/Router'; +import { DialogListSkeleton } from '../SkeletonLoaders/DialogList'; import { EditRecordSet } from './RecordSetEdit'; export function RecordSetsOverlay(): JSX.Element { @@ -85,7 +86,7 @@ export function RecordSetsDialog({ 'name' ); - const [unsortedData] = usePromise(recordSetsPromise, true); + const [unsortedData] = usePromise(recordSetsPromise, false); const data = React.useMemo( () => typeof unsortedData === 'object' @@ -208,7 +209,16 @@ export function RecordSetsDialog({ onClose={handleClose} /> ) : null - ) : null; + ) : ( + {commonText.cancel()}} + header={commonText.recordSets()} + icon={{icons.collection}} + onClose={handleClose} + > + + + ); } function Row({