type === 'any')?.fieldFormat
}
diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx
index eaf960cf9d3..1eaf7f3d297 100644
--- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx
+++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import type { LocalizedString } from 'typesafe-i18n';
-import { useAsyncState } from '../../hooks/useAsyncState';
+import { useAsyncState, usePromise } from '../../hooks/useAsyncState';
import { useInfiniteScroll } from '../../hooks/useInfiniteScroll';
import { usePaginatedCollection } from '../../hooks/usePaginatedCollection';
import { commonText } from '../../localization/common';
@@ -15,6 +15,7 @@ import type { SpecifyResource } from '../DataModel/legacyTypes';
import { schema } from '../DataModel/schema';
import type { SpecifyTable } from '../DataModel/specifyTable';
import type { SpQuery } from '../DataModel/types';
+import { fetchFormatters } from '../Formatters/formatters';
import { treeRanksPromise } from '../InitialContext/treeRanks';
import { RecordMergingLink } from '../Merging';
import { loadingGif } from '../Molecules';
@@ -28,11 +29,12 @@ import {
import { fetchPickList } from '../PickLists/fetch';
import { userPreferences } from '../Preferences/userPreferences';
import { generateMappingPathPreview } from '../WbPlanView/mappingPreview';
+import { mappingPathToString } from '../WbPlanView/mappingHelpers';
import { recordSetFromQueryLoading } from './Components';
import { CreateRecordSet } from './CreateRecordSet';
import type { QueryFieldSpec } from './fieldSpec';
import type { QueryField } from './helpers';
-import { sortTypes } from './helpers';
+import { queryFieldFormatType, sortTypes } from './helpers';
import { QueryResultsTable } from './ResultsTable';
import { QueryToForms } from './ToForms';
import { QueryToMap } from './ToMap';
@@ -40,6 +42,46 @@ import { SplitView } from './SplitView';
export type QueryResultRow = RA
;
+type ResultFormatDefinition = {
+ readonly table: { readonly name: string } | undefined;
+ readonly name: string;
+ readonly isDefault: boolean;
+};
+
+export function getResultHeaderFormatSuffix(
+ field: QueryField,
+ visibleFields: RA,
+ tableName: string,
+ formatters:
+ | {
+ readonly formatters: RA;
+ readonly aggregators: RA;
+ }
+ | undefined
+): string | undefined {
+ const type = queryFieldFormatType(field.mappingPath);
+ const formatName = field.filters.find(
+ ({ type }) => type === 'any'
+ )?.fieldFormat;
+ if (type === undefined || !formatName || formatters === undefined)
+ return undefined;
+ const definitions =
+ type === 'aggregator' ? formatters.aggregators : formatters.formatters;
+ if (
+ definitions.some(
+ ({ table, name, isDefault }) =>
+ table?.name === tableName && name === formatName && isDefault
+ )
+ )
+ return undefined;
+ const path = mappingPathToString(field.mappingPath);
+ return visibleFields.filter(
+ (other) => mappingPathToString(other.mappingPath) === path
+ ).length > 1
+ ? ` (${formatName})`
+ : undefined;
+}
+
/*
* These values are somewhat arbitrary but are based on testing with real queries.
* Column widths smaller than 120px tend to cause readability issues, and widths
@@ -248,6 +290,10 @@ export function QueryResults(props: QueryResultsProps): JSX.Element {
.map((column, visibleIndex) => ({ ...column, visibleIndex })),
[fieldSpecs]
);
+ const [formatters] = usePromise(fetchFormatters, false);
+ const visibleFields = visibleColumns.map(
+ ({ fieldIndex }) => displayedFields[fieldIndex]
+ );
if (resultsRef !== undefined) resultsRef.current = results;
React.useEffect(() => {
@@ -637,6 +683,12 @@ export function QueryResults(props: QueryResultsProps): JSX.Element {
@@ -751,12 +803,14 @@ export function QueryResults(props: QueryResultsProps): JSX.Element {
function TableHeaderCell({
columnIndex,
fieldSpec,
+ formatSuffix,
sortConfig,
onSortChange: handleSortChange,
onResizeStart: handleResizeStart,
}: {
readonly columnIndex?: number;
readonly fieldSpec: QueryFieldSpec | undefined;
+ readonly formatSuffix?: string;
readonly sortConfig: QueryField['sortType'];
readonly onSortChange?: (sortType: QueryField['sortType']) => void;
readonly onResizeStart?: (event: React.MouseEvent) => void;
@@ -773,6 +827,7 @@ function TableHeaderCell({
fieldSpec.baseTable.name,
fieldSpec.toMappingPath()
)}
+ {formatSuffix}
) : undefined;
diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx
index f4344d3cdb6..a4549adfa9e 100644
--- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx
+++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx
@@ -3,6 +3,7 @@ import React from 'react';
import { useUnloadProtect } from '../../hooks/navigation';
import { useResource } from '../../hooks/resource';
import { useAsyncState } from '../../hooks/useAsyncState';
+import { usePromise } from '../../hooks/useAsyncState';
import { useBooleanState } from '../../hooks/useBooleanState';
import { useCachedState } from '../../hooks/useCachedState';
import { useErrorContext } from '../../hooks/useErrorContext';
@@ -28,7 +29,12 @@ 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 {
+ getMappedFields,
+ mappingPathIsComplete,
+ mutateMappingPath,
+} from '../WbPlanView/helpers';
+import type { MappingPath } from '../WbPlanView/Mapper';
import { getMappingLineProps } from '../WbPlanView/LineComponents';
import { MappingView } from '../WbPlanView/MapperComponents';
import {
@@ -43,9 +49,16 @@ import { navigatorSpecs } from '../WbPlanView/navigatorSpecs';
import { CheckReadAccess } from './CheckReadAccess';
import { IsQueryBasicContext, useQueryViewPref } from './Context';
import { QueryFields } from './Fields';
+import { fetchFormatters } from '../Formatters/formatters';
+import { QueryFieldSpec } from './fieldSpec';
import { QueryFromMap } from './FromMap';
import { QueryHeader } from './Header';
-import { unParseQueryFields } from './helpers';
+import {
+ nextAvailableQueryFormat,
+ queryFieldFormatType,
+ unParseQueryFields,
+} from './helpers';
+import type { QueryField } from './helpers';
import { getInitialState, reducer } from './reducer';
import type { QueryResultRow } from './Results';
import { QueryBuilderResults } from './QueryBuilderResults';
@@ -80,15 +93,20 @@ function Wrapped({
forceCollection,
isEmbedded = false,
autoRun = false,
+ defaultBasicView = false,
// If present, this callback is called when query results are selected
onSelected: handleSelected,
onChange: handleChange,
+ renderFieldPrefix,
+ canRemoveField,
+ isFieldReadOnly,
}: {
readonly query: SpecifyResource;
readonly recordSet?: SpecifyResource;
readonly forceCollection: number | undefined;
readonly isEmbedded?: boolean;
readonly autoRun?: boolean;
+ readonly defaultBasicView?: boolean;
readonly onSelected?: (selected: RA) => void;
readonly onChange?: (props: {
readonly fields: RA>;
@@ -96,6 +114,9 @@ function Wrapped({
readonly searchSynonymy: boolean | null;
readonly isSeries: boolean | null;
}) => void;
+ readonly renderFieldPrefix?: (field: QueryField, line: number) => JSX.Element;
+ readonly canRemoveField?: (field: QueryField, line: number) => boolean;
+ readonly isFieldReadOnly?: (field: QueryField, line: number) => boolean;
}): JSX.Element {
const [query, setQuery] = useResource(queryResource);
useErrorContext('query', query);
@@ -121,6 +142,16 @@ function Wrapped({
);
const [state, dispatch] = React.useReducer(reducer, pendingState);
+ const formatSelectionRequestId = React.useRef(0);
+ const [formatSelectionRequest, setFormatSelectionRequest] = React.useState<{
+ readonly fieldId: number;
+ readonly requestId: number;
+ }>();
+ const openFormatSelection = (fieldId: number): void =>
+ setFormatSelectionRequest({
+ fieldId,
+ requestId: ++formatSelectionRequestId.current,
+ });
const initialFields = React.useRef('');
@@ -128,6 +159,7 @@ function Wrapped({
React.useEffect(() => {
const initialState = buildInitialState();
+ setFormatSelectionRequest(undefined);
dispatch({
type: 'ResetStateAction',
state: initialState,
@@ -183,13 +215,67 @@ function Wrapped({
queryText.queryUnloadProtect()
);
- const handleAddField = (mappingPath = state.mappingView): void =>
+ const [formatters] = usePromise(fetchFormatters, false);
+ const availableFormats = (mappingPath: MappingPath): RA => {
+ const type = queryFieldFormatType(mappingPath);
+ if (type === undefined || formatters === undefined) return [];
+ const tableName = QueryFieldSpec.fromPath(state.baseTableName, mappingPath)
+ .table.name;
+ const definitions =
+ type === 'formatter'
+ ? formatters.formatters.map(({ table, name, isDefault }) => ({
+ table,
+ name,
+ isDefault,
+ }))
+ : formatters.aggregators.map(({ table, name, isDefault }) => ({
+ table,
+ name,
+ isDefault,
+ }));
+ return definitions
+ .filter(({ table }) => table?.name === tableName)
+ .sort(
+ ({ isDefault: left }, { isDefault: right }) =>
+ Number(right) - Number(left)
+ )
+ .map(({ name }) => name);
+ };
+ const usedFormats = (mappingPath: MappingPath): ReadonlySet =>
+ new Set(
+ state.fields
+ .filter(
+ ({ mappingPath: path }) =>
+ JSON.stringify(path) === JSON.stringify(mappingPath)
+ )
+ .map(
+ ({ filters }) =>
+ filters.find(({ type }) => type === 'any')?.fieldFormat ?? ''
+ )
+ );
+ const nextFormat = (mappingPath: MappingPath): string | undefined =>
+ nextAvailableQueryFormat(
+ state.fields,
+ mappingPath,
+ availableFormats(mappingPath)
+ );
+ const canAddMapping = (mappingPath: MappingPath): boolean =>
+ queryFieldFormatType(mappingPath) === undefined
+ ? !getMappedFields(state.fields, mappingPath.slice(0, -1)).includes(
+ mappingPath.at(-1)!
+ )
+ : usedFormats(mappingPath).size === 0 ||
+ nextFormat(mappingPath) !== undefined;
+
+ const handleAddField = (mappingPath = state.mappingView): void => {
+ const id = Math.max(-1, ...state.fields.map(({ id }) => id)) + 1;
+ const hasExistingFormat = usedFormats(mappingPath).size > 0;
dispatch({
type: 'ChangeFieldsAction',
fields: [
...state.fields,
{
- id: Math.max(-1, ...state.fields.map(({ id }) => id)) + 1,
+ id,
mappingPath,
sortType: undefined,
filters: [
@@ -198,12 +284,18 @@ function Wrapped({
startValue: '',
isNot: false,
isStrict: false,
+ fieldFormat: nextFormat(mappingPath),
},
],
isDisplay: true,
},
],
});
+ if (queryFieldFormatType(mappingPath) !== undefined) {
+ if (hasExistingFormat) openFormatSelection(id);
+ else setFormatSelectionRequest(undefined);
+ }
+ };
const serializeQueryFields = (
fields: typeof state.fields = state.fields
@@ -262,13 +354,16 @@ function Wrapped({
queryResource.isNew() ? 'create' : 'update'
);
const isReadOnly = React.useContext(ReadOnlyContext) || !hasAccess;
- const getMappedFieldsBind = getMappedFields.bind(undefined, state.fields);
+ const getMappedFieldsBind = (mappingPath: MappingPath): RA =>
+ getMappedFields(state.fields, mappingPath).filter(
+ (name) =>
+ queryFieldFormatType([...mappingPath, name]) === undefined ||
+ !canAddMapping([...mappingPath, name])
+ );
const mapButtonEnabled =
!isReadOnly &&
mappingPathIsComplete(state.mappingView) &&
- !getMappedFieldsBind(state.mappingView.slice(0, -1)).includes(
- state.mappingView.at(-1)!
- );
+ canAddMapping(state.mappingView);
// Scroll down to query results when pressed the "Query" button
const [form, setForm] = React.useState(null);
@@ -307,7 +402,7 @@ function Wrapped({
);
const resultsShown = state.queryRunCount !== 0;
- const [isBasic] = useQueryViewPref(query.id);
+ const [isBasic] = useQueryViewPref(query.id, defaultBasicView);
const resultsRef = React.useRef | undefined>(
undefined
@@ -384,6 +479,7 @@ function Wrapped({
dispatch({
type: 'ChangeOpenedElementAction',
@@ -563,12 +659,46 @@ function Wrapped({
onMappingChange={
isReadOnly
? undefined
- : (line, payload): void =>
+ : (line, payload): void => {
+ const path = mutateMappingPath({
+ ...payload,
+ mappingPath: state.fields[line].mappingPath,
+ ignoreToMany: true,
+ });
+ if (
+ mappingPathIsComplete(path) &&
+ queryFieldFormatType(path) !== undefined &&
+ JSON.stringify(path) !==
+ JSON.stringify(state.fields[line].mappingPath)
+ ) {
+ const format = nextFormat(path);
+ const hasExistingFormat = usedFormats(path).size > 0;
+ if (hasExistingFormat && format === undefined) return;
+ dispatch({
+ type: 'ChangeFieldAction',
+ line,
+ field: {
+ ...state.fields[line],
+ mappingPath: path,
+ filters: state.fields[line].filters.map(
+ (filter) =>
+ filter.type === 'any'
+ ? { ...filter, fieldFormat: format }
+ : filter
+ ),
+ },
+ });
+ if (hasExistingFormat)
+ openFormatSelection(state.fields[line].id);
+ else setFormatSelectionRequest(undefined);
+ return;
+ }
dispatch({
type: 'ChangeSelectElementValueAction',
line,
...payload,
- })
+ });
+ }
}
onOpen={(line, index): void =>
dispatch({
diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/Fields.test.ts b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/Fields.test.ts
new file mode 100644
index 00000000000..33d6eed5667
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/Fields.test.ts
@@ -0,0 +1,48 @@
+import { canMoveField, isReadOnlyFieldIndex } from '../Fields';
+import type { QueryField } from '../helpers';
+
+const fields: readonly QueryField[] = [0, 1, 2].map((id) => ({
+ id,
+ mappingPath: [],
+ sortType: undefined,
+ isDisplay: true,
+ filters: [],
+}));
+const isFieldReadOnly = (_field: QueryField, line: number): boolean =>
+ line === 1;
+
+describe('canMoveField', () => {
+ test('prevents moving a field across a read-only neighbor', () => {
+ expect(canMoveField(fields, 0, 'down', isFieldReadOnly)).toBe(false);
+ expect(canMoveField(fields, 2, 'up', isFieldReadOnly)).toBe(false);
+ });
+
+ test('prevents moving a read-only field', () => {
+ expect(canMoveField(fields, 1, 'up', isFieldReadOnly)).toBe(false);
+ expect(canMoveField(fields, 1, 'down', isFieldReadOnly)).toBe(false);
+ });
+
+ test('allows in-bounds moves between editable fields', () => {
+ expect(canMoveField(fields, 0, 'down', undefined)).toBe(true);
+ expect(canMoveField(fields, 2, 'up', undefined)).toBe(true);
+ });
+
+ test('prevents moves past collection boundaries', () => {
+ expect(canMoveField(fields, 0, 'up', undefined)).toBe(false);
+ expect(canMoveField(fields, 2, 'down', undefined)).toBe(false);
+ });
+});
+
+describe('isReadOnlyFieldIndex', () => {
+ test('identifies valid read-only target indexes', () => {
+ expect(isReadOnlyFieldIndex(fields, 1, isFieldReadOnly)).toBe(true);
+ expect(isReadOnlyFieldIndex(fields, 0, isFieldReadOnly)).toBe(false);
+ });
+
+ test('rejects indexes outside the field collection', () => {
+ expect(isReadOnlyFieldIndex(fields, -1, isFieldReadOnly)).toBe(false);
+ expect(isReadOnlyFieldIndex(fields, fields.length, isFieldReadOnly)).toBe(
+ false
+ );
+ });
+});
diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/Formatter.test.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/Formatter.test.tsx
new file mode 100644
index 00000000000..ff087040681
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/Formatter.test.tsx
@@ -0,0 +1,157 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import React from 'react';
+
+import { requireContext } from '../../../tests/helpers';
+import { queryText } from '../../../localization/query';
+import { QueryFieldRecordFormatter } from '../Formatter';
+
+jest.mock('../../Formatters/formatters', () => ({
+ fetchFormatters: Promise.resolve({
+ formatters: [
+ {
+ table: { name: 'Agent' },
+ name: 'Default',
+ title: 'Default',
+ isDefault: true,
+ },
+ {
+ table: { name: 'Agent' },
+ name: 'Alternate',
+ title: 'Alternate',
+ isDefault: false,
+ },
+ {
+ table: { name: 'Address' },
+ name: 'Only',
+ title: 'Only',
+ isDefault: true,
+ },
+ ],
+ aggregators: [
+ {
+ table: { name: 'Agent' },
+ name: 'Default',
+ title: 'Default',
+ isDefault: true,
+ },
+ {
+ table: { name: 'Agent' },
+ name: 'Alternate',
+ title: 'Alternate',
+ isDefault: false,
+ },
+ {
+ table: { name: 'Collector' },
+ name: 'Collector',
+ title: 'Collector',
+ isDefault: true,
+ },
+ {
+ table: { name: 'Collector' },
+ name: 'Collector Identifiers',
+ title: 'Collector Identifiers',
+ isDefault: false,
+ },
+ ],
+ }),
+}));
+
+requireContext();
+
+test('recognizes the backstop aggregation in a DwC-A mapping', async () => {
+ render(
+
+ );
+
+ fireEvent.click(
+ await screen.findByRole('button', { name: queryText.chooseFormatter() })
+ );
+ expect(
+ screen.getByRole('combobox', { name: queryText.chooseFormatter() })
+ ).toHaveValue('Collector Identifiers');
+ expect(
+ screen.queryByRole('option', { name: /current, invalid value/i })
+ ).not.toBeInTheDocument();
+});
+
+test.each(['formatter', 'aggregator'] as const)(
+ 'opens the %s selection when a newly added field has multiple choices',
+ async (type) => {
+ const props = {
+ type,
+ tableName: 'Agent' as const,
+ formatter: 'Alternate',
+ usedFormats: new Set(['Default']),
+ onChange: jest.fn(),
+ };
+ const { rerender } = render();
+
+ expect(
+ await screen.findByRole('button', { name: queryText.chooseFormatter() })
+ ).toBeInTheDocument();
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
+
+ rerender(
+
+ );
+ expect(
+ await screen.findByRole('combobox', { name: queryText.chooseFormatter() })
+ ).toHaveValue('Alternate');
+
+ expect(
+ screen.getByRole('option', { name: /Alternate/ })
+ ).toBeInTheDocument();
+ }
+);
+
+test('does not show a format selection when there is only one choice', async () => {
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: queryText.chooseFormatter() })
+ ).not.toBeInTheDocument();
+});
+
+test.each(['formatter', 'aggregator'] as const)(
+ 'keeps the first %s selection closed and accepts an empty default format',
+ async (type) => {
+ render(
+
+ );
+
+ const button = await screen.findByRole('button', {
+ name: queryText.chooseFormatter(),
+ });
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
+
+ fireEvent.click(button);
+ expect(
+ screen.getByRole('combobox', { name: queryText.chooseFormatter() })
+ ).toHaveValue('');
+ expect(
+ screen.queryByText(queryText.invalidPicklistValue({ value: '' }))
+ ).not.toBeInTheDocument();
+ }
+);
diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/ResultsHeadings.test.ts b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/ResultsHeadings.test.ts
new file mode 100644
index 00000000000..01c2b0a54ba
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/ResultsHeadings.test.ts
@@ -0,0 +1,109 @@
+import { requireContext } from '../../../tests/helpers';
+import {
+ formattedEntry,
+ formatToManyIndex,
+} from '../../WbPlanView/mappingHelpers';
+import { generateMappingPathPreview } from '../../WbPlanView/mappingPreview';
+import { QueryFieldSpec } from '../fieldSpec';
+import type { QueryField } from '../helpers';
+import { getResultHeaderFormatSuffix } from '../Results';
+
+requireContext();
+
+const collectorPath = [
+ 'collectingEvent',
+ 'collectors',
+ formatToManyIndex(1),
+ formattedEntry,
+];
+
+const makeField = (
+ mappingPath: string[],
+ fieldFormat?: string
+): QueryField => ({
+ id: 0,
+ mappingPath,
+ sortType: undefined,
+ isDisplay: true,
+ filters: [
+ {
+ type: 'any',
+ startValue: '',
+ isNot: false,
+ isStrict: false,
+ fieldFormat,
+ },
+ ],
+});
+
+const formatters = {
+ formatters: [
+ { table: { name: 'CollectionObject' }, name: 'Default', isDefault: true },
+ { table: { name: 'CollectionObject' }, name: 'Other', isDefault: false },
+ ],
+ aggregators: [
+ { table: { name: 'Collector' }, name: 'Collector', isDefault: true },
+ {
+ table: { name: 'Collector' },
+ name: 'Collector Identifiers',
+ isDefault: false,
+ },
+ ],
+};
+
+test('names an additional visible aggregation while keeping the default heading plain', () => {
+ const original = makeField(collectorPath, 'Collector');
+ const additional = makeField(collectorPath, 'Collector Identifiers');
+ const fields = [original, additional];
+ const preview = generateMappingPathPreview(
+ 'CollectionObject',
+ QueryFieldSpec.fromPath('CollectionObject', collectorPath).toMappingPath()
+ );
+
+ expect(preview).toBe('Collectors');
+ expect(
+ `${preview}${getResultHeaderFormatSuffix(original, fields, 'Collector', formatters) ?? ''}`
+ ).toBe('Collectors');
+ expect(
+ `${preview}${getResultHeaderFormatSuffix(additional, fields, 'Collector', formatters)}`
+ ).toBe('Collectors (Collector Identifiers)');
+ expect(
+ getResultHeaderFormatSuffix(
+ additional,
+ [additional],
+ 'Collector',
+ formatters
+ )
+ ).toBeUndefined();
+});
+
+test('uses the same rule for formatted fields and ignores unrelated paths', () => {
+ const path = [formattedEntry];
+ const defaultField = makeField(path);
+ const additional = makeField(path, 'Other');
+
+ expect(
+ getResultHeaderFormatSuffix(
+ additional,
+ [defaultField, additional],
+ 'CollectionObject',
+ formatters
+ )
+ ).toBe(' (Other)');
+ expect(
+ getResultHeaderFormatSuffix(
+ additional,
+ [makeField(collectorPath), additional],
+ 'CollectionObject',
+ formatters
+ )
+ ).toBeUndefined();
+ expect(
+ getResultHeaderFormatSuffix(
+ makeField(['catalogNumber']),
+ [defaultField, additional],
+ 'CollectionObject',
+ formatters
+ )
+ ).toBeUndefined();
+});
diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/multipleFormats.test.ts b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/multipleFormats.test.ts
new file mode 100644
index 00000000000..441a9916045
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/multipleFormats.test.ts
@@ -0,0 +1,97 @@
+import { requireContext } from '../../../tests/helpers';
+import {
+ formattedEntry,
+ formatToManyIndex,
+} from '../../WbPlanView/mappingHelpers';
+import type { QueryField } from '../helpers';
+import {
+ nextAvailableQueryFormat,
+ parseQueryFields,
+ queryFieldFormatType,
+ unParseQueryFields,
+} from '../helpers';
+
+requireContext();
+
+const path = [formattedEntry];
+const makeField = (format?: string): QueryField => ({
+ id: 0,
+ mappingPath: path,
+ sortType: undefined,
+ isDisplay: true,
+ filters: [
+ {
+ type: 'any',
+ startValue: '',
+ isNot: false,
+ isStrict: false,
+ fieldFormat: format,
+ },
+ ],
+});
+
+test('offers each formatted type once on the same query path', () => {
+ const formats = ['Default', 'Alternate'];
+ expect(nextAvailableQueryFormat([], path, formats)).toBeUndefined();
+ expect(nextAvailableQueryFormat([makeField()], path, formats)).toBe(
+ 'Alternate'
+ );
+ expect(
+ nextAvailableQueryFormat(
+ [makeField(), makeField('Alternate')],
+ path,
+ formats
+ )
+ ).toBeUndefined();
+ expect(nextAvailableQueryFormat([makeField('Default')], path, formats)).toBe(
+ 'Alternate'
+ );
+});
+
+test('identifies aggregated relationships separately from formatted relationships', () => {
+ const aggregatedPath = ['preparations', formatToManyIndex(1), formattedEntry];
+ expect(queryFieldFormatType(path)).toBe('formatter');
+ expect(queryFieldFormatType(aggregatedPath)).toBe('aggregator');
+ expect(
+ nextAvailableQueryFormat([makeField('Default')], aggregatedPath, [
+ 'Default',
+ ])
+ ).toBeUndefined();
+});
+
+test('keeps distinct formatted columns when serializing and parsing', () => {
+ const fields = [makeField('Default'), makeField('Alternate')];
+ const serialized = unParseQueryFields('CollectionObject', fields);
+ expect(serialized).toHaveLength(2);
+ expect(
+ parseQueryFields(serialized).map(({ filters }) => filters[0]?.fieldFormat)
+ ).toEqual(['Default', 'Alternate']);
+
+ const aggregatedPath = ['preparations', formatToManyIndex(1), formattedEntry];
+ const aggregated = unParseQueryFields('CollectionObject', [
+ { ...makeField('Default'), mappingPath: aggregatedPath },
+ { ...makeField('Alternate'), mappingPath: aggregatedPath },
+ ]);
+ expect(aggregated).toHaveLength(2);
+ expect(
+ parseQueryFields(aggregated).map(({ filters }) => filters[0]?.fieldFormat)
+ ).toEqual(['Default', 'Alternate']);
+});
+
+test('keeps the first formatted or aggregated column on the implicit default', () => {
+ const aggregatedPath = ['preparations', formatToManyIndex(1), formattedEntry];
+ for (const mappingPath of [path, aggregatedPath]) {
+ const firstFormat = nextAvailableQueryFormat([], mappingPath, [
+ 'Default',
+ 'Alternate',
+ ]);
+ expect(firstFormat).toBeUndefined();
+ const first = { ...makeField(firstFormat), mappingPath };
+ expect(
+ unParseQueryFields('CollectionObject', [first])[0]?.formatName
+ ).toBeUndefined();
+ expect(
+ nextAvailableQueryFormat([first], mappingPath, ['Default', 'Alternate'])
+ ).toBe('Alternate');
+ }
+});
diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/helpers.ts b/specifyweb/frontend/js_src/lib/components/QueryBuilder/helpers.ts
index aa9736df03b..b6a00727c30 100644
--- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/helpers.ts
+++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/helpers.ts
@@ -8,6 +8,7 @@ import type { SerializedResource } from '../DataModel/helperTypes';
import type { SpecifyResource } from '../DataModel/legacyTypes';
import { serializeResource } from '../DataModel/serializers';
import { genericTables, getTable, tables } from '../DataModel/tables';
+import { schema } from '../DataModel/schema';
import type { SpQuery, SpQueryField, Tables } from '../DataModel/types';
import { error } from '../Errors/assert';
import { queryMappingLocalityColumns } from '../Leaflet/config';
@@ -40,6 +41,10 @@ export type QueryField = {
* query lines move
*/
readonly id: number;
+ /** Index of the corresponding SpQueryField in the serialized query. */
+ readonly sourceIndex?: number;
+ /** Stable identity of the corresponding serialized SpQueryField. */
+ readonly sourceStringId?: string;
readonly mappingPath: MappingPath;
readonly sortType: SortTypes;
readonly isDisplay: boolean;
@@ -66,67 +71,111 @@ export function parseQueryFields(
): RA {
return group(
Array.from(queryFields)
- .sort(sortFunction(({ position }) => position))
- .map(({ isNot, isDisplay, isStrict, ...field }, index) => {
- const fieldSpec = QueryFieldSpec.fromStringId(
- field.stringId,
- field.isRelFld ?? false
- );
+ .map((field, sourceIndex) => ({ field, sourceIndex }))
+ .sort(sortFunction(({ field: { position } }) => position))
+ .map(
+ (
+ { field: { isNot, isDisplay, isStrict, ...field }, sourceIndex },
+ index
+ ) => {
+ const fieldSpec = QueryFieldSpec.fromStringId(
+ field.stringId,
+ field.isRelFld ?? false
+ );
- /*
- * SpQueryField.startValue containing fullDate may be in different formats
- * See https://github.com/specify/specify7/issues/1348
- */
- const startValue =
- typeof field.startValue === 'string' &&
- fieldSpec.datePart === 'fullDate' &&
- !field.startValue.includes(today)
- ? field.startValue
- .split(',')
- .map((value) =>
- parseValue(
- parserFromType('java.sql.Timestamp'),
- undefined,
- value
+ /*
+ * SpQueryField.startValue containing fullDate may be in different formats
+ * See https://github.com/specify/specify7/issues/1348
+ */
+ const startValue =
+ typeof field.startValue === 'string' &&
+ fieldSpec.datePart === 'fullDate' &&
+ !field.startValue.includes(today)
+ ? field.startValue
+ .split(',')
+ .map((value) =>
+ parseValue(
+ parserFromType('java.sql.Timestamp'),
+ undefined,
+ value
+ )
+ )
+ .map((parsed) =>
+ parsed?.isValid
+ ? (parsed.parsed as string)
+ : (field.startValue ?? '')
)
- )
- .map((parsed) =>
- parsed?.isValid
- ? (parsed.parsed as string)
- : (field.startValue ?? '')
- )
- .join(',')
- : field.startValue;
+ .join(',')
+ : field.startValue;
- const mappingPath = fieldSpec.toMappingPath();
- return [
- mappingPathToString(mappingPath),
- {
- id: index,
- mappingPath,
- sortType: sortTypes[field.sortType],
- filter: {
- type: defined(
- Object.entries(queryFieldFilterSpecs).find(
- ([_, { id }]) => id === field.operStart
- ),
- `Unknown SpQueryField.operStart value: ${field.operStart}`
- )[KEY],
- fieldFormat: field.formatName ?? undefined,
- isNot,
- isStrict,
- startValue,
+ const mappingPath = fieldSpec.toMappingPath();
+ return [
+ `${mappingPathToString(mappingPath)}${
+ queryFieldFormatType(mappingPath) !== undefined
+ ? `:${field.formatName ?? ''}`
+ : ''
+ }`,
+ {
+ id: index,
+ sourceIndex,
+ sourceStringId: field.stringId,
+ mappingPath,
+ sortType: sortTypes[field.sortType],
+ filter: {
+ type: defined(
+ Object.entries(queryFieldFilterSpecs).find(
+ ([_, { id }]) => id === field.operStart
+ ),
+ `Unknown SpQueryField.operStart value: ${field.operStart}`
+ )[KEY],
+ fieldFormat: field.formatName ?? undefined,
+ isNot,
+ isStrict,
+ startValue,
+ },
+ isDisplay,
},
- isDisplay,
- },
- ] as const;
- })
+ ] as const;
+ }
+ )
).map(([_mappingPath, groupedFields]) => ({
...removeKey(groupedFields[0], 'filter'),
filters: groupedFields.map(({ filter }) => filter),
}));
}
+/** Relationship columns with the same path are distinct by their format. */
+export const queryFieldFormatType = (
+ mappingPath: MappingPath
+): 'aggregator' | 'formatter' | undefined =>
+ mappingPath.at(-1) === formattedEntry
+ ? mappingPath.at(-2) === `${schema.referenceSymbol}1`
+ ? 'aggregator'
+ : 'formatter'
+ : undefined;
+
+/** The blank legacy selection denotes the table's default format. */
+export function nextAvailableQueryFormat(
+ fields: RA,
+ mappingPath: MappingPath,
+ formats: RA
+): string | undefined {
+ const matchingFields = fields.filter(
+ ({ mappingPath: path }) =>
+ mappingPathToString(path) === mappingPathToString(mappingPath)
+ );
+ if (matchingFields.length === 0) return undefined;
+ const used = new Set(
+ matchingFields.map(
+ ({ filters }) =>
+ filters.find(({ type }) => type === 'any')?.fieldFormat ?? ''
+ )
+ );
+ return formats.find(
+ (name, index) => !used.has(name) && !(index === 0 && used.has(''))
+ );
+}
+
/**
* Values for query fields with this ID are returned from the back-end, but
* not shown in the results. This is used if field was added to a query
diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Alignment.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Alignment.tsx
new file mode 100644
index 00000000000..661c0e294cd
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Alignment.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+
+import { schemaText } from '../../localization/schema';
+import type { RA } from '../../utils/types';
+import { localized } from '../../utils/types';
+import { Button } from '../Atoms/Button';
+import type { LiteralField, Relationship } from '../DataModel/specifyField';
+import {
+ formatTermExampleText,
+ TermInfoDialog,
+} from '../DwcaDefinition/DwcaDefinition';
+import gbifCores from '../DwcaDefinition/data/gbifCores.json';
+import { getCoreTermPatterns } from '../DwcaDefinition/data/coreTermPatterns';
+import gbifExtensions from '../DwcaDefinition/data/gbifExtensions.json';
+import { getSystemInfo } from '../InitialContext/systemInfo';
+
+type Vocabulary = (typeof gbifCores)[number] | (typeof gbifExtensions)[number];
+type VocabularyField = Vocabulary['fields'][number];
+
+export type FieldAlignment = {
+ readonly term: VocabularyField;
+ readonly vocabulary: Vocabulary;
+};
+
+const vocabularies: readonly Vocabulary[] = [...gbifCores, ...gbifExtensions];
+
+const normalizeFieldIdentity = (field: LiteralField | Relationship): string =>
+ `${field.table.name}.${field.name}`.toLowerCase();
+
+export function getFieldAlignments(
+ field: LiteralField | Relationship
+): RA {
+ const fieldIdentity = normalizeFieldIdentity(field);
+ const alignments: FieldAlignment[] = [];
+ const seen = new Set();
+
+ Object.entries(getCoreTermPatterns(getSystemInfo()?.discipline_type)).forEach(
+ ([termName, patterns]) => {
+ if (!patterns.some((pattern) => fieldIdentity.endsWith(pattern))) return;
+ vocabularies.forEach((vocabulary) => {
+ const term = vocabulary.fields.find(({ name }) => name === termName);
+ if (term === undefined) return;
+ const key = `${vocabulary.rowType}:${term.name}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ alignments.push({ term, vocabulary });
+ });
+ }
+ );
+
+ return alignments;
+}
+
+export function SchemaConfigAlignment({
+ field,
+}: {
+ readonly field: LiteralField | Relationship;
+}): JSX.Element {
+ const [selectedAlignment, setSelectedAlignment] = React.useState<
+ FieldAlignment | undefined
+ >(undefined);
+ const alignments = React.useMemo(() => getFieldAlignments(field), [field]);
+ const alignmentsByVocabulary = React.useMemo(() => {
+ const grouped = new Map<
+ string,
+ { vocabulary: Vocabulary; terms: RA }
+ >();
+ alignments.forEach(({ term, vocabulary }) => {
+ const key = vocabulary.rowType;
+ const group = grouped.get(key);
+ if (group === undefined) {
+ grouped.set(key, { vocabulary, terms: [term] });
+ } else {
+ grouped.set(key, { ...group, terms: [...group.terms, term] });
+ }
+ });
+ return [...grouped.values()];
+ }, [alignments]);
+ return (
+
+ );
+}
diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx
index cfb02381491..2b146f61c5a 100644
--- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx
+++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx
@@ -4,6 +4,7 @@ import type { LocalizedString } from 'typesafe-i18n';
import { useId } from '../../hooks/useId';
import { commonText } from '../../localization/common';
+import { headerText } from '../../localization/header';
import { resourcesText } from '../../localization/resources';
import { schemaText } from '../../localization/schema';
import { appResourceIds } from '../../utils/ajax/helpers';
@@ -28,6 +29,7 @@ import { getItemType } from './helpers';
import type { ItemType } from './index';
import type { SchemaData } from './schemaData';
import { fetchSchemaPickLists } from './schemaData';
+import { SchemaConfigAlignment } from './Alignment';
export function SchemaConfigFormat({
schemaData,
@@ -161,6 +163,18 @@ export function SchemaConfigFormat({
systemPickLists,
}}
/>
+
+ {schemaText.dataModelAlignment()}
+
+
+
);
}
diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Alignment.test.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Alignment.test.ts
new file mode 100644
index 00000000000..642eb0133f2
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Alignment.test.ts
@@ -0,0 +1,90 @@
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+
+import type { LiteralField } from '../../DataModel/specifyField';
+import * as systemInfo from '../../InitialContext/systemInfo';
+import { getFieldAlignments, SchemaConfigAlignment } from '../Alignment';
+
+const field = (table: string, name: string): LiteralField =>
+ ({ table: { name: table }, name }) as unknown as LiteralField;
+
+describe('data model vocabulary alignments', () => {
+ afterEach(() => jest.restoreAllMocks());
+
+ test('shows each term above its description', () => {
+ const html = renderToStaticMarkup(
+ createElement(SchemaConfigAlignment, {
+ field: field('CollectingEvent', 'StartDate'),
+ })
+ );
+ const item = new DOMParser()
+ .parseFromString(html, 'text/html')
+ .querySelector('li');
+ expect(item?.firstElementChild?.tagName).toBe('BUTTON');
+ expect(item?.lastElementChild?.tagName).toBe('P');
+ expect(item?.lastElementChild?.textContent).not.toBe('');
+ });
+
+ test('matches field identities case-insensitively', () => {
+ const [alignment] = getFieldAlignments(
+ field('CollectingEvent', 'StartDate')
+ );
+
+ expect(alignment?.term.name).toBe('http://rs.tdwg.org/dwc/terms/eventDate');
+ });
+
+ test('includes discipline-specific patterns', () => {
+ jest.spyOn(systemInfo, 'getSystemInfo').mockReturnValue({
+ discipline_type: 'fish',
+ } as ReturnType);
+
+ expect(
+ getFieldAlignments(field('CollectingEventAttribute', 'Text9')).some(
+ ({ term }) => term.name === 'http://rs.tdwg.org/dwc/terms/habitat'
+ )
+ ).toBe(true);
+ });
+
+ test('does not match patterns inside longer field names', () => {
+ const alignments = getFieldAlignments(
+ field('CollectingEvent', 'StartDateNumericDay')
+ );
+
+ expect(
+ alignments.some(
+ ({ term }) => term.name === 'http://rs.tdwg.org/dwc/terms/eventDate'
+ )
+ ).toBe(false);
+ });
+
+ test('returns all terms matching one field pattern', () => {
+ const alignments = getFieldAlignments(
+ field('CollectionObjectAttribute', 'CountAmt')
+ );
+
+ expect(alignments.map(({ term }) => term.name)).toEqual(
+ expect.arrayContaining([
+ 'http://rs.tdwg.org/dwc/terms/individualCount',
+ 'http://rs.tdwg.org/dwc/terms/organismQuantity',
+ ])
+ );
+ });
+
+ test('resolves alignments from GBIF extension catalogs', () => {
+ const alignments = getFieldAlignments(field('CollectionObject', 'GUID'));
+
+ expect(
+ alignments.some(
+ ({ term, vocabulary }) =>
+ term.name === 'http://rs.tdwg.org/dwc/terms/occurrenceID' &&
+ vocabulary.rowType !== 'http://rs.tdwg.org/dwc/terms/Occurrence'
+ )
+ ).toBe(true);
+ });
+
+ test('returns no alignments for an unknown field', () => {
+ expect(
+ getFieldAlignments(field('CollectionObject', 'UnknownField'))
+ ).toEqual([]);
+ });
+});
diff --git a/specifyweb/frontend/js_src/lib/localization/common.ts b/specifyweb/frontend/js_src/lib/localization/common.ts
index 269a0ce7894..adf684a6dad 100644
--- a/specifyweb/frontend/js_src/lib/localization/common.ts
+++ b/specifyweb/frontend/js_src/lib/localization/common.ts
@@ -90,6 +90,12 @@ export const commonText = createDictionary({
'hr-hr': 'Zatvoriti',
nb: 'Lukk',
},
+ showDetails: {
+ 'en-us': 'Show details',
+ },
+ hideDetails: {
+ 'en-us': 'Hide details',
+ },
configureExpressSearch: {
'en-us': 'Configure Express Search',
'ru-ru': 'Настройка экспресс-поиска',
diff --git a/specifyweb/frontend/js_src/lib/localization/dwca.ts b/specifyweb/frontend/js_src/lib/localization/dwca.ts
new file mode 100644
index 00000000000..e2cec11c1ec
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/localization/dwca.ts
@@ -0,0 +1,143 @@
+/**
+ * Localization strings used by the Darwin Core editor
+ *
+ * @module
+ */
+
+import { createDictionary } from './utils';
+
+// Refer to "Guidelines for Programmers" in ./README.md before editing this file
+
+export const dwcaText = createDictionary({
+ dwcaChangeCoreConfirmation: {
+ 'en-us': 'Change the Darwin Core type and reset mappings?',
+ },
+ dwcaChangeCoreConfirmationDescription: {
+ 'en-us':
+ 'Changing the core changes the query base table and will erase the current core and extension mappings.',
+ },
+ dwcaReplaceMappingConfirmation: {
+ 'en-us': 'Replace the current mapping?',
+ },
+ dwcaReplaceMappingConfirmationDescription: {
+ 'en-us':
+ 'The selected template or query will replace the current field mappings and filters. A template may also replace the file name.',
+ },
+ dwcaReplaceMapping: {
+ 'en-us': 'Replace mapping',
+ },
+ dwcaDefinition: {
+ 'en-us': 'Darwin Core definition',
+ },
+ dwcaInvalidDefinition: {
+ 'en-us':
+ 'This definition contains invalid XML. Please fix it in the XML editor before using the visual editor.',
+ },
+ dwcaOpenXmlEditor: {
+ 'en-us': 'Open XML Editor',
+ },
+ dwcaMismatchedExtensionContext: {
+ 'en-us': 'An extension query uses a different context table',
+ },
+ dwcaMismatchedExtensionContextDescription: {
+ 'en-us':
+ 'Remove the extension below, or correct its Table ID in the XML editor.',
+ },
+ dwcaTerm: {
+ 'en-us': 'Term',
+ },
+ dwcaVocabulary: {
+ 'en-us': 'Vocabulary',
+ },
+ dwcaGroup: {
+ 'en-us': 'Group',
+ },
+ dwcaNoGroupSpecified: {
+ 'en-us': 'No group specified.',
+ },
+ dwcaUnspecifiedGroup: {
+ 'en-us': 'Unspecified group',
+ },
+ dwcaIri: {
+ 'en-us': 'IRI',
+ },
+ dwcaRequired: {
+ 'en-us': 'Required',
+ },
+ dwcaDescription: {
+ 'en-us': 'Description',
+ },
+ dwcaExamples: {
+ 'en-us': 'Examples',
+ },
+ dwcaNoDescriptionAvailable: {
+ 'en-us': 'No description is available.',
+ },
+ dwcaExtension: {
+ 'en-us': 'Extension',
+ },
+ dwcaRowType: {
+ 'en-us': 'Row type',
+ },
+ dwcaFileNamesMustBeUnique: {
+ 'en-us': 'File names must be unique.',
+ },
+ dwcaStartFromScratch: {
+ 'en-us': 'Start from scratch',
+ },
+ dwcaChooseExtension: {
+ 'en-us': 'Choose an extension',
+ },
+ dwcaChooseTerm: {
+ 'en-us': 'Choose a term',
+ },
+ dwcaChooseRowType: {
+ 'en-us': 'Choose a row type',
+ },
+ dwcaChooseQuery: {
+ 'en-us': 'Choose a query',
+ },
+ dwcaNoDefaultOrSaved: {
+ 'en-us':
+ 'No {default:string} or saved {query:string} is available for this {extension:string}.',
+ },
+ dwcaChooseTemplateOrQuery: {
+ 'en-us': 'Choose Template or Query',
+ },
+ dwcaMappingSettings: {
+ 'en-us': 'Mapping Settings',
+ },
+ dwcaCheckFileNameInSettings: {
+ 'en-us': 'Check file name in settings',
+ },
+ dwcaRequiredTermsUnmapped: {
+ 'en-us': '{count:number} required terms unmapped',
+ },
+ dwcaRequiredTermUnmapped: {
+ 'en-us': '1 required term unmapped',
+ },
+ dwcaTemplates: {
+ 'en-us': 'Templates',
+ },
+ dwcaSavedQueries: {
+ 'en-us': 'Saved Queries',
+ },
+ dwcaAddField: {
+ 'en-us': 'Add a {query:string} field to begin mapping.',
+ },
+ dwcaRequiredTerm: {
+ 'en-us': '{title:string} ({name:string})',
+ },
+ dwcaTermWithQualifier: {
+ 'en-us': '{title:string} ({qualifier:string})',
+ },
+ dwcaMapRequiredTerms: {
+ 'en-us': 'Map all required terms before saving: {terms:string}',
+ },
+ dwcaMapRequiredTermsHeading: {
+ 'en-us': 'Map all required terms before saving:',
+ },
+ dwcaCore: {
+ 'en-us': 'Core',
+ },
+} as const);
diff --git a/specifyweb/frontend/js_src/lib/localization/resources.ts b/specifyweb/frontend/js_src/lib/localization/resources.ts
index ca3f90b32ee..faf1f7cbd4d 100644
--- a/specifyweb/frontend/js_src/lib/localization/resources.ts
+++ b/specifyweb/frontend/js_src/lib/localization/resources.ts
@@ -808,24 +808,11 @@ export const resourcesText = createDictionary({
'Ovaj format će se koristiti samo ako je vrijednost polja uvjeta jednaka ovom uvjetu i nije null.',
nb: 'Dette formatet vil bare bli brukt hvis betingelsene er oppfylt og ikke er null.',
},
- wrongScopeWarning: {
- 'en-us':
- "This resource belongs to a different collection/discipline than the one you are currently in. It's recommended to switch collection before editing this resource",
- 'de-ch':
- 'Diese Ressource gehört zu einer anderen Sammlung/Disziplin als der, in der Sie sich aktuell befinden. Es wird empfohlen, die Sammlung zu wechseln, bevor Sie diese Ressource bearbeiten.',
- 'es-es':
- 'Este recurso pertenece a una colección/disciplina diferente a la que estás utilizando actualmente. Se recomienda cambiar de colección antes de editar este recurso.',
- 'fr-fr':
- 'Cette ressource appartient à une collection/discipline différente de celle dans laquelle vous vous trouvez actuellement. Il est recommandé de changer de collection avant de modifier cette ressource.',
- 'ru-ru':
- 'Этот ресурс относится к другой коллекции/дисциплине, нежели та, в которой вы сейчас находитесь. Рекомендуется переключиться на другую коллекцию перед редактированием этого ресурса.',
- 'uk-ua':
- 'Цей ресурс належить до іншої колекції/дисципліни, ніж та, у якій ви зараз перебуваєте. Рекомендовано змінити колекцію перед редагуванням цього ресурсу',
- 'pt-br':
- 'Este recurso pertence a uma coleção/disciplina diferente daquela em que você está atualmente. Recomenda-se trocar de coleção antes de editar este recurso.',
- 'hr-hr':
- 'Ovaj resurs pripada drugoj zbirci/disciplini od one u kojoj se trenutno nalazite. Preporučuje se promjena zbirke prije uređivanja ovog resursa.',
- nb: 'Denne ressursen tilhører en annen samling/disiplinen enn den du er i for øyeblikket. Det anbefales å bytte samling før du redigerer dette.',
+ wrongScopeWarningSummary: {
+ 'en-us': 'Different collection or discipline',
+ },
+ wrongScopeWarningDetails: {
+ 'en-us': 'Switch to that collection before editing this resource.',
},
thisFieldName: {
'en-us': 'This field name (for preview purposes only)',
diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts
index 64c3f064e4f..9897e7f70d0 100644
--- a/specifyweb/frontend/js_src/lib/localization/schema.ts
+++ b/specifyweb/frontend/js_src/lib/localization/schema.ts
@@ -546,6 +546,12 @@ export const schemaText = createDictionary({
'hr-hr': 'Format polja',
nb: 'Felt-format',
},
+ dataModelAlignment: {
+ 'en-us': 'Data Model Alignment',
+ },
+ noDataModelAlignment: {
+ 'en-us': 'No alignment found.',
+ },
uiFormattersForField: {
'en-us': 'Field Formatters for {fieldLabel:string}',
'de-ch': 'Feldformatierer für {fieldLabel:string}',
diff --git a/specifyweb/frontend/js_src/package-lock.json b/specifyweb/frontend/js_src/package-lock.json
index ede71fa2987..3519633b866 100644
--- a/specifyweb/frontend/js_src/package-lock.json
+++ b/specifyweb/frontend/js_src/package-lock.json
@@ -14328,6 +14328,23 @@
"node": "*"
}
},
+ "node_modules/minimatch/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/minimatch/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
diff --git a/specifyweb/frontend/js_src/package.json b/specifyweb/frontend/js_src/package.json
index c24d654dd7d..67ccb8dc974 100644
--- a/specifyweb/frontend/js_src/package.json
+++ b/specifyweb/frontend/js_src/package.json
@@ -17,6 +17,7 @@
},
"scripts": {
"//": "All of these should be executed from the js_src directory",
+ "dwca:catalog": "node --import tsx lib/components/DwcaDefinition/data/generateGbifCatalog.ts",
"localizationTests": "node --import ./lib/tests/jsdom.js --import ./lib/tests/register.js --import tsx lib/localization/__tests__/scanUsages.ts",
"pullFromWeblate": "node --import ./lib/tests/jsdom.js --import ./lib/tests/register.js --import tsx lib/localization/utils/pull.ts",
"schema:extract": "node --import ./lib/tests/jsdom.js --import ./lib/tests/register.js --import tsx lib/localization/schema-localization/extract.ts",
@@ -144,6 +145,12 @@
"@eslint/eslintrc": {
"minimatch": "3.1.4"
},
+ "minimatch@3.1.4": {
+ "brace-expansion": "2.0.2"
+ },
+ "minimatch@5.1.8": {
+ "brace-expansion": "2.0.2"
+ },
"glob@10.3.10": {
"minimatch": "9.0.9"
},