import { isCardinalityMultiple, EntityRawSchemaArray, EntitySchema, Schema, RelationDefinitionSchema, RelationDefinitionMatcher, isRelationTypeReversed, EntityRole, } from "@cubicweb/client"; import { Identifier, RaRecord } from "./types.js"; const RELATION_SEPARATOR = "__"; const REVERSE_INDICATOR = "reverse_"; /** * Type describing the input value of a relation */ export type CubicWebReferenceArrayInputValueType = { created?: Identifier[]; selected?: Identifier[]; removed?: Identifier[]; id: "temp_input"; }; /** * Gets a unique key representing the tuple: mode - subject - relation type - object * * Reverse operation from `getRelationFromKey`. * * @param relation The relation definition to get a key for * @param role Subject or object mroe * @returns A unique key representing the relation definition * * @category Relations Utilities */ export const getRelationKey = ( relation: Readonly, role: "subject" | "object" ) => { const type = `${relation.subject.type}${RELATION_SEPARATOR}${relation.type}${RELATION_SEPARATOR}${relation.object.type}`; return role === "subject" ? type : `${REVERSE_INDICATOR}${type}`; }; /** * Retrieves the relation definition schema from the given key. * * Reverse operation from `getRelationKey`. * * @param relationKey The relation key generated with `getRelationKey` * @param schema The CubicWeb schema * @returns The relation definitio schema represented by the given key in the schema * @throws Error if no relation could be found from the given key * * @category Relations Utilities */ export const getRelationFromKey = ( relationKey: string, schema: EntitySchema | Schema ) => { const role = isRelationTypeReversed(relationKey) ? "object" : "subject"; const types = role === "subject" ? relationKey : relationKey.replace(REVERSE_INDICATOR, ""); const typesArray = types.split(RELATION_SEPARATOR); // If there is no separator, we are working with an attribute let matchData: RelationDefinitionMatcher = {}; if (typesArray.length != 3) { matchData = { type: types, final: true, }; } else { matchData = { subject: typesArray[0], type: typesArray[1], object: typesArray[2], final: false, }; } const relationsMatched = schema.matchRelationDefinitions(matchData); if (relationsMatched.length > 0) { return relationsMatched[0]; } throw Error(`Could not find relation with data ${JSON.stringify(matchData)}`); }; /** * Checks the cardinality of the relation to detect if users can pick new elements to * add to the given relation. * * @param relation The relation to show a list for * @param mode Subject or object mode for the relation * @returns True if users can pick elements from the list, false otherwise * * @category Relations Utilities */ export const canPickFromList = ( relation: RelationDefinitionSchema, mode: "object" | "subject" ) => { return !( (mode === "object" && relation.cardinality.subject === "1") || (mode === "subject" && relation.cardinality.object === "1") ); }; /** * Checks the content of the input value to detect if the user is creating a * new entity for the relation. When creating a new entity, the input's content will be an object * instead of a string/number. * * @param value THe input value * @returns True if the user is creating a new relation * * @category Relations Utilities */ export const isCreatingNewRelation = ( value: Identifier | Partial | undefined ): value is Partial => { return ( value != undefined && !(typeof value == "number" || typeof value === "string") ); }; /** * Checks if the given value is a React Admin Identifier. * * @param value The value to check * @returns True if the given value is a string or number * * @category Relations Utilities */ export const isIdentifier = (value: unknown): value is Identifier => { return typeof value === "string" || typeof value === "number"; }; /** * Checks if the given keys represent the same relation, with one reversed. * * @param parentRelationKey The parent's relation key * @param relationKey The relation key * @returns True if reversing one of the keys returns the other * * @category Relations Utilities */ export const isEditingParentReverseRelation = ( parentRelationKey: string | null, relationKey: string ) => { return ( parentRelationKey != null && (`${REVERSE_INDICATOR}${relationKey}` === parentRelationKey || `${REVERSE_INDICATOR}${parentRelationKey}` === relationKey) ); }; /** * Checks if we are trying to edit the parent relation but cardinality forbids us. * * @param parentRelationKey The parent's relation key * @param relation The relation to edit * @param role Subject or object mode * @returns True if the parent is blocking edition * * @category Relations Utilities */ export const isParentPreventingEditing = ( parentRelationKey: string | null, relation: RelationDefinitionSchema, role: EntityRole ) => { return ( isEditingParentReverseRelation( parentRelationKey, getRelationKey(relation, role) ) && relation.cardinality[role] === "1" ); }; /** * If the given value is an empty string, array or object, returns undefined. * Returns the given value otherwise. * * @param v The value to convert * @returns The given value if not empty or undefined * * @category Relations Utilities */ export const emptyToUndefined = (v: T) => { if ( (typeof v === "string" && v === "") || (Array.isArray(v) && v.length === 0) || // @ts-ignore typescript did not check the first condition (typeof v === "object" && Object.keys(v).length === 0) ) return undefined; else return v; }; /** * Recursively parses the given object and removes empty values as defined in `emptyToUndefined`. * * @param data The data to strip * @returns The initial data without empty values * * @category Relations Utilities */ export const recursivelyStripEmptyValues = (data: T): T | undefined => { if (data == null) { return undefined; } else if (typeof data === "object" && !Array.isArray(data)) { const finalData: Partial = {}; Object.entries(data).forEach(([key, value]) => { const stripedValue = recursivelyStripEmptyValues(value); if (stripedValue !== undefined) { finalData[key] = stripedValue; } }); return emptyToUndefined(finalData as T); } else if (Array.isArray(data)) { return emptyToUndefined( data .map(recursivelyStripEmptyValues) .filter((v) => v !== undefined) as unknown as T ); } else { return emptyToUndefined(data); } }; /** * Removes data associated to relations of cardinality + and *. * * This can be useful to prevent react-admin's cache from being modified * after validating an Edit or Create form. * * @param entitySchema The entity schema * @param data The data to strip * @returns The original record without entries associated to relation of cardinality + and * * * @category Relations Utilities */ export const removeRelationData = ( entitySchema: EntitySchema, data: Partial ) => { const relations = entitySchema.relationDefinitions; const relationToIgnore = [ ...relations.subject .filter((r) => isCardinalityMultiple(r.cardinality.object)) .map((r) => getRelationKey(r, "subject")), ...relations.object .filter((r) => isCardinalityMultiple(r.cardinality.subject)) .map((r) => getRelationKey(r, "object")), ]; const newData: Partial = {}; Object.entries(data).forEach(([key, value]) => { if (!relationToIgnore.includes(key)) { newData[key] = value; } }); return newData; }; const capitalize = (value: string) => value[0].toUpperCase() + value.slice(1); const humanize = (value: string) => value.replace(/_/g, " "); /** * Generates a humanized label for the given relation. * * @param relation The relation to get a label for * @param mode Subject or object relation mode * @returns A humanized label * * @category Relations Utilities */ export const getRelationLabel = ( relation: RelationDefinitionSchema, mode: "subject" | "object" ) => { if (!relation.final) { let label = ""; const humanizedRelationType = humanize(relation.type); if (mode === "object") { label = `${relation.subject.type} ${humanizedRelationType} this`; } else { label = `${humanizedRelationType} ${relation.object.type}`; } return capitalize(label); } return capitalize(humanize(relation.type)); };