import { castArray, maxBy } from 'lodash/fp'; import _ from 'lodash'; import InvalidRelationError from '../errors/invalid-relation.mjs'; /** * When connecting relations, the order you connect them matters. * * Example, if you connect the following relations: * { id: 5, position: { before: 1 } } * { id: 1, position: { before: 2 } } * { id: 2, position: { end: true } } * * Going through the connect array, id 5 has to be connected before id 1, * so the order of id5 = id1 - 1. But the order value of id 1 is unknown. * The only way to know the order of id 1 is to connect it first. * * This function makes sure the relations are connected in the right order: * { id: 2, position: { end: true } } * { id: 1, position: { before: 2 } } * { id: 5, position: { before: 1 } } * */ const sortConnectArray = (connectArr, initialArr = [], strictSort = true)=>{ const sortedConnect = []; // Boolean to know if we have to recalculate the order of the relations let needsSorting = false; // Map to validate if relation is already in sortedConnect or DB. const relationInInitialArray = initialArr.reduce((acc, rel)=>({ ...acc, [rel.id]: true }), {}); // Map to store the first index where a relation id is connected const mappedRelations = connectArr.reduce((mapper, relation)=>{ const adjacentRelId = relation.position?.before || relation.position?.after; if (!adjacentRelId || !relationInInitialArray[adjacentRelId] && !mapper[adjacentRelId]) { needsSorting = true; } /** * We do not allow duplicate relations to be connected, so we need to check for uniqueness with components * Note that the id here includes the uid for polymorphic relations * * So for normal relations, the same id means the same relation * For component relations, it means the unique combo of (id, component name) */ // Check if there's an existing relation with this id const existingRelation = mapper[relation.id]; // Check if existing relation has a component or not const hasNoComponent = existingRelation && !('__component' in existingRelation); // Check if the existing relation has the same component as the new relation const hasSameComponent = existingRelation && existingRelation.__component === relation.__component; // If we have an existing relation that is not unique (no component or same component) we won't accept it if (existingRelation && (hasNoComponent || hasSameComponent)) { throw new InvalidRelationError(`The relation with id ${relation.id} is already connected. ` + 'You cannot connect the same relation twice.'); } return { [relation.id]: { ...relation, computed: false }, ...mapper }; }, {}); // If we don't need to sort the connect array, we can return it as is if (!needsSorting) return connectArr; // Recursively compute in which order the relation should be connected const computeRelation = (relation, relationsSeenInBranch)=>{ const adjacentRelId = relation.position?.before || relation.position?.after; const adjacentRelation = mappedRelations[adjacentRelId]; // If the relation has already been seen in the current branch, // it means there is a circular reference if (adjacentRelId && relationsSeenInBranch[adjacentRelId]) { throw new InvalidRelationError('A circular reference was found in the connect array. ' + 'One relation is trying to connect before/after another one that is trying to connect before/after it'); } // This relation has already been computed if (mappedRelations[relation.id]?.computed) { return; } mappedRelations[relation.id].computed = true; // Relation does not have a before or after attribute or is in the initial array if (!adjacentRelId || relationInInitialArray[adjacentRelId]) { sortedConnect.push(relation); return; } // Look if id is referenced elsewhere in the array if (mappedRelations[adjacentRelId]) { computeRelation(adjacentRelation, { ...relationsSeenInBranch, [relation.id]: true }); sortedConnect.push(relation); } else if (strictSort) { // If we reach this point, it means that the adjacent relation is not in the connect array // and it is not in the database. throw new InvalidRelationError(`There was a problem connecting relation with id ${relation.id} at position ${JSON.stringify(relation.position)}. The relation with id ${adjacentRelId} needs to be connected first.`); } else { // We are in non-strict mode so we can push the relation. sortedConnect.push({ id: relation.id, position: { end: true } }); } }; // Iterate over connectArr and populate sortedConnect connectArr.forEach((relation)=>computeRelation(relation, {})); return sortedConnect; }; /** * Responsible for calculating the relations order when connecting them. * * The connect method takes an array of relations with positional attributes: * - before: the id of the relation to connect before * - after: the id of the relation to connect after * - end: it should be at the end * - start: it should be at the start * * Example: * - Having a connect array like: * [ { id: 4, before: 2 }, { id: 4, before: 3}, {id: 5, before: 4} ] * - With the initial relations: * [ { id: 2, order: 4 }, { id: 3, order: 10 } ] * - Step by step, going through the connect array, the array of relations would be: * [ { id: 4, order: 3.5 }, { id: 2, order: 4 }, { id: 3, order: 10 } ] * [ { id: 2, order: 4 }, { id: 4, order: 3.5 }, { id: 3, order: 10 } ] * [ { id: 2, order: 4 }, { id: 5, order: 3.5 }, { id: 4, order: 3.5 }, { id: 3, order: 10 } ] * - The final step would be to recalculate fractional order values. * [ { id: 2, order: 4 }, { id: 5, order: 3.33 }, { id: 4, order: 3.66 }, { id: 3, order: 10 } ] * * @param {Array<*>} initArr - array of relations to initialize the class with * @param {string} idColumn - the column name of the id * @param {string} orderColumn - the column name of the order * @param {boolean} strict - if true, will throw an error if a relation is connected adjacent to * another one that does not exist * @return {*} */ const relationsOrderer = (initArr, idColumn, orderColumn, strict)=>{ const computedRelations = castArray(initArr ?? []).map((r)=>({ init: true, id: r[idColumn], order: Number(r[orderColumn]) || 1 })); const maxOrder = maxBy('order', computedRelations)?.order || 0; const findRelation = (id)=>{ const idx = computedRelations.findIndex((r)=>r.id === id); return { idx, relation: computedRelations[idx] }; }; const removeRelation = (r)=>{ const { idx } = findRelation(r.id); if (idx >= 0) { computedRelations.splice(idx, 1); } }; const insertRelation = (r)=>{ let idx; if (r.position?.before) { const { idx: _idx, relation } = findRelation(r.position.before); if (relation.init) { r.order = relation.order - 0.5; } else { r.order = relation.order; } idx = _idx; } else if (r.position?.after) { const { idx: _idx, relation } = findRelation(r.position.after); if (relation.init) { r.order = relation.order + 0.5; } else { r.order = relation.order; } idx = _idx + 1; } else if (r.position?.start) { r.order = 0.5; idx = 0; } else { r.order = maxOrder + 0.5; idx = computedRelations.length; } // Insert the relation in the array computedRelations.splice(idx, 0, r); }; return { disconnect (relations) { castArray(relations).forEach((relation)=>{ removeRelation(relation); }); return this; }, connect (relations) { sortConnectArray(castArray(relations), computedRelations, strict).forEach((relation)=>{ this.disconnect(relation); try { insertRelation(relation); } catch (err) { throw new Error(`There was a problem connecting relation with id ${relation.id} at position ${JSON.stringify(relation.position)}. The list of connect relations is not valid`); } }); return this; }, get () { return computedRelations; }, /** * Get a map between the relation id and its order */ getOrderMap () { return _(computedRelations).groupBy('order').reduce((acc, relations)=>{ if (relations[0]?.init) return acc; relations.forEach((relation, idx)=>{ acc[relation.id] = Math.floor(relation.order) + (idx + 1) / (relations.length + 1); }); return acc; }, {}); } }; }; export { relationsOrderer, sortConnectArray }; //# sourceMappingURL=relations-orderer.mjs.map