diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 19e82a6fcaa..dc6ee8550c6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,10 +1,9 @@ --- name: Bug report about: Create a report to help us improve -title: '' -labels: 'type:bug, pri:unknown' -assignees: '' - +title: "" +labels: "type:bug, pri:unknown" +assignees: "" --- **Describe the bug** @@ -12,6 +11,7 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -23,12 +23,17 @@ A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. -**Desktop:** - - OS: [e.g. Windows 10] - - Browser: [e.g. Chrome, Safari] - - Specify 7 Version: [e.g. 7.6.1] +**Crash Report** + +If the bug resulted in an error message, please click on "Download Error +Message" and attach it here + +Alternatively, please fill out the following information manually: -**Database Name:** If applicable +- OS: [e.g. Windows 10] +- Browser: [e.g. Chrome, Safari] +- Specify 7 Version: [e.g. 7.6.1] +- Database Name: [e.g. kufish] (you can see this in the "About Specify 7" dialog) **Reported By** Name of your institution diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f1c8964bbce..8ce9d8462e1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -252,7 +252,7 @@ jobs: if: steps.changed.outputs.changed working-directory: specifyweb/frontend/js_src run: | - npx prettier --write --minify `echo "${{steps.changed.outputs.changed}}" | tr " " "\n"` + npx prettier --write --minify `echo "${{steps.changed.outputs.changed}}" | tr " " "\n"` || true - name: Commit linted files (if made any changes) working-directory: specifyweb/frontend/js_src diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b90a9e63ee..28b7babf51d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,34 @@ Coming in the next few months: ## [7.8.9](https://github.com/specify/specify7/compare/v7.8.7...HEAD) (Unreleased) +## [7.8.8](https://github.com/specify/specify7/compare/v7.8.7.1...v7.8.8) (March 20 2023) + +### Added + +- A new warning for attachments that are too large to upload has been + added ([#729](https://github.com/specify/specify7/issues/729)) +- A webpack visualizer has been added for development purposes ([#3119](https://github.com/specify/specify7/pull/3119)) + +### Fixed + +- "Export to KML" functionality has been returned ([#3088](https://github.com/specify/specify7/issues/3088) - *Reported + by CSIRO*) +- Fixed issue that prevented some users from merging items in the + trees ([#3133](https://github.com/specify/specify7/pull/3133) - *Reported by RBGE and AAFC*) +- Display issues preventing the "Name" field from displaying in the Security & Accounts panel has been resolved + - ([#3140](https://github.com/specify/specify7/issues/3140) - *Reported by SAIAB*) +- Record sets can no longer have a negative index value ([#3033](https://github.com/specify/specify7/issues/3033)) +- The color picker is now correctly positioned in Safari ([#2215](https://github.com/specify/specify7/issues/2215)) +- The default export delimiter is once again "Comma" instead of " + Tab" ([#3106](https://github.com/specify/specify7/issues/3106) - *Reported by FWRI*) +- Fixed some app resources not displaying due to a scoping bug ([#3014](https://github.com/specify/specify7/issues/3104) + - *Reported by SAIAB*) +- System information is now stored in the stack + trace ([5be8ece](https://github.com/specify/specify7/commit/5be8ece6cd5937354622b9efae162a9cd7aeb329)) +- Header overflowing has been resolved in the App Resources + viewer ([#3103](https://github.com/specify/specify7/issues/3103)) + + ## [7.8.7.1](https://github.com/specify/specify7/compare/v7.8.7...v7.8.7.1) (March 3 2023) ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51f3dccb481..e21cf14625b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,6 +101,8 @@ database both though Django ORM and though SQLAlchemy. Back-end root directory is [./specifyweb/](https://github.com/specify/specify7/tree/production/specifyweb) +[We have a video of a full back-end overview from January 2023 available here](https://drive.google.com/file/d/1OW60g99aiPw1Y8uHdCUxZCiVnLbFhObG/view?usp=sharing) + ## IDE Setup No special IDE configuration is required, but some optional plugins would diff --git a/specifyweb/context/app_resource.py b/specifyweb/context/app_resource.py index dc99af88cc4..3cd81009bd6 100644 --- a/specifyweb/context/app_resource.py +++ b/specifyweb/context/app_resource.py @@ -69,7 +69,8 @@ def load_resource_at_level(collection, user, level, resource_name): def get_path_for_level(collection, user, level): """Build the filesystem path for a given resource level.""" - discipline_dir = discipline_dirs.get(collection.discipline.type, None) + discipline_dir = None if collection is None else \ + discipline_dirs.get(collection.discipline.type, None) usertype = get_usertype(user) paths = { diff --git a/specifyweb/context/tests.py b/specifyweb/context/tests.py index ca84030bfe3..adb82780241 100644 --- a/specifyweb/context/tests.py +++ b/specifyweb/context/tests.py @@ -1,14 +1,13 @@ import json +from django.test import TestCase, Client from jsonschema import validate # type: ignore from jsonschema.exceptions import ValidationError # type: ignore -from django.test import TestCase, TransactionTestCase, Client - -from specifyweb.specify import models, api from specifyweb.specify.api_tests import ApiTests from . import viewsets + class ViewTests(ApiTests): def setUp(self): super(ViewTests, self).setUp() diff --git a/specifyweb/context/urls.py b/specifyweb/context/urls.py index 55197991c94..57b18de57d1 100644 --- a/specifyweb/context/urls.py +++ b/specifyweb/context/urls.py @@ -2,7 +2,7 @@ Defines the urls for the app context subsystem """ -from django.conf.urls import include, url +from django.conf.urls import url from django.urls import path from . import views, user_resources @@ -22,6 +22,8 @@ url(r'^system_info.json$', views.system_info), url(r'^domain.json$', views.domain), url(r'^view.json$', views.view), + url(r'^views.json$', views.views), + url(r'^viewsets.json$', views.viewsets), url(r'^datamodel.json$', views.datamodel), url(r'^schema_localization.json$', views.schema_localization), url(r'^app.resource$', views.app_resource), diff --git a/specifyweb/context/views.py b/specifyweb/context/views.py index 68f7666da5b..c95579499fd 100644 --- a/specifyweb/context/views.py +++ b/specifyweb/context/views.py @@ -34,7 +34,7 @@ from .app_resource import get_app_resource from .remote_prefs import get_remote_prefs from .schema_localization import get_schema_languages, get_schema_localization -from .viewsets import get_view +from .viewsets import get_views def set_collection_cookie(response, collection_id): @@ -397,29 +397,42 @@ def schema_localization(request): lang = request.GET.get('lang', request.LANGUAGE_CODE) return JsonResponse(get_schema_localization(request.specify_collection, 0, lang)) +view_parameters_schema = [ + { + "name" : "name", + "in":"query", + "required" : False, + "schema": { + "type": "string" + }, + "description" : "The name of the view to fetch" + }, + { + "name": "table", + "in": "query", + "required": False, + "schema": { + "type": "string" + }, + "description": "Table name to restrict views to. Either this or 'name' must be provided, but not both" + }, +] + @openapi(schema={ "parameters": [ - { - "name" : "name", - "in":"query", - "required" : True, - "schema": { - "type": "string" - }, - "description" : "The name of the view to fetch" + *view_parameters_schema, + { + "name": "quiet", + "in": "query", + "required": False, + "schema": { + "type": "boolean", + "default": False, }, - { - "name" : "quiet", - "in": "query", - "required" : False, - "schema": { - "type": "boolean", - "default": False, - }, - "allowEmptyValue": True, - "description": "Flag to indicate that if the view does not exist, return response with code 204 instead of 404" - } - ], + "allowEmptyValue": True, + "description": "Flag to indicate that if the view does not exist, return response with code 204 instead of 404" + } + ], "get" : { "responses": { "404": { @@ -427,6 +440,9 @@ def schema_localization(request): }, "204": { "description" : "View was not found but 'quiet' flag was provided" + }, + "200": { + "description": "View definition", } } } @@ -436,25 +452,102 @@ def schema_localization(request): @cache_control(max_age=86400, private=True) def view(request): """Return a Specify view definition by name taking into account the logged in user and collection.""" - quiet = "quiet" in request.GET and request.GET['quiet'].lower() != 'false' + # If view can not be found, return 204 if quiet and 404 otherwise + data = view_helper(request,1) + if not data: + quiet = \ + "quiet" in request.GET and request.GET['quiet'].lower() != 'false' + if quiet: return HttpResponse(status=204) + raise Http404("view: %s not found", request.GET['name'] if 'name' in request.GET else request.GET['table']) + return HttpResponse(json.dumps(data[0]), content_type="application/json") + +@openapi(schema={ + "parameters": [ + *view_parameters_schema, + { + "name": "limit", + "in": "query", + "required": False, + "schema": { + "type": "number", + "default": 0, + }, + "allowEmptyValue": True, + "description": "Maximum number of view definitions to return. Default - no limit" + } + ], + "get" : { + "responses": { + "200": { + "description": "View definition", + } + } + } +}) +@require_http_methods(['GET', 'HEAD']) +@login_maybe_required +@cache_control(max_age=86400, private=True) +def views(request): + """ + Return all Specify view definitions for a given table or by name taking + into account the logged in user and collection. + """ + try: + limit = int(request.GET['limit']) + except: + limit = 0 + data = view_helper(request,limit) + return HttpResponse(json.dumps(data), content_type="application/json") + +@openapi(schema={ + "get": { + "responses": { + "200": { + "description": "List of Specify 6 viewset xml files", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string", + } + }, + }, + }, + } + } + } +}) +@require_http_methods(['GET', 'HEAD']) +@login_maybe_required +@cache_control(max_age=86400, private=True) +def viewsets(request): + """Retrive a list of Specify 6 viewset xml files.""" + viewsets = [] + for root, dir, files in os.walk(settings.SPECIFY_CONFIG_DIR): + for file in files: + if file.endswith('.views.xml'): + viewsets.append( + os.path.relpath(os.path.join(root, file),settings.SPECIFY_CONFIG_DIR) + ) + return HttpResponse(json.dumps(viewsets), content_type="application/json") + + +def view_helper(request, limit): if 'collectionid' in request.GET: # Allow a URL parameter to override the logged in collection. collection = Collection.objects.get(id=request.GET['collectionid']) else: collection = request.specify_collection - try: - view_name = request.GET['name'] - except: - raise Http404() + view_name = request.GET['name'] if 'name' in request.GET else None + table = request.GET['table'] if 'table' in request.GET else None + if view_name is None and table is None: + raise Http404("'table' or 'name' must be provided.") + if view_name is not None and table is not None: + raise Http404("'table' and 'name' can not be provided together.") - # If view can not be found, return 204 if quiet and 404 otherwise - try: - data = get_view(collection, request.specify_user, view_name) - except Http404 as exception: - if quiet: return HttpResponse(status=204) - raise exception - return HttpResponse(json.dumps(data), content_type="application/json") + return get_views(collection, request.specify_user, view_name, limit, table) @require_http_methods(['GET', 'HEAD']) @login_maybe_required diff --git a/specifyweb/context/viewsets.py b/specifyweb/context/viewsets.py index e0519e56e3a..d195c9bc9e7 100644 --- a/specifyweb/context/viewsets.py +++ b/specifyweb/context/viewsets.py @@ -2,21 +2,38 @@ Provides a function that returns the appropriate view for a given context hierarchy level. Depends on the user and logged in collectien of the request. """ +import itertools import logging import os -from django.http import Http404 -from django.utils.encoding import force_bytes from xml.etree import ElementTree from xml.sax.saxutils import quoteattr +from django.conf import settings +from django.http import Http404 +from django.utils.encoding import force_bytes + from specifyweb.specify.models import Spappresourcedata from . import app_resource as AR logger = logging.getLogger(__name__) -def get_view(collection, user, viewname): +def get_view(collection, user, viewname, table=None): + # take the first view from the generator + views = get_views(collection,user,viewname,1,table) + if not views: + raise Http404("view: %s not found" % viewname) + +def get_views(collection, user, viewname, limit=None, table=None): """Return the data for the named view for the given user logged into the given collection.""" logger.debug("get_view %s %s %s", collection, user, viewname) + + if viewname is not None: + xpath = 'views/view[@name=%s]' % quoteattr(viewname) + elif table is not None: + xpath = 'views/view[@class=%s]' % quoteattr(f'edu.ku.brc.specify.datamodel.{table}') + else: + raise ValueError("Must specify viewname or table") + # setup a generator that looks for the view in the proper discovery order matches = ((id, viewset, view, src, level) # db first, then disk @@ -26,51 +43,60 @@ def get_view(collection, user, viewname): # then in the viewset files in a given directory level for id, viewset in get_viewsets(collection, user, level) # finally in the list of views in the file - for view in viewset.findall('views/view[@name=%s]' % quoteattr(viewname))) + for view in viewset.findall(xpath)) - # take the first view from the generator - try: - id, viewset, view, source, level = next(matches) - except StopIteration: - raise Http404("view: %s not found" % viewname) + limited_matches = matches if limit is None or limit==0 else itertools.islice(matches, limit) + return [process_view(view) for view in limited_matches] + +def process_view(match): + id, viewset, view, source, level = match altviews = view.findall('altviews/altview') # make a set of the viewdefs the view points to viewdefs = set(viewdef for altview in altviews - for viewdef in viewset.findall('viewdefs/viewdef[@name=%s]' % quoteattr(altview.attrib['viewdef']))) + for viewdef in viewset.findall( + 'viewdefs/viewdef[@name=%s]' % quoteattr(altview.attrib['viewdef']))) # some viewdefs reference other viewdefs through the 'definition' attribute # we will need to make sure those viewdefs are also sent to the client def get_definition(viewdef): definition = viewdef.find('definition') if definition is None: return - definition_viewdef = viewset.find('viewdefs/viewdef[@name=%s]' % quoteattr(definition.text)) + definition_viewdef = \ + viewset.find('viewdefs/viewdef[@name=%s]' % quoteattr(definition.text)) if definition_viewdef is None: raise Http404("no viewdef: %s for definition of viewdef: %s" % ( - definition.text, viewdef.attrib['name'])) + definition.text, viewdef.attrib['name'])) return definition_viewdef # add any viewdefs referenced in other viewdefs to the set viewdefs.update([definition - for viewdef in viewdefs - for definition in [ get_definition(viewdef) ] - if definition is not None]) + for viewdef in viewdefs + for definition in [get_definition(viewdef)] + if definition is not None]) # build the data to send to the client data = view.attrib.copy() data['altviews'] = dict((altview.attrib['name'], altview.attrib.copy()) for altview in altviews) - data['viewdefs'] = dict((viewdef.attrib['name'], ElementTree.tostring(viewdef, encoding="unicode")) + data['viewdefs'] = dict((viewdef.attrib['name'], + ElementTree.tostring(viewdef, encoding="unicode")) for viewdef in viewdefs) # these properties are useful to see where the view was found for debugging + data['view'] = ElementTree.tostring(view, encoding="unicode") data['viewsetName'] = viewset.attrib['name'] data['viewsetLevel'] = level data['viewsetSource'] = source - data['viewsetId'] = id + if type(id) is int: + data['viewsetId'] = id + data['viewsetFile'] = None + else: + data['viewsetId'] = None + data['viewsetFile'] = id return data def get_viewsets_from_db(collection, user, level): @@ -86,7 +112,10 @@ def get_viewsets_from_db(collection, user, level): def viewsets(): for o in objs: try: - yield o.spviewsetobj.id, ElementTree.fromstring(force_bytes(o.data)) + # Like default parser, but preserves comments + parser = ElementTree.XMLParser( + target=ElementTree.TreeBuilder(insert_comments=True)) + yield o.spviewsetobj.id, ElementTree.fromstring(force_bytes(o.data), parser=parser) except Exception as e: logger.error("Bad XML in view set: %s\n%s id = %s", e, o, o.id) @@ -106,7 +135,9 @@ def load_viewsets(collection, user, level): def viewsets(): for f in registry.findall('file'): try: - yield None, get_viewset_from_file(path, f.attrib['file']) + file_name = f.attrib['file'] + relative_path = os.path.join(os.path.relpath(path, settings.SPECIFY_CONFIG_DIR),file_name) + yield relative_path, get_viewset_from_file(path, file_name) except Exception: pass @@ -116,7 +147,10 @@ def get_viewset_from_file(path, filename): """Just load the XML for a viewset from path and pull out the root.""" file_path = os.path.join(path, filename) try: - return ElementTree.parse(file_path).getroot() + # Like default parser, but preserves comments + parser = ElementTree.XMLParser( + target=ElementTree.TreeBuilder(insert_comments=True)) + return ElementTree.parse(file_path, parser).getroot() except Exception as e: logger.error("Couldn't load viewset from %s\n$s", file_path, e) raise diff --git a/specifyweb/frontend/js_src/css/main.css b/specifyweb/frontend/js_src/css/main.css index 694e923a6bf..7fe0d44d742 100644 --- a/specifyweb/frontend/js_src/css/main.css +++ b/specifyweb/frontend/js_src/css/main.css @@ -100,7 +100,7 @@ [type='checkbox'], [type='radio'] { - @apply cursor-pointer text-brand-200 focus:!ring-2 focus:!ring-offset-1 + @apply cursor-pointer text-brand-200 focus:border-none focus:!ring-2 focus:!ring-offset-0 dark:text-brand-400; } diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/Aside.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/Aside.tsx index 762f7032c86..d650e07359c 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/Aside.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/Aside.tsx @@ -2,14 +2,13 @@ import React from 'react'; import { useParams } from 'react-router-dom'; import type { LocalizedString } from 'typesafe-i18n'; -import { useCachedState } from '../../hooks/useCachedState'; import { useErrorContext } from '../../hooks/useErrorContext'; import { useId } from '../../hooks/useId'; import { commonText } from '../../localization/common'; import { resourcesText } from '../../localization/resources'; import { StringToJsx } from '../../localization/utils'; import { f } from '../../utils/functools'; -import type { RA } from '../../utils/types'; +import type { GetSet, RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; import { multiSortFunction, removeItem, replaceItem } from '../../utils/utils'; import { Ul } from '../Atoms'; @@ -24,35 +23,48 @@ import { hasToolPermission } from '../Permissions/helpers'; import { ActiveLink, useIsActive } from '../Router/ActiveLink'; import { scrollIntoView } from '../TreeView/helpers'; import { appResourceIcon } from './EditorComponents'; -import { useFilteredAppResources } from './Filters'; import type { AppResourceFilters as AppResourceFiltersType } from './filtersHelpers'; -import { getResourceType } from './filtersHelpers'; +import { + defaultAppResourceFilters, + filterAppResources, + getResourceType, +} from './filtersHelpers'; import { buildAppResourceConformation, getAppResourceMode } from './helpers'; import type { AppResources, AppResourcesTree } from './hooks'; import { useAppResourceCount, useResourcesTree } from './hooks'; import { appResourceSubTypes } from './types'; +const emptyArray = [] as const; + export function AppResourcesAside({ - resources: initialResources, - initialFilters, + resources: unfilteredResources, + filters = defaultAppResourceFilters, isEmbedded, onOpen: handleOpen, + conformations: [initialConformations = emptyArray, setConformations], }: { readonly resources: AppResources; - readonly initialFilters?: AppResourceFiltersType; + readonly filters: AppResourceFiltersType | undefined; readonly isEmbedded: boolean; readonly onOpen?: ( resource: SerializedResource ) => void; + readonly conformations: GetSet | undefined>; }): JSX.Element { - const [conformations = [], setConformations] = useCachedState( - 'appResources', - 'conformation' + const resources = React.useMemo( + () => filterAppResources(unfilteredResources, filters), + [filters, unfilteredResources] ); - const resources = useFilteredAppResources(initialResources, initialFilters); const resourcesTree = useResourcesTree(resources); useErrorContext('appResourcesTree', resourcesTree); + const conformations = React.useMemo( + () => + initialConformations === emptyArray + ? buildAppResourceConformation(resourcesTree) + : initialConformations, + [initialConformations, resourcesTree] + ); useOpenCurrent(conformations, setConformations, resourcesTree); return ( @@ -108,7 +120,7 @@ function useOpenCurrent( updateConformation( item, conformation?.children.find( - (conformation) => conformation.key === category.key + (conformation) => conformation.key === item.key ) ) ) @@ -343,6 +355,7 @@ function ResourceItem({ }: { readonly resource: SerializedResource & { readonly type: ReturnType; + readonly label?: LocalizedString; }; readonly onOpen: | ((resource: SerializedResource) => void) @@ -381,9 +394,9 @@ function ResourceItem({ } > {appResourceIcon(resource.type)} - {typeof resource.name === 'string' - ? (resource.name as LocalizedString) - : commonText.nullInline()} + {('label' in resource ? resource.label : undefined) ?? + (resource.name as LocalizedString) ?? + commonText.nullInline()} ); } diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/Create.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/Create.tsx index d614d9083e7..5a691cfbb1c 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/Create.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/Create.tsx @@ -2,10 +2,13 @@ import React from 'react'; import { useOutletContext } from 'react-router'; import { useNavigate, useParams } from 'react-router-dom'; +import { useAsyncState } from '../../hooks/useAsyncState'; import { commonText } from '../../localization/common'; import { headerText } from '../../localization/header'; import { resourcesText } from '../../localization/resources'; +import { ajax } from '../../utils/ajax'; import { f } from '../../utils/functools'; +import type { RA } from '../../utils/types'; import { mappedFind } from '../../utils/utils'; import { Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; @@ -14,6 +17,7 @@ import { addMissingFields } from '../DataModel/addMissingFields'; import type { SerializedResource } from '../DataModel/helperTypes'; import { deserializeResource } from '../DataModel/serializers'; import type { SpAppResourceDir } from '../DataModel/types'; +import { filePathToHuman } from '../FormEditor/fetchAllViews'; import { spAppResourceView, spViewSetNameView, @@ -52,6 +56,9 @@ export function CreateAppResource(): JSX.Element { undefined ); const [mimeType, setMimeType] = React.useState(undefined); + const [templateFile, setTemplateFile] = React.useState< + string | false | undefined + >(undefined); return directory === undefined ? ( ) : type === undefined ? ( @@ -99,6 +106,7 @@ export function CreateAppResource(): JSX.Element { onClick={(): void => { setMimeType(mimeType ?? ''); setName(name); + setTemplateFile(false); }} > {icon} @@ -126,11 +134,14 @@ export function CreateAppResource(): JSX.Element { + ) : templateFile === undefined && type.tableName === 'SpViewSetObj' ? ( + ) : ( ); @@ -165,26 +176,82 @@ function getUrl( directoryKey: string, type: AppResourceType, name: string, - mimeType: string | undefined + mimeType: string | undefined, + templateFile?: string ): string { const path = type.tableName === 'SpAppResource' ? 'app-resource' : 'view-set'; return formatUrl(`/specify/resources/${path}/new/`, { directoryKey, name, mimeType, + templateFile, }); } +function ViewSetTemplates({ + onSelect: handleSelect, +}: { + readonly onSelect: (fileName: string | false) => void; +}): JSX.Element | null { + const [viewSets] = useAsyncState( + React.useCallback( + async () => + ajax>( + `/context/viewsets.json`, + { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }, + { + strict: false, + } + ) + .then(({ data }) => { + if (data.length === 0) handleSelect(false); + return data; + }) + .catch((error) => { + console.error(error); + handleSelect(false); + return undefined; + }), + [handleSelect] + ), + true + ); + return viewSets === undefined ? null : ( + handleSelect(false)} + > +
    + {viewSets.map((path, index) => ( +
  • + handleSelect(path)}> + {filePathToHuman(path)} + +
  • + ))} +
+
+ ); +} + function EditAppResource({ directory, name, type, mimeType, + templateFile, }: { readonly directory: SerializedResource; readonly name: string; readonly type: AppResourceType; readonly mimeType: string | undefined; + readonly templateFile: string | undefined; }): JSX.Element { const resource = React.useMemo( () => @@ -228,7 +295,8 @@ function EditAppResource({ directoryKey, type, resource.get('name'), - resource.get('mimeType') ?? undefined + resource.get('mimeType') ?? undefined, + templateFile ) ); /* diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx index 7d2a1ca5867..bd453380e2a 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx @@ -1,17 +1,19 @@ import React from 'react'; import { useErrorContext } from '../../hooks/useErrorContext'; +import { useLiveState } from '../../hooks/useLiveState'; import { useTriggerState } from '../../hooks/useTriggerState'; import { formsText } from '../../localization/forms'; import { localityText } from '../../localization/locality'; import { getAppResourceUrl } from '../../utils/ajax/helpers'; +import { f } from '../../utils/functools'; import { defined } from '../../utils/types'; import { getUniqueName } from '../../utils/uniquifyName'; import { Button } from '../Atoms/Button'; -import { Form } from '../Atoms/Form'; +import { Form, Select } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; -import { toTable } from '../DataModel/helpers'; +import { toResource, toTable } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import { createResource } from '../DataModel/resource'; import { @@ -38,9 +40,10 @@ import { appResourceIcon, AppResourceLoad, } from './EditorComponents'; -import { getResourceType } from './filtersHelpers'; +import { getAppResourceType, getResourceType } from './filtersHelpers'; import { useAppResourceData } from './hooks'; -import { AppResourcesTabs } from './Tabs'; +import { AppResourcesTab, useEditorTabs } from './Tabs'; +import { appResourceSubTypes } from './types'; export function AppResourceEditor({ resource, @@ -128,8 +131,36 @@ export function AppResourceEditor({ resource: appResource, }); const isInOverlay = isOverlay(React.useContext(OverlayContext)); + + const tabs = useEditorTabs(resource); + const [tabIndex, setTab] = useLiveState(React.useCallback(() => 0, [tabs])); + const tab = Math.min(tabIndex, tabs.length - 1); + const handleChangeTab = React.useCallback( + (index: number) => { + setTab(index); + syncData(); + }, + [syncData] + ); + const headerButtons = (
+ {tabs.length > 1 && ( +
+ +
+ )} {!isInOverlay && ( (0); - const handleChangeTab = React.useCallback( - (index: number) => { - setTabIndex(index); - syncData(); - }, - [syncData] + const [cleanup, setCleanup] = React.useState< + (() => Promise) | undefined + >(undefined); + const handleSetCleanup = React.useCallback( + (callback: (() => Promise) | undefined) => setCleanup(() => callback), + [] ); return typeof resourceData === 'object' @@ -207,20 +236,21 @@ export function AppResourceEditor({ forwardRef={setForm} > - { if (typeof data === 'function') setLastData(() => data); else setResourceData({ ...resourceData, data }); }} + onSetCleanup={handleSetCleanup} /> @@ -280,6 +310,24 @@ export function AppResourceEditor({ 'spAppResourceDir', resourceDirectory.resource_uri ); + + const subType = f.maybe( + toResource( + serializeResource(appResource), + 'SpAppResource' + ), + getAppResourceType + ); + // Set a mime type if it's not set yet + if (typeof subType === 'string') { + const type = appResourceSubTypes[subType]; + if (typeof type.name === 'string') + appResource.set( + 'mimeType', + type.mimeType ?? appResource.get('mimeType') + ); + } + await appResource.save(); const resource = serializeResource(appResource); @@ -300,9 +348,11 @@ export function AppResourceEditor({ ) ?? null, }); await appResourceData.save(); - await clearUrlCache( - getAppResourceUrl(appResource.get('name')) - ); + if (appResource.specifyTable.name === 'SpAppResource') + await clearUrlCache( + getAppResourceUrl(appResource.get('name')) + ); + await cleanup?.(); setResourceData( serializeResource( diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/EditorComponents.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/EditorComponents.tsx index ac3b6b388b1..5e33bfeb47a 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/EditorComponents.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/EditorComponents.tsx @@ -25,6 +25,7 @@ import { Dialog } from '../Molecules/Dialog'; import { downloadFile, FilePicker, fileToText } from '../Molecules/FilePicker'; import type { BaseSpec } from '../Syncer'; import type { SimpleXmlNode } from '../Syncer/xmlToJson'; +import { getUserPref } from '../UserPreferences/helpers'; import { usePref } from '../UserPreferences/usePref'; import { jsonLinter, xmlLinter } from './codeMirrorLinters'; import type { getResourceType } from './filtersHelpers'; @@ -146,6 +147,19 @@ export function useIndent(): string { return indentWithTab ? '\t' : ' '.repeat(indentSize); } +/** + * Use useIndent() instead whenever possible + */ +export function getIndent(): string { + const indentSize = getUserPref('appResources', 'behavior', 'indentSize'); + const indentWithTab = getUserPref( + 'appResources', + 'behavior', + 'indentWithTab' + ); + return indentWithTab ? '\t' : ' '.repeat(indentSize); +} + export function useCodeMirrorExtensions( resource: SerializedResource, appResource: SpecifyResource, @@ -155,7 +169,10 @@ export function useCodeMirrorExtensions( const [indentSize] = usePref('appResources', 'behavior', 'indentSize'); const indentCharacter = useIndent(); - const mode = getAppResourceExtension(resource); + const mode = React.useMemo( + () => getAppResourceExtension(resource), + [resource] + ); const [extensions, setExtensions] = React.useState>([]); React.useEffect(() => { function handleLinted(results: RA): void { diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/EditorWrapper.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/EditorWrapper.tsx index 09744402b94..949cf4c5694 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/EditorWrapper.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/EditorWrapper.tsx @@ -4,10 +4,14 @@ import { useNavigate, useParams } from 'react-router-dom'; import { useSearchParameter } from '../../hooks/navigation'; import { useAsyncState } from '../../hooks/useAsyncState'; +import { ajax } from '../../utils/ajax'; +import { Http } from '../../utils/ajax/definitions'; +import { getAppResourceUrl } from '../../utils/ajax/helpers'; import { f } from '../../utils/functools'; import { Container } from '../Atoms'; import { DataEntry } from '../Atoms/DataEntry'; import { addMissingFields } from '../DataModel/addMissingFields'; +import { toResource } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import { fetchResource } from '../DataModel/resource'; import type { @@ -23,11 +27,13 @@ import { findAppResourceDirectoryKey, } from './Create'; import { AppResourceEditor } from './Editor'; +import { getAppResourceType } from './filtersHelpers'; import type { AppResourceMode } from './helpers'; import { getAppResourceMode } from './helpers'; import type { AppResources, AppResourcesTree } from './hooks'; import { useResourcesTree } from './hooks'; import type { AppResourcesOutlet } from './index'; +import { appResourceSubTypes } from './types'; export function AppResourceView(): JSX.Element { return ; @@ -46,7 +52,7 @@ export function Wrapper({ getSet: [resources, setResources], } = useOutletContext(); - const { name, mimeType, rawDirectoryKey, clone } = useProps(); + const { name, mimeType, rawDirectoryKey, clone, templateFile } = useProps(); const newResource = React.useMemo( () => @@ -62,11 +68,11 @@ export function Wrapper({ specifyUser: userInformation.resource_uri, } ), - [resources, name, mimeType] + [resources, name, mimeType, mode] ); const resource = useAppResource(newResource, resources, mode); - const initialData = useInitialData(f.parseInt(clone)); + const initialData = useInitialData(resource, f.parseInt(clone), templateFile); const navigate = useNavigate(); @@ -81,8 +87,7 @@ export function Wrapper({ const baseHref = `/specify/resources/${ mode === 'appResources' ? 'app-resource' : 'view-set' }`; - return initialData === undefined ? null : resource === undefined || - directory === undefined ? ( + return initialData === undefined ? null : directory === undefined ? ( ) : ( | undefined, + newResource: SerializedResource, resources: AppResources, mode: AppResourceMode -): SerializedResource | undefined { +): SerializedResource { const { id } = useParams(); return React.useMemo( () => @@ -189,18 +196,50 @@ function useAppResource( } function useInitialData( - initialDataFrom: number | undefined + resource: SerializedResource, + initialDataFrom: number | undefined, + templateFile: string | undefined ): string | false | undefined { return useAsyncState( - React.useCallback( - async () => - initialDataFrom === undefined - ? false - : fetchResource('SpAppResourceData', initialDataFrom).then( - ({ data }) => data ?? '' - ), - [initialDataFrom] - ), + React.useCallback(async () => { + if (typeof initialDataFrom === 'number') + return fetchResource('SpAppResourceData', initialDataFrom).then( + ({ data }) => data ?? '' + ); + else if (typeof templateFile === 'string') { + if (templateFile.includes('..')) + console.error( + 'Relative paths not allowed. Path is always relative to /static/config/' + ); + else + return ajax(`/static/config/${templateFile}`, { + headers: {}, + }) + .then(({ data }) => data ?? '') + .catch(() => ''); + } + const subType = f.maybe( + toResource(resource, 'SpAppResource'), + getAppResourceType + ); + if (typeof subType === 'string') { + const type = appResourceSubTypes[subType]; + const useTemplate = + typeof type.name === 'string' && + (!('useTemplate' in type) || type.useTemplate); + if (useTemplate) + return ajax( + getAppResourceUrl(type.name, 'quiet'), + { + headers: {}, + }, + { expectedResponseCodes: [Http.OK, Http.NO_CONTENT] } + ).then(({ data }) => data); + } + return false; + // Run this only once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialDataFrom, templateFile]), true )[0]; } diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/Filters.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/Filters.tsx index bcbb991a643..3e8e3207257 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/Filters.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/Filters.tsx @@ -6,6 +6,7 @@ import { useCachedState } from '../../hooks/useCachedState'; import { commonText } from '../../localization/common'; import { headerText } from '../../localization/header'; import { resourcesText } from '../../localization/resources'; +import type { RA } from '../../utils/types'; import { toggleItem } from '../../utils/utils'; import { Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; @@ -14,12 +15,10 @@ import { Input, Label } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; import { Link } from '../Atoms/Link'; import { Dialog } from '../Molecules/Dialog'; -import type { AppResourceFilters as AppResourceFiltersType } from './filtersHelpers'; import { allAppResources, countAppResources, defaultAppResourceFilters, - filterAppResources, isAllAppResourceTypes, } from './filtersHelpers'; import type { AppResources } from './hooks'; @@ -46,8 +45,7 @@ export function AppResourcesFilters({ return ( <> -
- {resourcesText.filters()} + @@ -78,7 +76,7 @@ export function AppResourcesFilters({ > {icons.cog} -
+ {isOpen && ( ; +}): JSX.Element { + return ( +
+ {screenReaderLabel} + {children} +
+ ); +} + +export const radioButtonClassName = (isPressed: boolean) => ` + ${className.niceButton} ${className.ariaHandled} + ${ + isPressed + ? className.blueButton + : 'hover:bg-gray-300 hover:dark:bg-neutral-600' + } + `; + function RadioButton({ isPressed, children, @@ -182,14 +204,7 @@ function RadioButton({ // REFACTOR: this should reuse Button.Small ); } - -export function useFilteredAppResources( - initialResources: AppResources, - initialFilters: AppResourceFiltersType | undefined = defaultAppResourceFilters -): AppResources { - const [filters, setFilters] = useCachedState('appResources', 'filters'); - - /* - * Allows to temporary override configured app resource filters. Before - * unmount, previous value is returned - */ - React.useEffect(() => { - if (initialFilters === defaultAppResourceFilters) return undefined; - setFilters(initialFilters); - const oldFilter = filters; - return (): void => setFilters(oldFilter); - /* - * Only run this on mount - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [setFilters, initialFilters]); - - const nonNullFilters = filters ?? initialFilters; - return React.useMemo( - () => filterAppResources(initialResources, nonNullFilters), - [nonNullFilters, initialResources] - ); -} diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx index 01a392b240d..a6cc11aa2de 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx @@ -18,8 +18,12 @@ import type { SpAppResourceDir, SpViewSetObj, } from '../DataModel/types'; +import { RssExportFeedEditor } from '../ExportFeed'; +import { exportFeedSpec } from '../ExportFeed/spec'; import { DataObjectFormatter } from '../Formatters'; import { formattersSpec } from '../Formatters/spec'; +import { FormEditor } from '../FormEditor'; +import { viewSetsSpec } from '../FormEditor/spec'; import type { BaseSpec } from '../Syncer'; import type { SimpleXmlNode } from '../Syncer/xmlToJson'; import { PreferencesContent } from '../UserPreferences'; @@ -37,7 +41,6 @@ import type { appResourceSubTypes } from './types'; export type AppResourceEditorType = 'generic' | 'json' | 'visual' | 'xml'; export type AppResourceTabProps = { - readonly editorType: AppResourceEditorType; readonly resource: SerializedResource; readonly appResource: SpecifyResource; readonly directory: SerializedResource; @@ -52,6 +55,7 @@ export type AppResourceTabProps = { readonly onChange: ( data: string | (() => string | null | undefined) | null ) => void; + readonly onSetCleanup: (callback: () => Promise) => void; }; const generateEditor = (xmlSpec: (() => BaseSpec) | undefined) => function AppResourceTextEditor({ @@ -59,9 +63,11 @@ const generateEditor = (xmlSpec: (() => BaseSpec) | undefined) => appResource, data, showValidationRef, + className = '', onChange: handleChange, - }: Omit & { + }: Omit & { readonly onChange: (data: string) => void; + readonly className?: string; }): JSX.Element { const isDarkMode = useDarkMode(); const extensions = useCodeMirrorExtensions(resource, appResource, xmlSpec); @@ -93,11 +99,20 @@ const generateEditor = (xmlSpec: (() => BaseSpec) | undefined) => const isReadOnly = React.useContext(ReadOnlyContext); return ( { + selectionRef.current = state.selection.toJSON(); + }} + className={`w-full border border-brand-300 dark:border-none ${className}`} + /* + * Disable spell check if we are doing own validation as otherwise it's + * hard to differentiate between browser's spell check errors and our + * validation errors + */ + spellCheck={typeof xmlSpec === 'function'} value={data ?? ''} /* * FEATURE: provide supported attributes for autocomplete @@ -105,9 +120,6 @@ const generateEditor = (xmlSpec: (() => BaseSpec) | undefined) => * https://github.com/codemirror/lang-xml#api-reference */ onChange={handleChange} - onUpdate={({ state }): void => { - selectionRef.current = state.selection.toJSON(); - }} /> ); }; @@ -165,7 +177,7 @@ function UserPreferencesEditor({ export const visualAppResourceEditors = f.store< RR< - keyof typeof appResourceSubTypes, + keyof typeof appResourceSubTypes | 'viewSet', | { readonly visual?: (props: AppResourceTabProps) => JSX.Element; readonly json?: (props: AppResourceTabProps) => JSX.Element; @@ -174,6 +186,10 @@ export const visualAppResourceEditors = f.store< | undefined > >(() => ({ + viewSet: { + visual: FormEditor, + xml: generateXmlEditor(viewSetsSpec), + }, label: undefined, report: undefined, userPreferences: { @@ -185,17 +201,19 @@ export const visualAppResourceEditors = f.store< json: AppResourceTextEditor, }, leafletLayers: undefined, - rssExportFeed: undefined, + rssExportFeed: { + visual: RssExportFeedEditor, + xml: generateXmlEditor(exportFeedSpec), + }, expressSearchConfig: undefined, + typeSearches: undefined, webLinks: { visual: WebLinkEditor, - json: WebLinkEditor, xml: generateXmlEditor(webLinksSpec), }, uiFormatters: undefined, dataObjectFormatters: { visual: DataObjectFormatter, - json: DataObjectFormatter, xml: generateXmlEditor(formattersSpec), }, searchDialogDefinitions: undefined, diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/Tabs.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/Tabs.tsx index 45b76575563..b3f88e0b68c 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/Tabs.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/Tabs.tsx @@ -9,7 +9,6 @@ import type { GetSet, IR, RA, RR } from '../../utils/types'; import { filterArray } from '../../utils/types'; import { WarningMessage } from '../Atoms'; import { Button } from '../Atoms/Button'; -import { className } from '../Atoms/className'; import { toResource } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; @@ -23,6 +22,7 @@ import type { import { ErrorBoundary } from '../Errors/ErrorBoundary'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import { appResourceIcon } from './EditorComponents'; +import { radioButtonClassName } from './Filters'; import { getAppResourceType, getResourceType } from './filtersHelpers'; import type { AppResourceEditorType, @@ -33,7 +33,8 @@ import { visualAppResourceEditors, } from './TabDefinitions'; -export function AppResourcesTabs({ +export function AppResourcesTab({ + tab: Component, label, showValidationRef, headerButtons, @@ -41,10 +42,11 @@ export function AppResourcesTabs({ resource, directory, data, - index, isFullScreen: [isFullScreen, handleChangeFullScreen], onChange: handleChange, + onSetCleanup: setCleanup, }: { + readonly tab: Component; readonly label: LocalizedString; readonly showValidationRef: React.MutableRefObject<(() => void) | null>; readonly appResource: SpecifyResource; @@ -53,30 +55,23 @@ export function AppResourcesTabs({ readonly headerButtons: JSX.Element; readonly data: string | null; readonly isFullScreen: GetSet; - readonly index: GetSet; readonly onChange: ( data: string | (() => string | null | undefined) | null ) => void; + readonly onSetCleanup: (callback: () => Promise) => void; }): JSX.Element { - const tabs = useEditorTabs(resource); const children = ( - [ - label, - , - ]) - )} - /> + + + ); return isFullScreen ? ( JSX.Element; + +export function useEditorTabs( resource: SerializedResource ): RA<{ readonly label: LocalizedString; - readonly component: ( - props: Omit - ) => JSX.Element; + readonly component: (props: AppResourceTabProps) => JSX.Element; }> { - const subType = f.maybe( - toResource(resource, 'SpAppResource'), - getAppResourceType - ); + const subType = + f.maybe(toResource(resource, 'SpAppResource'), getAppResourceType) ?? + 'viewSet'; return React.useMemo(() => { const editors = typeof subType === 'string' @@ -123,7 +117,7 @@ function useEditorTabs( { label: labels.generic, component(props): JSX.Element { - return ; + return ; }, }, ] @@ -138,7 +132,7 @@ function useEditorTabs( {type === 'visual' && ( )} - + ); }, @@ -188,13 +182,15 @@ export function Tabs({ {Object.keys(tabs).map((label, index) => ( handleChange(index) : undefined + currentIndex === index + ? (): void => handleChange(index) + : undefined } > {label} diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts b/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts index 0d3a10d25ab..94e2e1c1ec8 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts @@ -65,8 +65,8 @@ export const getAppResourceType = ( ): keyof typeof appResourceSubTypes => resource.name === 'preferences' && (resource.mimeType ?? '') === '' ? 'otherPropertiesResource' - : Object.entries(appResourceSubTypes).find( - ([_key, { name, mimeType }]) => - (name === undefined || name === resource.name) && - (mimeType === undefined || mimeType === resource.mimeType) + : Object.entries(appResourceSubTypes).find(([_key, { name, mimeType }]) => + name === undefined + ? mimeType === resource.mimeType + : name === resource.name )?.[KEY] ?? 'otherAppResources'; diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/hooks.ts b/specifyweb/frontend/js_src/lib/components/AppResources/hooks.ts index c59892cfdb2..a7c755e8040 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/hooks.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/hooks.ts @@ -14,10 +14,13 @@ import type { SpAppResourceData, SpAppResourceDir, SpecifyUser, - SpViewSetObj as SpViewSetObject, + SpViewSetObj, } from '../DataModel/types'; +import { usePref } from '../UserPreferences/usePref'; +import { getAppResourceType } from './filtersHelpers'; import { getAppResourceCount, getAppResourceMode } from './helpers'; import { getAppResourceTree } from './tree'; +import { appResourceSubTypes } from './types'; export type AppResources = { readonly directories: RA>; @@ -25,10 +28,12 @@ export type AppResources = { readonly collections: RA>; readonly users: RA>; readonly appResources: RA>; - readonly viewSets: RA>; + readonly viewSets: RA>; }; -export function useAppResources(): GetOrSet { +export function useAppResources( + loadingScreen: boolean = true +): GetOrSet { return useAsyncState( React.useCallback( async () => @@ -54,7 +59,7 @@ export function useAppResources(): GetOrSet { }), [] ), - true + loadingScreen ); } @@ -72,16 +77,48 @@ export type AppResourcesTree = RA<{ */ readonly key: string; readonly directory: SerializedResource | undefined; - readonly appResources: RA>; - readonly viewSets: RA>; + readonly appResources: RA< + SerializedResource & { + readonly label?: LocalizedString; + } + >; + readonly viewSets: RA>; readonly subCategories: AppResourcesTree; }>; export function useResourcesTree(resources: AppResources): AppResourcesTree { - return React.useMemo( - () => getAppResourceTree(resources), - [resources] + const [localize] = usePref( + 'appResources', + 'appearance', + 'localizeResourceNames' ); + return React.useMemo(() => { + const tree = getAppResourceTree(resources); + return localize ? localizeTree(tree) : tree; + }, [resources, localize]); +} + +const localizeTree = (tree: AppResourcesTree): AppResourcesTree => + tree.map(({ appResources, subCategories, ...rest }) => ({ + ...rest, + appResources: appResources.map(localizeResource), + subCategories: localizeTree(subCategories), + })); + +function localizeResource( + resource: SerializedResource & { + readonly label?: LocalizedString; + } +): SerializedResource & { readonly label?: LocalizedString } { + const type = appResourceSubTypes[getAppResourceType(resource)]; + // Check that resource of this type can only have one specific name + return typeof type.name === 'string' + ? { + ...resource, + // Then replace the name with a localized label unless it's already set + label: resource.label ?? type.label, + } + : resource; } export function useAppResourceCount( @@ -97,7 +134,7 @@ export function useAppResourceCount( * Fetch resource contents */ export function useAppResourceData( - resource: SerializedResource, + resource: SerializedResource, initialData: string | undefined ): { readonly resourceData: GetOrSet< @@ -123,12 +160,13 @@ export function useAppResourceData( ? await fetchCollection('SpAppResourceData', { limit: 1, [relationshipName]: resource.id, - }).then(({ records }) => - /* - * For some reason, app resource can have multiple app resource - * datas (but it never does in practice) - */ - typeof records[0] === 'object' ? records[0] : newResource + }).then( + ({ records }) => + /* + * For some reason, app resource can have multiple app resource + * datas (but it never does in practice) + */ + records[0] ?? newResource ) : newResource; const newData = fixLineBreaks(dataResource.data ?? ''); @@ -147,7 +185,7 @@ const fixLineBreaks = (string: string): string => string.replaceAll(/[\n\r]+/gu, '\n'); export const getAppResourceExtension = ( - resource: SerializedResource + resource: SerializedResource ): string => resource._tableName === 'SpViewSetObj' ? 'xml' @@ -156,7 +194,8 @@ export const getAppResourceExtension = ( function getResourceExtension( resource: SerializedResource ): 'jrxml' | 'json' | 'properties' | 'txt' | 'xml' { - const mimeType = resource.mimeType?.toLowerCase() ?? ''; + const type = appResourceSubTypes[getAppResourceType(resource)]; + const mimeType = resource.mimeType?.toLowerCase() ?? type?.mimeType ?? ''; if (mimeType in mimeMapper) return mimeMapper[mimeType]; else if (mimeType.startsWith('jrxml')) return 'jrxml'; else if (resource.name === 'preferences' && mimeType === '') diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/index.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/index.tsx index 3bf8a46cef0..cab99816f4b 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { useCachedState } from '../../hooks/useCachedState'; import { useErrorContext } from '../../hooks/useErrorContext'; import { resourcesText } from '../../localization/resources'; import type { GetOrSet } from '../../utils/types'; @@ -15,7 +16,7 @@ export function AppResourcesWrapper(): JSX.Element { return ( - + @@ -44,6 +45,9 @@ function AppResourcesView({ }): JSX.Element { const [resources] = getSet; + const conformations = useCachedState('appResources', 'conformation'); + const [filters] = useCachedState('appResources', 'filters'); + return (
@@ -53,7 +57,12 @@ function AppResourcesView({
- + getSet={getSet as GetOrSet} /> diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/tree.ts b/specifyweb/frontend/js_src/lib/components/AppResources/tree.ts index 56a2c1904b7..cd8927dc2f9 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/tree.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/tree.ts @@ -91,7 +91,7 @@ const remoteUserType = 'Prefs'.toLowerCase(); const disambiguateGlobalPrefs = ( appResources: RA>, directories: RA> -): RA> => +): AppResourcesTree[number]['appResources'] => appResources.map((resource) => { if (resource.name !== prefResource) return resource; const directory = directories.find( @@ -101,9 +101,9 @@ const disambiguateGlobalPrefs = ( if (!directory) return resource; const userType = directory.userType?.toLowerCase(); if (userType === globalUserType) - return { ...resource, name: resourcesText.globalPreferences() }; + return { ...resource, label: resourcesText.globalPreferences() }; else if (userType === remoteUserType) - return { ...resource, name: resourcesText.remotePreferences() }; + return { ...resource, label: resourcesText.remotePreferences() }; else return resource; }); @@ -204,20 +204,20 @@ const getCollectionResources = ( resources: AppResources ): AppResourcesTree => [ { - label: resourcesText.userTypes(), - key: 'userTypes', + label: userText.users(), + key: 'users', directory: undefined, appResources: [], viewSets: [], - subCategories: sortTree(getUserTypeResources(collection, resources)), + subCategories: sortTree(getUserResources(collection, resources)), }, { - label: userText.users(), - key: 'users', + label: resourcesText.userTypes(), + key: 'userTypes', directory: undefined, appResources: [], viewSets: [], - subCategories: sortTree(getUserResources(collection, resources)), + subCategories: sortTree(getUserTypeResources(collection, resources)), }, ]; @@ -252,29 +252,27 @@ const getUserResources = ( collection: SerializedResource, resources: AppResources ): AppResourcesTree => - resources.users - .map((user) => { - const directories = resources.directories.filter( - (directory) => - directory.collection === collection.resource_uri && - directory.specifyUser === user.resource_uri && - directory.isPersonal - ); - const directory = - directories[0] ?? - addMissingFields('SpAppResourceDir', { - collection: collection.resource_uri, - discipline: collection.discipline, - specifyUser: user.resource_uri, - isPersonal: true, - }); + resources.users.map((user) => { + const directories = resources.directories.filter( + (directory) => + directory.collection === collection.resource_uri && + directory.specifyUser === user.resource_uri && + directory.isPersonal + ); + const directory = + directories[0] ?? + addMissingFields('SpAppResourceDir', { + collection: collection.resource_uri, + discipline: collection.discipline, + specifyUser: user.resource_uri, + isPersonal: true, + }); - return { - label: user.name as LocalizedString, - key: `collection_${collection.id}_user_${user.id}`, - directory, - ...mergeDirectories(directories, resources), - subCategories: [], - }; - }) - .sort(sortFunction(({ label }) => label)); + return { + label: user.name as LocalizedString, + key: `collection_${collection.id}_user_${user.id}`, + directory, + ...mergeDirectories(directories, resources), + subCategories: [], + }; + }); diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx index 8d175ce9e1a..ccf5144e0d2 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx @@ -28,12 +28,21 @@ export const appResourceTypes: RR = { }, }; -export type AppResourceSubType = { +type AppResourceSubType = { readonly mimeType: string | undefined; readonly name: string | undefined; readonly documentationUrl: string | undefined; readonly icon: JSX.Element; readonly label: LocalizedString; + /** + * Whether when creating a new app resource of this type, should copy the + * contents from an existing app resource of that type that is in current + * scope. + * Default value: + * If app resource type can only have one specific name, this is true + * Else false + */ + readonly useTemplate?: boolean; }; /** @@ -42,7 +51,7 @@ export type AppResourceSubType = { * current resource. Thus, subtypes should be sorted from the most * specific to the least specific. */ -export const appResourceSubTypes = { +export const appResourceSubTypes = ensure>()({ label: { mimeType: 'jrxml/label', name: undefined, @@ -66,6 +75,7 @@ export const appResourceSubTypes = { 'https://discourse.specifysoftware.org/t/specify-7-user-preferences-webinar/861', icon: icons.cog, label: preferencesText.userPreferences(), + useTemplate: false, }, defaultUserPreferences: { mimeType: 'application/json', @@ -99,6 +109,14 @@ export const appResourceSubTypes = { icon: icons.search, label: resourcesText.expressSearchConfig(), }, + typeSearches: { + mimeType: 'text/xml', + name: 'TypeSearches', + documentationUrl: + 'https://discourse.specifysoftware.org/t/adding-a-non-native-query-combo-box/859#h-1-type-search-definition-typesearch_defxml-8', + icon: icons.documentSearch, + label: resourcesText.typeSearches(), + }, webLinks: { mimeType: 'text/xml', name: 'WebLinks', @@ -167,6 +185,4 @@ export const appResourceSubTypes = { icon: icons.document, label: resourcesText.otherAppResource(), }, -} as const; - -ensure>()(appResourceSubTypes); +} as const); diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/Button.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/Button.tsx index dbadd5a230e..7b6e16c8ed9 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/Button.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/Button.tsx @@ -110,6 +110,10 @@ export const Button = { 'Button.Orange', `${className.niceButton} ${className.orangeButton}` ), + Specify: button( + 'Button.Specify', + `${className.niceButton} ${className.specifyButton}` + ), Green: button( 'Button.Green', `${className.niceButton} ${className.greenButton}` diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx index b6cfc3ac666..62fb8983c70 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx @@ -64,7 +64,7 @@ export const DataEntry = { >( 'DataEntry.Grid', 'div', - `overflow-x-auto items-center p-1 -ml-1 gap-2`, + `items-center p-1 -ml-1 gap-2`, ({ viewDefinition, display, diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/Submit.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/Submit.tsx index 77c964210e9..0dc0ae424ff 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/Submit.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/Submit.tsx @@ -50,6 +50,10 @@ export const Submit = { 'Submit.Orange', `${className.niceButton} ${className.orangeButton}` ), + Specify: submitButton( + 'Submit.Specify', + `${className.niceButton} ${className.specifyButton}` + ), Green: submitButton( 'Submit.Green', `${className.niceButton} ${className.greenButton}` diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/DataEntry.test.ts b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/DataEntry.test.ts index c8085017758..320b87c4ae3 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/DataEntry.test.ts +++ b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/DataEntry.test.ts @@ -71,6 +71,7 @@ snapshot(DataEntry.Edit, { onClick: f.never }); snapshot(DataEntry.Clone, { onClick: f.never }); snapshot(DataEntry.Search, { onClick: f.never }); snapshot(DataEntry.Remove, { onClick: f.never }); + describe('DataEntry.visit', () => { snapshot(DataEntry.Visit, { resource: undefined }, 'no resource'); snapshot( diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Button.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Button.test.tsx.snap index 05ed4c18b71..9d8e274b502 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Button.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Button.test.tsx.snap @@ -139,3 +139,17 @@ exports[`Button.Small default variant 1`] = ` /> `; + +exports[`DialogButton closes the dialog 1`] = ` + + + +`; diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/DataEntry.test.ts.snap b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/DataEntry.test.ts.snap index 8323611734e..a976680fa77 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/DataEntry.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/DataEntry.test.ts.snap @@ -30,7 +30,7 @@ exports[` renders without errors 2`] = `