Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add business rule to validate Determination taxon with COType #5127

Merged
merged 16 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from 70 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
sharadsw marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { formsText } from '../../localization/forms';
import { resourcesText } from '../../localization/resources';
import type { BusinessRuleResult } from './businessRules';
import type { AnySchema, TableFields } from './helperTypes';
Expand All @@ -10,13 +11,15 @@ import {
updateLoanPrep,
} from './interactionBusinessRules';
import type { SpecifyResource } from './legacyTypes';
import { idFromUrl } from './resource';
import { setSaveBlockers } from './saveBlockers';
import type { Collection } from './specifyTable';
import { tables } from './tables';
import type {
Address,
BorrowMaterial,
CollectionObject,
CollectionObjectType,
Determination,
DNASequence,
LoanPreparation,
Expand Down Expand Up @@ -47,6 +50,7 @@ type MappedBusinessRuleDefs = {
};

const CURRENT_DETERMINATION_KEY = 'determination-isCurrent';
const DETERMINATION_TAXON_KEY = 'determination-taxon';

export const businessRuleDefs: MappedBusinessRuleDefs = {
Address: {
Expand Down Expand Up @@ -153,7 +157,7 @@ export const businessRuleDefs: MappedBusinessRuleDefs = {
fieldChecks: {
taxon: async (
determination: SpecifyResource<Determination>
): Promise<BusinessRuleResult> =>
): Promise<BusinessRuleResult | undefined> =>
determination
.rgetPromise('taxon', true)
.then((taxon: SpecifyResource<Taxon> | null) => {
Expand All @@ -165,6 +169,37 @@ export const businessRuleDefs: MappedBusinessRuleDefs = {
.then(async (accepted) =>
accepted === null ? taxon : getLastAccepted(accepted)
);

const collectionObject = determination.collection
?.related as SpecifyResource<CollectionObject>;
sharadsw marked this conversation as resolved.
Show resolved Hide resolved
collectionObject
.rgetPromise('collectionObjectType', true)
.then((coType: SpecifyResource<CollectionObjectType>) => {
sharadsw marked this conversation as resolved.
Show resolved Hide resolved
/*
* Have to set save blockers directly here to get this working.
* Since following code has to wait for above rgetPromise to resolve, returning a Promise<BusinessRuleResult> for validation here is too slow and
* does not get captured by business rules.
*/
if (
idFromUrl(coType.get('taxonTreeDef')) ===
idFromUrl(taxon?.get('definition') ?? '')
sharadsw marked this conversation as resolved.
Show resolved Hide resolved
) {
setSaveBlockers(
determination,
determination.specifyTable.field.taxon,
[],
DETERMINATION_TAXON_KEY
);
} else {
setSaveBlockers(
determination,
determination.specifyTable.field.taxon,
[formsText.invalidTree()],
DETERMINATION_TAXON_KEY
);
}
});

return taxon === null
? {
isValid: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,12 @@ export const ResourceBase = Backbone.Model.extend({
this.trigger('change', this);
return undefined;
}
/*
* Needed for taxonTreeDef on discipline because field.isVirtual equals false
*/
case 'one-to-one': {
return value;
}
}
if (!field.isVirtual)
softFail('Unhandled setting of relationship field', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,14 @@ import { getPref } from '../InitialContext/remotePrefs';
import { fetchPossibleRanks } from '../PickLists/TreeLevelPickList';
import { formatUrl } from '../Router/queryString';
import type { BusinessRuleResult } from './businessRules';
import type {
AnyTree,
FilterTablesByEndsWith,
TableFields,
} from './helperTypes';
import type { AnyTree, TableFields } from './helperTypes';
import type { SpecifyResource } from './legacyTypes';
import { idFromUrl } from './resource';
import type { Tables } from './types';

// eslint-disable-next-line unicorn/prevent-abbreviations
export type TreeDefItem<TREE extends AnyTree> =
FilterTablesByEndsWith<`${TREE['tableName']}TreeDefItem`>;
Tables[`${TREE['tableName']}TreeDefItem`];

export const initializeTreeRecord = (
resource: SpecifyResource<AnyTree>
Expand All @@ -36,7 +34,11 @@ export const treeBusinessRules = async (
const possibleRanks =
parentDefItem === undefined
? undefined
: await fetchPossibleRanks(resource, parentDefItem.get('rankId'));
: await fetchPossibleRanks(
resource,
parentDefItem.get('rankId'),
idFromUrl(parentDefItem.get('treeDef'))!
);

const doExpandSynonymActionsPref = getPref(
`sp7.allow_adding_child_to_synonymized_parent.${resource.specifyTable.name}`
Expand Down
1 change: 1 addition & 0 deletions specifyweb/frontend/js_src/lib/components/Forms/Save.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export function SaveButton<SCHEMA extends AnySchema = AnySchema>({

const ButtonComponent = saveBlocked ? Button.Danger : Button.Save;
const SubmitComponent = saveBlocked ? Submit.Danger : Submit.Save;

// Don't allow cloning the resource if it changed
const isChanged = saveRequired || externalSaveRequired;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ajax } from '../../utils/ajax';
import type { RA } from '../../utils/types';
import { tables } from '../DataModel/tables';
import {
getTreeDefinitionItems,
strictGetTreeDefinitionItems,
treeRanksPromise,
} from '../InitialContext/treeRanks';
import {
Expand Down Expand Up @@ -98,7 +98,8 @@ function useGenusRankId(): number | false | undefined {
async () =>
treeRanksPromise.then(
() =>
getTreeDefinitionItems('Taxon', false)!.find(
// REFACTOR: Narrow the TreeDefinition used to find the rankId of the Genus Rank
strictGetTreeDefinitionItems('Taxon', false, 'all').find(
(item) => (item.name || item.title)?.toLowerCase() === 'genus'
)?.rankId ?? false
),
Expand Down
147 changes: 89 additions & 58 deletions specifyweb/frontend/js_src/lib/components/InitialContext/treeRanks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,35 @@
* given discipline.
*/

import { ajax } from '../../utils/ajax';
import { getCache } from '../../utils/cache';
import { f } from '../../utils/functools';
import type { RA } from '../../utils/types';
import { defined } from '../../utils/types';
import {
caseInsensitiveHash,
sortFunction,
unCapitalize,
} from '../../utils/utils';
import { caseInsensitiveHash } from '../../utils/utils';
import type {
AnySchema,
AnyTree,
FilterTablesByEndsWith,
SerializedRecord,
SerializedResource,
} from '../DataModel/helperTypes';
import type { SpecifyResource } from '../DataModel/legacyTypes';
import { fetchContext as fetchDomain, schema } from '../DataModel/schema';
import { getDomainResource } from '../DataModel/scoping';
import { serializeResource } from '../DataModel/serializers';
import { genericTables } from '../DataModel/tables';
import type { Tables } from '../DataModel/types';

let treeDefinitions: {
readonly [TREE_NAME in AnyTree['tableName']]: {
readonly definition: SpecifyResource<Tables[`${TREE_NAME}TreeDef`]>;
readonly ranks: RA<SerializedResource<Tables[`${TREE_NAME}TreeDefItem`]>>;
};
} = undefined!;
export type TreeInformation = {
readonly [TREE_NAME in AnyTree['tableName']]: RA<{
readonly definition: SerializedResource<FilterTablesByEndsWith<'TreeDef'>>;
readonly ranks: RA<
SerializedResource<FilterTablesByEndsWith<'TreeDefItem'>>
>;
}>;
};

let treeDefinitions: TreeInformation = undefined!;

/*
* FEATURE: allow reordering trees
Expand All @@ -37,7 +40,6 @@ let treeDefinitions: {
const commonTrees = ['Geography', 'Storage', 'Taxon'] as const;
const treesForPaleo = ['GeologicTimePeriod', 'LithoStrat'] as const;
export const allTrees = [...commonTrees, ...treesForPaleo] as const;
const paleoDiscs = new Set(['paleobotany', 'invertpaleo', 'vertpaleo']);
/*
* Until discipline information is loaded, assume all trees are appropriate in
* this discipline
Expand All @@ -60,46 +62,36 @@ export const treeRanksPromise = Promise.all([
import('../Permissions').then(async ({ fetchContext }) => fetchContext),
import('../DataModel/tables').then(async ({ fetchContext }) => fetchContext),
fetchDomain,
])
.then(async ([{ hasTreeAccess, hasTablePermission }]) =>
hasTablePermission('Discipline', 'read')
? getDomainResource('discipline')
?.fetch()
.then((discipline) => {
if (!f.has(paleoDiscs, discipline?.get('type')))
disciplineTrees = commonTrees;
})
.then(async () =>
Promise.all(
disciplineTrees
.filter((treeName) => hasTreeAccess(treeName, 'read'))
.map(async (treeName) =>
getDomainResource(getTreeScope(treeName) as 'discipline')
?.rgetPromise(
`${unCapitalize(treeName) as 'geography'}TreeDef`
)
.then((treeDefinition) => {
const ranks = {
definition: treeDefinition,
ranks: Array.from(
treeDefinition.getDependentResource('treeDefItems')
?.models ?? [],
(definitionItem) => serializeResource(definitionItem)
).sort(sortFunction(({ rankId }) => rankId)),
};
]).then(async () =>
ajax<{
readonly [TREE_NAME in AnyTree['tableName']]: RA<{
readonly definition: SerializedRecord<Tables[`${TREE_NAME}TreeDef`]>;
readonly ranks: RA<SerializedRecord<Tables[`${TREE_NAME}TreeDefItem`]>>;
}>;
}>('/api/specify_trees/', {
headers: { Accept: 'application/json' },
}).then(({ data }) => {
const treeNames = new Set(
Object.keys(data).map((key) => key.toLowerCase())
);
disciplineTrees = allTrees.filter((treeName) =>
treeNames.has(treeName.toLowerCase())
);

return [treeName, ranks] as const;
})
)
)
)
: []
)
.then((ranks) => {
// @ts-expect-error
treeDefinitions = Object.fromEntries(ranks.filter(Boolean));
treeDefinitions = Object.fromEntries(
Object.entries(data).map(([treeName, information]) => [
treeName,
information.map(({ definition, ranks }) => ({
definition: serializeResource(
definition as SerializedRecord<FilterTablesByEndsWith<'TreeDef'>>
),
ranks: ranks.map((rank) => serializeResource(rank)),
})),
])
);
return treeDefinitions;
});
})
);

function getTreeScope(
treeName: AnyTree['tableName']
Expand All @@ -114,22 +106,61 @@ function getTreeScope(
);
}

export function getTreeDefinitions<TREE_NAME extends AnyTree['tableName']>(
tableName: TREE_NAME,
treeDefinitionId: number | 'all' | undefined = getCache(
'tree',
`definition${tableName}`
)
): TreeInformation[TREE_NAME] {
const specificTreeDefinitions = caseInsensitiveHash(
defined(treeDefinitions),
tableName
);

return typeof treeDefinitionId === 'number'
? specificTreeDefinitions.filter(
({ definition }) => definition.id === treeDefinitionId
)
: specificTreeDefinitions;
}

export function getTreeDefinitionItems<TREE_NAME extends AnyTree['tableName']>(
tableName: TREE_NAME,
includeRoot: boolean
): typeof treeDefinitions[TREE_NAME]['ranks'] | undefined {
const definition = caseInsensitiveHash(treeDefinitions, tableName);
return definition?.ranks.slice(includeRoot ? 0 : 1);
includeRoot: boolean,
treeDefinitionId: number | 'all' | undefined = getCache(
'tree',
`definition${tableName}`
)
): TreeInformation[TREE_NAME][number]['ranks'] | undefined {
const specificTreeDefinitions =
treeDefinitions === undefined
? undefined
: caseInsensitiveHash(treeDefinitions, tableName);

return specificTreeDefinitions === undefined
? undefined
: typeof treeDefinitionId === 'number'
? specificTreeDefinitions
.find(({ definition }) => definition.id === treeDefinitionId)
?.ranks.slice(includeRoot ? 0 : 1)
: specificTreeDefinitions.flatMap(({ ranks }) =>
ranks.slice(includeRoot ? 0 : 1)
);
}

export const strictGetTreeDefinitionItems = <
TREE_NAME extends AnyTree['tableName']
>(
tableName: TREE_NAME,
includeRoot: boolean
): typeof treeDefinitions[TREE_NAME]['ranks'] =>
includeRoot: boolean,
treeDefinitionId: number | 'all' | undefined = getCache(
'tree',
`definition${tableName}`
)
): TreeInformation[TREE_NAME][number]['ranks'] =>
defined(
getTreeDefinitionItems(tableName, includeRoot),
getTreeDefinitionItems(tableName, includeRoot, treeDefinitionId),
`Unable to get tree ranks for a ${tableName} table`
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ async function recursiveResourceResolve(
return [];
const tableRanks = strictGetTreeDefinitionItems(
treeTableName as 'Geography',
false
false,
'all'
);
const currentRank = defined(
tableRanks.find(({ rankId }) => rankId === resource.get('rankId')),
Expand Down
Loading
Loading