Morph many to one picker (#14155)

This PR follows the multiSelect PR merged previously. It will enable
morph relation Many to One to be handled from the table, using a
singleSelect picker

Main point : I decided to change the singleSelect API to take an array
of **objectMetadataName** instead of only one to deal with both our
usecases.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Guillim
2025-10-07 17:25:11 +02:00
committed by GitHub
parent 9f9c2d85ca
commit f60817a1e1
138 changed files with 2116 additions and 1763 deletions
@@ -6,19 +6,19 @@ describe('applyDiff', () => {
describe('input validation', () => {
it('should throw error for non-array diffs', () => {
const obj = { test: 'value' };
expect(() => applyDiff(obj, null as any)).toThrow(
'Diffs must be an array'
'Diffs must be an array',
);
expect(() => applyDiff(obj, 'invalid' as any)).toThrow(
'Diffs must be an array'
'Diffs must be an array',
);
});
it('should handle empty diffs array', () => {
const obj = { test: 'value' };
const result = applyDiff(obj, []);
expect(result).toEqual({ test: 'value' });
expect(result).not.toBe(obj); // Should return a copy
});
@@ -30,7 +30,7 @@ describe('applyDiff', () => {
{ type: 'CREATE', path: [], value: 'test' } as any,
{ type: 'CREATE', path: ['test'], value: 'updated' },
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({ test: 'updated' });
});
@@ -42,7 +42,7 @@ describe('applyDiff', () => {
const diffs: Difference[] = [
{ type: 'CREATE', path: ['newProp'], value: 'newValue' },
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({
existing: 'value',
@@ -55,7 +55,7 @@ describe('applyDiff', () => {
const diffs: Difference[] = [
{ type: 'CREATE', path: ['level1', 'newProp'], value: 'newValue' },
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({
level1: {
@@ -70,7 +70,7 @@ describe('applyDiff', () => {
const diffs: Difference[] = [
{ type: 'CREATE', path: [1], value: 'newElement' },
];
const result = applyDiff(obj, diffs);
expect(result).toEqual(['existing', 'newElement']);
});
@@ -80,7 +80,7 @@ describe('applyDiff', () => {
const diffs: Difference[] = [
{ type: 'CREATE', path: ['level1', 'level2', 'prop'], value: 'deep' },
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({
level1: {
@@ -98,7 +98,7 @@ describe('applyDiff', () => {
{ type: 'CREATE', path: ['level1', 'level2'], value: {} },
{ type: 'CREATE', path: ['level1', 'level2', 'prop'], value: 'deep' },
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({
level1: {
@@ -114,9 +114,14 @@ describe('applyDiff', () => {
it('should change existing properties', () => {
const obj = { prop: 'oldValue' };
const diffs: Difference[] = [
{ type: 'CHANGE', path: ['prop'], oldValue: 'oldValue', value: 'newValue' },
{
type: 'CHANGE',
path: ['prop'],
oldValue: 'oldValue',
value: 'newValue',
},
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({ prop: 'newValue' });
});
@@ -124,9 +129,14 @@ describe('applyDiff', () => {
it('should change nested properties', () => {
const obj = { level1: { prop: 'oldValue' } };
const diffs: Difference[] = [
{ type: 'CHANGE', path: ['level1', 'prop'], oldValue: 'oldValue', value: 'newValue' },
{
type: 'CHANGE',
path: ['level1', 'prop'],
oldValue: 'oldValue',
value: 'newValue',
},
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({
level1: { prop: 'newValue' },
@@ -138,7 +148,7 @@ describe('applyDiff', () => {
const diffs: Difference[] = [
{ type: 'CHANGE', path: [0], oldValue: 'old', value: 'new' },
];
const result = applyDiff(obj, diffs);
expect(result).toEqual(['new', 'values']);
});
@@ -151,9 +161,14 @@ describe('applyDiff', () => {
],
};
const diffs: Difference[] = [
{ type: 'CHANGE', path: ['users', 0, 'name'], oldValue: 'John', value: 'Johnny' },
{
type: 'CHANGE',
path: ['users', 0, 'name'],
oldValue: 'John',
value: 'Johnny',
},
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({
users: [
@@ -170,7 +185,7 @@ describe('applyDiff', () => {
const diffs: Difference[] = [
{ type: 'REMOVE', path: ['remove'], oldValue: 'toDelete' },
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({ keep: 'value' });
});
@@ -185,7 +200,7 @@ describe('applyDiff', () => {
const diffs: Difference[] = [
{ type: 'REMOVE', path: ['level1', 'remove'], oldValue: 'toDelete' },
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({
level1: { keep: 'value' },
@@ -197,7 +212,7 @@ describe('applyDiff', () => {
const diffs: Difference[] = [
{ type: 'REMOVE', path: [1], oldValue: 'remove' },
];
const result = applyDiff(obj, diffs);
expect(result).toEqual(['keep1', 'keep2']);
});
@@ -208,7 +223,7 @@ describe('applyDiff', () => {
{ type: 'REMOVE', path: [1], oldValue: 'b' }, // Remove 'b'
{ type: 'REMOVE', path: [3], oldValue: 'd' }, // Remove 'd'
];
const result = applyDiff(obj, diffs);
expect(result).toEqual(['a', 'c', 'e']);
});
@@ -220,7 +235,7 @@ describe('applyDiff', () => {
{ type: 'REMOVE', path: [2], oldValue: 'c' }, // Remove 'c'
{ type: 'REMOVE', path: [4], oldValue: 'e' }, // Remove 'e'
];
const result = applyDiff(obj, diffs);
expect(result).toEqual(['b', 'd', 'f']);
});
@@ -235,7 +250,7 @@ describe('applyDiff', () => {
const diffs: Difference[] = [
{ type: 'REMOVE', path: ['items', 0, 'tags', 1], oldValue: 'tag2' }, // Remove 'tag2'
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({
items: [
@@ -250,9 +265,9 @@ describe('applyDiff', () => {
const diffs: Difference[] = [
{ type: 'REMOVE', path: ['invalid'], oldValue: 'invalid' },
];
expect(() => applyDiff(obj, diffs)).toThrow(
'Expected numeric index for array removal, got string'
'Expected numeric index for array removal, got string',
);
});
});
@@ -267,15 +282,20 @@ describe('applyDiff', () => {
array: ['a', 'b', 'c'],
},
};
const diffs: Difference[] = [
{ type: 'CREATE', path: ['newProp'], value: 'newValue' },
{ type: 'CHANGE', path: ['change'], oldValue: 'oldValue', value: 'newValue' },
{
type: 'CHANGE',
path: ['change'],
oldValue: 'oldValue',
value: 'newValue',
},
{ type: 'REMOVE', path: ['remove'], oldValue: 'toDelete' },
{ type: 'REMOVE', path: ['nested', 'array', 1], oldValue: 'b' }, // Remove 'b'
{ type: 'CREATE', path: ['nested', 'newArray'], value: [1, 2, 3] },
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({
keep: 'value',
@@ -296,7 +316,7 @@ describe('applyDiff', () => {
{ type: 'REMOVE', path: [3], oldValue: 'd' }, // Remove 'd'
{ type: 'REMOVE', path: [3], oldValue: 'd' }, // Remove 'd'
];
const result = applyDiff(obj, diffs);
expect(result).toEqual(['A', 'c']);
});
@@ -308,20 +328,25 @@ describe('applyDiff', () => {
const diffs: Difference[] = [
{ type: 'INVALID' as any, path: ['test'], value: 'newValue' },
];
expect(() => applyDiff(obj, diffs)).toThrow(
'Unsupported diff type: INVALID'
'Unsupported diff type: INVALID',
);
});
it('should throw error with path information for invalid operations', () => {
const obj = { test: 'value' };
const diffs: Difference[] = [
{ type: 'CHANGE', path: ['nonExistent', 'deep', 'path'], oldValue: 'value', value: 'value' },
{
type: 'CHANGE',
path: ['nonExistent', 'deep', 'path'],
oldValue: 'value',
value: 'value',
},
];
expect(() => applyDiff(obj, diffs)).toThrow(
'Failed to apply diff at path nonExistent.deep.path'
'Failed to apply diff at path nonExistent.deep.path',
);
});
});
@@ -335,7 +360,7 @@ describe('applyDiff', () => {
];
expect(() => applyDiff(obj, diffs)).toThrow(
"Refusing to set forbidden property key '__proto__' on object (prototype pollution protection)"
"Refusing to set forbidden property key '__proto__' on object (prototype pollution protection)",
);
});
@@ -344,20 +369,20 @@ describe('applyDiff', () => {
const unicodeBypassAttempts = [
// __proto__ with Unicode escapes
'__\u0070roto__', // \u0070 = 'p'
'__\u{70}roto__', // ES6 syntax
'__pr\u006fto__', // \u006f = 'o'
'__\u0070roto__', // \u0070 = 'p'
'__\u{70}roto__', // ES6 syntax
'__pr\u006fto__', // \u006f = 'o'
'__proto\u005f\u005f', // \u005f = '_'
// constructor with Unicode escapes
// constructor with Unicode escapes
'construc\u0074or', // \u0074 = 't'
'constr\u0075ctor', // \u0075 = 'u'
'\u0063onstructor', // \u0063 = 'c'
// prototype with Unicode escapes
'proto\u0074ype', // \u0074 = 't'
'prototy\u0070e', // \u0070 = 'p'
'\u0070rototype', // \u0070 = 'p'
'proto\u0074ype', // \u0074 = 't'
'prototy\u0070e', // \u0070 = 'p'
'\u0070rototype', // \u0070 = 'p'
];
unicodeBypassAttempts.forEach((maliciousKey) => {
@@ -366,7 +391,9 @@ describe('applyDiff', () => {
];
expect(() => applyDiff(obj, diffs)).toThrow(
new RegExp(`Refusing to set forbidden property key.*prototype pollution protection`)
new RegExp(
`Refusing to set forbidden property key.*prototype pollution protection`,
),
);
});
});
@@ -375,11 +402,16 @@ describe('applyDiff', () => {
const obj = { safe: 'value' };
const diffs: Difference[] = [
{ type: 'CHANGE', path: ['constructor'], oldValue: 'old', value: 'malicious' },
{
type: 'CHANGE',
path: ['constructor'],
oldValue: 'old',
value: 'malicious',
},
];
expect(() => applyDiff(obj, diffs)).toThrow(
"Refusing to set forbidden property key 'constructor' on object (prototype pollution protection)"
"Refusing to set forbidden property key 'constructor' on object (prototype pollution protection)",
);
});
@@ -414,36 +446,51 @@ describe('applyDiff', () => {
it('should not modify the original object', () => {
const obj = { prop: 'value', nested: { deep: 'value' } };
const originalObj = JSON.parse(JSON.stringify(obj));
const diffs: Difference[] = [
{ type: 'CHANGE', path: ['prop'], oldValue: 'value', value: 'newValue' },
{ type: 'CHANGE', path: ['nested', 'deep'], oldValue: 'value', value: 'newDeepValue' },
{
type: 'CHANGE',
path: ['prop'],
oldValue: 'value',
value: 'newValue',
},
{
type: 'CHANGE',
path: ['nested', 'deep'],
oldValue: 'value',
value: 'newDeepValue',
},
];
applyDiff(obj, diffs);
expect(obj).toEqual(originalObj);
});
it('should not modify the original array', () => {
const obj = ['a', 'b', 'c'];
const originalObj = [...obj];
const diffs: Difference[] = [
{ type: 'REMOVE', path: [1], oldValue: 'b' },
];
applyDiff(obj, diffs);
expect(obj).toEqual(originalObj);
});
it('should handle frozen objects', () => {
const obj = Object.freeze({ prop: 'value' });
const diffs: Difference[] = [
{ type: 'CHANGE', path: ['prop'], oldValue: 'value', value: 'newValue' },
{
type: 'CHANGE',
path: ['prop'],
oldValue: 'value',
value: 'newValue',
},
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({ prop: 'newValue' });
expect(obj.prop).toBe('value'); // Original unchanged
@@ -470,22 +517,36 @@ describe('applyDiff', () => {
},
],
};
const diffs: Difference[] = [
// Update trigger settings
{ type: 'CHANGE', path: ['trigger', 'settings', 'table'], oldValue: 'users', value: 'contacts' },
{
type: 'CHANGE',
path: ['trigger', 'settings', 'table'],
oldValue: 'users',
value: 'contacts',
},
// Remove first step
{ type: 'REMOVE', path: ['steps', 0], oldValue: '1' },
// Update remaining step
{ type: 'CHANGE', path: ['steps', 1, 'settings', 'to'], oldValue: 'test@example.com', value: 'new@example.com' },
{
type: 'CHANGE',
path: ['steps', 1, 'settings', 'to'],
oldValue: 'test@example.com',
value: 'new@example.com',
},
// Add new step
{ type: 'CREATE', path: ['steps', 2], value: {
id: '3',
type: 'WEBHOOK',
settings: { url: 'https://api.example.com/webhook' },
}},
{
type: 'CREATE',
path: ['steps', 2],
value: {
id: '3',
type: 'WEBHOOK',
settings: { url: 'https://api.example.com/webhook' },
},
},
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({
trigger: {
@@ -517,18 +578,27 @@ describe('applyDiff', () => {
{ id: 5, name: 'Item 5' },
],
};
const diffs: Difference[] = [
// Remove items at indices 1 and 3 (Item 2 and Item 4)
{ type: 'REMOVE', path: ['items', 1], oldValue: 'Item 2' },
{ type: 'REMOVE', path: ['items', 3], oldValue: 'Item 4' },
{ type: 'REMOVE', path: ['items', 3], oldValue: 'Item 4' },
// Update remaining item
{ type: 'CHANGE', path: ['items', 0, 'name'], oldValue: 'Item 1', value: 'Updated Item 1' },
{
type: 'CHANGE',
path: ['items', 0, 'name'],
oldValue: 'Item 1',
value: 'Updated Item 1',
},
// Add new item
{ type: 'CREATE', path: ['items', 5], value: { id: 6, name: 'New Item' } },
{
type: 'CREATE',
path: ['items', 5],
value: { id: 6, name: 'New Item' },
},
];
const result = applyDiff(obj, diffs);
expect(result).toEqual({
items: [
@@ -3,36 +3,40 @@ import { findOrThrow } from '@/utils';
describe('findOrThrow', () => {
it('returns the element when predicate matches', () => {
const arr = [1, 2, 3, 4];
const result = findOrThrow(arr, x => x === 3);
const result = findOrThrow(arr, (x) => x === 3);
expect(result).toBe(3);
});
it('throws default error when no element matches', () => {
const arr = [1, 2, 3];
expect(() => findOrThrow(arr, x => x === 5)).toThrow(new Error('Element not found'));
expect(() => findOrThrow(arr, (x) => x === 5)).toThrow(
new Error('Element not found'),
);
});
it('throws custom error when provided', () => {
const arr = ['a', 'b', 'c'];
const customError = new Error('Custom error!');
expect(() => findOrThrow(arr, x => x === 'z', customError)).toThrow(customError);
expect(() => findOrThrow(arr, (x) => x === 'z', customError)).toThrow(
customError,
);
});
it('returns the first matching element when multiple match', () => {
const arr = [2, 4, 6, 8];
const result = findOrThrow(arr, x => x % 2 === 0);
const result = findOrThrow(arr, (x) => x % 2 === 0);
expect(result).toBe(2);
});
it('works with object arrays', () => {
const arr = [{id: 1}, {id: 2}, {id: 3}];
const result = findOrThrow(arr, obj => obj.id === 2);
expect(result).toEqual({id: 2});
const arr = [{ id: 1 }, { id: 2 }, { id: 3 }];
const result = findOrThrow(arr, (obj) => obj.id === 2);
expect(result).toEqual({ id: 2 });
});
it('works with primitive arrays', () => {
const arr = ['apple', 'banana', 'cherry'];
const result = findOrThrow(arr, fruit => fruit.startsWith('b'));
const result = findOrThrow(arr, (fruit) => fruit.startsWith('b'));
expect(result).toBe('banana');
});
});
});
@@ -1,4 +1,4 @@
import { isNonEmptyArray } from "@/utils/array/isNonEmptyArray";
import { isNonEmptyArray } from '@/utils/array/isNonEmptyArray';
describe('isNonEmptyArray', () => {
it('should return true for a non empty array', () => {
@@ -1,4 +1,4 @@
import { parseJson } from "@/utils/parseJson";
import { parseJson } from '@/utils/parseJson';
describe('parseJson', () => {
it('if value is null', () => {
@@ -419,4 +419,4 @@ describe('safeParseRelativeDateFilterValue', () => {
});
});
});
});
});
+69 -38
View File
@@ -34,7 +34,9 @@ export const applyDiff = <T>(obj: T, diffs: Difference[]): T => {
try {
applyDiffToPath(mutableObj as MutableData, diff, arrayDeletionQueue);
} catch (error) {
throw new Error(`Failed to apply diff at path ${diff.path.join('.')}: ${error}`);
throw new Error(
`Failed to apply diff at path ${diff.path.join('.')}: ${error}`,
);
}
}
@@ -46,82 +48,103 @@ export const applyDiff = <T>(obj: T, diffs: Difference[]): T => {
const applyDiffToPath = (
obj: MutableData,
diff: Difference,
arrayDeletionQueue: ArrayDeletionFn[]
arrayDeletionQueue: ArrayDeletionFn[],
) => {
const { path, type } = diff;
const value = 'value' in diff ? diff.value : undefined;
const pathLength = path.length;
const lastPathElement = path[pathLength - 1];
const parentContainer = navigateToParent(obj, path);
switch (type) {
case 'CREATE':
case 'CHANGE':
setValueAtPath(parentContainer, lastPathElement, value);
break;
case 'REMOVE':
handleRemoval(obj, path, parentContainer, lastPathElement, arrayDeletionQueue);
handleRemoval(
obj,
path,
parentContainer,
lastPathElement,
arrayDeletionQueue,
);
break;
default:
throw new Error(`Unsupported diff type: ${type}`);
}
};
const navigateToParent = (obj: MutableData, path: (string | number)[]): MutableData => {
const navigateToParent = (
obj: MutableData,
path: (string | number)[],
): MutableData => {
let current = obj;
for (let i = 0; i < path.length - 1; i++) {
const pathElement = path[i];
if (current === null || current === undefined) {
throw new Error(`Cannot traverse path: found null/undefined at element ${i}`);
throw new Error(
`Cannot traverse path: found null/undefined at element ${i}`,
);
}
if (isNumber(pathElement) && !Array.isArray(current)) {
throw new Error(`Expected array at path element ${i}, got ${typeof current}`);
throw new Error(
`Expected array at path element ${i}, got ${typeof current}`,
);
}
if (isString(pathElement) && Array.isArray(current)) {
throw new Error(`Expected object at path element ${i}, got array`);
}
if (Array.isArray(current)) {
current = current[pathElement as number] as MutableData;
} else {
current = (current as ObjectType)[pathElement as string] as MutableData;
}
}
return current;
};
const setValueAtPath = (
container: MutableData,
pathElement: string | number,
value: unknown
value: unknown,
): void => {
if (Array.isArray(container)) {
if (!isNumber(pathElement)) {
throw new Error(`Expected numeric index for array, got ${typeof pathElement}`);
}
try {
container[pathElement] = value;
} catch (error) {
throw new Error(`Cannot set array element at index ${pathElement}: ${error}. Array may be non-extensible.`);
}
} else if (isObject(container)) {
if (FORBIDDEN_OBJECT_KEYS.includes(pathElement as string)) {
throw new Error(`Refusing to set forbidden property key '${pathElement}' on object (prototype pollution protection)`);
throw new Error(
`Expected numeric index for array, got ${typeof pathElement}`,
);
}
try {
container[pathElement] = value;
} catch (error) {
throw new Error(`Cannot set property '${String(pathElement)}': ${error}. Object may be non-extensible.`);
throw new Error(
`Cannot set array element at index ${pathElement}: ${error}. Array may be non-extensible.`,
);
}
} else if (isObject(container)) {
if (FORBIDDEN_OBJECT_KEYS.includes(pathElement as string)) {
throw new Error(
`Refusing to set forbidden property key '${pathElement}' on object (prototype pollution protection)`,
);
}
try {
container[pathElement] = value;
} catch (error) {
throw new Error(
`Cannot set property '${String(pathElement)}': ${error}. Object may be non-extensible.`,
);
}
} else {
throw new Error(`Expected object or array, got ${typeof container}`);
@@ -133,10 +156,16 @@ const handleRemoval = (
fullPath: (string | number)[],
parentContainer: MutableData,
lastPathElement: string | number,
arrayDeletionQueue: ArrayDeletionFn[]
arrayDeletionQueue: ArrayDeletionFn[],
): void => {
if (Array.isArray(parentContainer)) {
handleArrayRemoval(rootObj, fullPath, parentContainer, lastPathElement, arrayDeletionQueue);
handleArrayRemoval(
rootObj,
fullPath,
parentContainer,
lastPathElement,
arrayDeletionQueue,
);
} else {
handleObjectRemoval(parentContainer as ObjectType, lastPathElement);
}
@@ -148,14 +177,16 @@ const handleArrayRemoval = (
fullPath: (string | number)[],
parentArray: ArrayType,
index: string | number,
arrayDeletionQueue: ArrayDeletionFn[]
arrayDeletionQueue: ArrayDeletionFn[],
): void => {
if (typeof index !== 'number') {
throw new Error(`Expected numeric index for array removal, got ${typeof index}`);
throw new Error(
`Expected numeric index for array removal, got ${typeof index}`,
);
}
parentArray[index] = REMOVE_SYMBOL;
arrayDeletionQueue.push(() => {
if (fullPath.length === 1) {
if (Array.isArray(rootObj)) {
@@ -169,7 +200,7 @@ const handleArrayRemoval = (
const handleObjectRemoval = (
parentObject: ObjectType,
key: string | number
key: string | number,
): void => {
if (FORBIDDEN_OBJECT_KEYS.includes(key as string)) {
return;
@@ -180,13 +211,13 @@ const handleObjectRemoval = (
const filterRemoveSymbols = (array: ArrayType): void => {
const indicesToRemove: number[] = [];
for (let i = 0; i < array.length; i++) {
if (array[i] === REMOVE_SYMBOL) {
indicesToRemove.push(i);
}
}
for (let i = indicesToRemove.length - 1; i >= 0; i--) {
array.splice(indicesToRemove[i], 1);
}
@@ -1,5 +1,8 @@
export const filterOutByProperty = <T, K extends keyof T>(property: K, valueToExclude: T[K] | null | undefined) => {
export const filterOutByProperty = <T, K extends keyof T>(
property: K,
valueToExclude: T[K] | null | undefined,
) => {
return (itemToFilter: T) => {
return itemToFilter[property] !== valueToExclude
}
}
return itemToFilter[property] !== valueToExclude;
};
};
@@ -1,5 +1,5 @@
export const findById = <T extends { id: string }>(idToMatch: string) => {
return (itemToFind: T) => {
return itemToFind.id === idToMatch
}
}
return itemToFind.id === idToMatch;
};
};
@@ -1,5 +1,8 @@
export const findByProperty = <T, K extends keyof T>(property: K, valueToMatch: T[K] | null | undefined) => {
export const findByProperty = <T, K extends keyof T>(
property: K,
valueToMatch: T[K] | null | undefined,
) => {
return (itemToFind: T) => {
return itemToFind[property] === valueToMatch
}
}
return itemToFind[property] === valueToMatch;
};
};
@@ -3,11 +3,11 @@ import { assertIsDefinedOrThrow } from '@/utils';
export const findOrThrow = <T>(
array: T[],
predicate: (value: T) => boolean,
error: Error = new Error('Element not found'),
error: Error = new Error('Element not found'),
): T => {
const result = array.find(predicate);
assertIsDefinedOrThrow(result, error)
assertIsDefinedOrThrow(result, error);
return result;
};
@@ -1,17 +1,17 @@
import { isNumberOrNaN } from "@sniptt/guards";
import { isNumberOrNaN } from '@sniptt/guards';
export const sumByProperty = <T, K extends keyof T>(property: K) => {
return (accumulator: number, nextItem: T) => {
if(typeof accumulator !== "number") {
if (typeof accumulator !== 'number') {
accumulator = 0;
}
if(!isNumberOrNaN(nextItem[property])) {
if (!isNumberOrNaN(nextItem[property])) {
return accumulator;
}
accumulator += nextItem[property];
return accumulator
}
}
return accumulator;
};
};
@@ -0,0 +1,34 @@
import { CustomError } from '@/utils/errors';
import { capitalize } from '@/utils/strings';
enum RelationType {
MANY_TO_ONE = 'MANY_TO_ONE',
ONE_TO_MANY = 'ONE_TO_MANY',
}
type ComputeMorphRelationFieldNameArgs = {
fieldName: string;
relationType: RelationType;
targetObjectMetadataNameSingular: string;
targetObjectMetadataNamePlural: string;
};
export const computeMorphRelationFieldName = ({
fieldName,
relationType,
targetObjectMetadataNameSingular: nameSingular,
targetObjectMetadataNamePlural: namePlural,
}: ComputeMorphRelationFieldNameArgs): string => {
if (relationType === RelationType.MANY_TO_ONE) {
return `${fieldName}${capitalize(nameSingular)}`;
}
if (relationType === RelationType.ONE_TO_MANY) {
return `${fieldName}${capitalize(namePlural)}`;
}
throw new CustomError(
`Invalid relation type (${relationType}) for field ${fieldName} on ${nameSingular}`,
'INVALID_RELATION_TYPE_FOR_COMPUTE_MORPH_RELATION_FIELD_NAME',
);
};
@@ -1,4 +1,8 @@
import { type FieldMetadataType, type FilterableAndTSVectorFieldType, type ViewFilterOperand } from '@/types';
import {
type FieldMetadataType,
type FilterableAndTSVectorFieldType,
type ViewFilterOperand,
} from '@/types';
import { isEmptinessOperand } from '@/utils/filter/isEmptinessOperand';
import { getFilterTypeFromFieldType } from '@/utils/filter/utils/getFilterTypeFromFieldType';
@@ -7,7 +11,7 @@ export const checkIfShouldComputeEmptinessFilter = ({
correspondingFieldMetadataItem,
}: {
recordFilterOperand: ViewFilterOperand;
correspondingFieldMetadataItem: {type: FieldMetadataType};
correspondingFieldMetadataItem: { type: FieldMetadataType };
}) => {
const isAnEmptinessOperand = isEmptinessOperand(recordFilterOperand);
@@ -16,7 +16,10 @@ export const computeGqlOperationFilterForEmails = ({
subFieldName,
}: {
recordFilter: RecordFilter;
correspondingFieldMetadataItem: Pick<PartialFieldMetadataItem, 'name' | 'type'>;
correspondingFieldMetadataItem: Pick<
PartialFieldMetadataItem,
'name' | 'type'
>;
subFieldName: CompositeFieldSubFieldName | null | undefined;
}): RecordGqlOperationFilter => {
const isSubFieldFilter = isNonEmptyString(subFieldName);
@@ -1,4 +1,9 @@
import { ViewFilterOperand as RecordFilterOperand, type CompositeFieldSubFieldName, type LinksFilter, type PartialFieldMetadataItem } from '@/types';
import {
ViewFilterOperand as RecordFilterOperand,
type CompositeFieldSubFieldName,
type LinksFilter,
type PartialFieldMetadataItem,
} from '@/types';
import { CustomError } from '@/utils/errors';
import { type RecordFilter } from '@/utils/filter/turnRecordFilterGroupIntoGqlOperationFilter';
import { isNonEmptyString } from '@sniptt/guards';
@@ -9,7 +14,10 @@ export const computeGqlOperationFilterForLinks = ({
subFieldName,
}: {
recordFilter: RecordFilter;
correspondingFieldMetadataItem: Pick<PartialFieldMetadataItem, 'name' | 'type'>
correspondingFieldMetadataItem: Pick<
PartialFieldMetadataItem,
'name' | 'type'
>;
subFieldName: CompositeFieldSubFieldName | null | undefined;
}) => {
const isSubFieldFilter = isNonEmptyString(subFieldName);
@@ -13,7 +13,10 @@ export const computeEmptyGqlOperationFilterForEmails = ({
correspondingFieldMetadataItem,
}: {
recordFilter: RecordFilter;
correspondingFieldMetadataItem: Pick<PartialFieldMetadataItem, 'name' | 'type'>;
correspondingFieldMetadataItem: Pick<
PartialFieldMetadataItem,
'name' | 'type'
>;
}): RecordGqlOperationFilter => {
const subFieldName = recordFilter.subFieldName;
const isSubFieldFilter = isNonEmptyString(subFieldName);
@@ -53,7 +56,7 @@ export const computeEmptyGqlOperationFilterForEmails = ({
};
}
default: {
throw new CustomError(
throw new CustomError(
`Unknown subfield name ${subFieldName}`,
'UNKNOWN_SUBFIELD_NAME',
);
@@ -69,7 +69,7 @@ export const computeEmptyGqlOperationFilterForLinks = ({
};
}
default: {
throw new CustomError(
throw new CustomError(
`Unknown subfield name ${subFieldName}`,
'UNKNOWN_SUBFIELD_NAME',
);
@@ -1,4 +1,3 @@
import {
type PartialFieldMetadataItem,
type RecordFilterValueDependencies,
@@ -46,8 +45,7 @@ export const computeRecordGqlOperationFilter = ({
fields,
recordFilterGroups,
currentRecordFilterGroupId: outermostFilterGroupId,
}
);
});
const recordGqlOperationFilters = [
...regularRecordGqlOperationFilter,
@@ -19,5 +19,3 @@ export * from './utils/resolveDateViewFilterValue';
export * from './utils/validation-schemas/arrayOfStringsOrVariablesSchema';
export * from './utils/validation-schemas/arrayOfUuidsOrVariablesSchema';
export * from './utils/validation-schemas/jsonRelationFilterValueSchema';
@@ -1,7 +1,8 @@
import { ViewFilterOperand as RecordFilterOperand } from '@/types';
export const isEmptinessOperand = (operand: RecordFilterOperand): boolean => {
return [RecordFilterOperand.IS_EMPTY, RecordFilterOperand.IS_NOT_EMPTY].includes(
operand,
);
return [
RecordFilterOperand.IS_EMPTY,
RecordFilterOperand.IS_NOT_EMPTY,
].includes(operand);
};
@@ -1,7 +1,15 @@
import { CURRENCY_CODE_LABELS } from '@/constants';
import { type CurrencyCode } from '@/constants/CurrencyCode';
import { FieldMetadataType, ViewFilterOperand, type PartialFieldMetadataItem, type RecordGqlOperationFilter } from '@/types';
import { filterSelectOptionsOfFieldMetadataItem, type RecordFilter } from '@/utils';
import {
FieldMetadataType,
ViewFilterOperand,
type PartialFieldMetadataItem,
type RecordGqlOperationFilter,
} from '@/types';
import {
filterSelectOptionsOfFieldMetadataItem,
type RecordFilter,
} from '@/utils';
import { isNonEmptyArray } from '@/utils/array/isNonEmptyArray';
import { turnRecordFilterIntoRecordGqlOperationFilter } from '@/utils/filter/turnRecordFilterIntoGqlOperationFilter';
import { createAnyFieldRecordFilterBaseProperties } from '@/utils/filter/utils/createAnyFieldRecordFilterBaseProperties';
@@ -24,7 +32,6 @@ export const turnAnyFieldFilterIntoRecordGqlFilter = ({
filterValue: string;
fields: PartialFieldMetadataItem[];
}) => {
const anyFieldRecordFilters: RecordFilter[] = [];
const isFilterValueANumber = z.coerce.number().safeParse(filterValue).success;
@@ -1,4 +1,12 @@
import { type CompositeFieldSubFieldName, type FilterableAndTSVectorFieldType, type PartialFieldMetadataItem, RecordFilterGroupLogicalOperator, type RecordFilterValueDependencies, type RecordGqlOperationFilter, type ViewFilterOperand } from '@/types';
import {
type CompositeFieldSubFieldName,
type FilterableAndTSVectorFieldType,
type PartialFieldMetadataItem,
RecordFilterGroupLogicalOperator,
type RecordFilterValueDependencies,
type RecordGqlOperationFilter,
type ViewFilterOperand,
} from '@/types';
import { isDefined } from '@/utils';
import { turnRecordFilterIntoRecordGqlOperationFilter } from '@/utils/filter/turnRecordFilterIntoGqlOperationFilter';
@@ -17,7 +25,7 @@ export type RecordFilterGroup = {
id: string;
parentRecordFilterGroupId?: string | null;
logicalOperator: RecordFilterGroupLogicalOperator;
}
};
export const turnRecordFilterGroupsIntoGqlOperationFilter = ({
filterValueDependencies,
@@ -26,11 +34,11 @@ export const turnRecordFilterGroupsIntoGqlOperationFilter = ({
recordFilterGroups,
currentRecordFilterGroupId,
}: {
filterValueDependencies: RecordFilterValueDependencies,
filters: RecordFilter[],
fields: PartialFieldMetadataItem[],
recordFilterGroups: RecordFilterGroup[],
currentRecordFilterGroupId?: string,
filterValueDependencies: RecordFilterValueDependencies;
filters: RecordFilter[];
fields: PartialFieldMetadataItem[];
recordFilterGroups: RecordFilterGroup[];
currentRecordFilterGroupId?: string;
}): RecordGqlOperationFilter | undefined => {
const currentRecordFilterGroup = recordFilterGroups.find(
(recordFilterGroup) => recordFilterGroup.id === currentRecordFilterGroupId,
@@ -45,7 +53,7 @@ export const turnRecordFilterGroupsIntoGqlOperationFilter = ({
);
const groupRecordGqlOperationFilters = recordFiltersInGroup
.map((recordFilter) =>
.map((recordFilter) =>
turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: recordFilter,
@@ -29,7 +29,8 @@ import {
convertGreaterThanOrEqualRatingToArrayOfRatingValues,
convertLessThanOrEqualRatingToArrayOfRatingValues,
convertRatingToRatingValue,
generateILikeFiltersForCompositeFields, getEmptyRecordGqlOperationFilter,
generateILikeFiltersForCompositeFields,
getEmptyRecordGqlOperationFilter,
isExpectedSubFieldName,
} from '@/utils/filter';
@@ -37,7 +38,14 @@ import { resolveDateViewFilterValue } from '@/utils/filter/utils/resolveDateView
import { endOfDay, roundToNearestMinutes, startOfDay } from 'date-fns';
import { z } from 'zod';
import { checkIfShouldComputeEmptinessFilter, checkIfShouldSkipFiltering, CustomError, getFilterTypeFromFieldType, isDefined, type RecordFilter } from '@/utils';
import {
checkIfShouldComputeEmptinessFilter,
checkIfShouldSkipFiltering,
CustomError,
getFilterTypeFromFieldType,
isDefined,
type RecordFilter,
} from '@/utils';
import { arrayOfStringsOrVariablesSchema } from '@/utils/filter/utils/validation-schemas/arrayOfStringsOrVariablesSchema';
import { arrayOfUuidOrVariableSchema } from '@/utils/filter/utils/validation-schemas/arrayOfUuidsOrVariablesSchema';
import { jsonRelationFilterValueSchema } from '@/utils/filter/utils/validation-schemas/jsonRelationFilterValueSchema';
@@ -47,7 +55,7 @@ type FieldShared = {
name: string;
type: FieldMetadataType;
label: string;
}
};
type TurnRecordFilterIntoRecordGqlOperationFilterParams = {
filterValueDependencies?: RecordFilterValueDependencies;
@@ -56,7 +64,6 @@ type TurnRecordFilterIntoRecordGqlOperationFilterParams = {
recordIdsForUuid?: string[];
};
export const turnRecordFilterIntoRecordGqlOperationFilter = ({
recordFilter,
fieldMetadataItems,
@@ -337,7 +344,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
]
: selectedRecordIds;
if (!isDefined (recordIds) || recordIds.length === 0) return;
if (!isDefined(recordIds) || recordIds.length === 0) return;
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
@@ -347,7 +354,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
} as RelationFilter,
};
case RecordFilterOperand.IS_NOT: {
if (!isDefined (recordIds) || recordIds.length === 0) return;
if (!isDefined(recordIds) || recordIds.length === 0) return;
return {
or: [
{
@@ -1201,7 +1208,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
case 'UUID': {
const recordIds = recordIdsForUuid;
if (!isDefined (recordIds) || recordIds.length === 0) return;
if (!isDefined(recordIds) || recordIds.length === 0) return;
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
@@ -1,4 +1,4 @@
import { arrayOfStringsOrVariablesSchema } from "@/utils/filter/utils/validation-schemas/arrayOfStringsOrVariablesSchema";
import { arrayOfStringsOrVariablesSchema } from '@/utils/filter/utils/validation-schemas/arrayOfStringsOrVariablesSchema';
describe('arrayOfStringsOrVariablesSchema', () => {
describe('Empty value handling', () => {
@@ -1,4 +1,4 @@
import { generateILikeFiltersForCompositeFields } from "@/utils/filter/utils/generateILikeFiltersForCompositeFields";
import { generateILikeFiltersForCompositeFields } from '@/utils/filter/utils/generateILikeFiltersForCompositeFields';
describe('generateILikeFiltersForCompositeFields', () => {
it('should format composite filters for simple filter string', () => {
@@ -1,7 +1,6 @@
import { FieldMetadataType, type PartialFieldMetadataItem } from "@/types";
import { turnAnyFieldFilterIntoRecordGqlFilter } from "@/utils/filter/turnAnyFieldFilterIntoRecordGqlFilter";
import { filterSelectOptionsOfFieldMetadataItem } from "@/utils/filter/utils/filterSelectOptionsOfFieldMetadataItem";
import { FieldMetadataType, type PartialFieldMetadataItem } from '@/types';
import { turnAnyFieldFilterIntoRecordGqlFilter } from '@/utils/filter/turnAnyFieldFilterIntoRecordGqlFilter';
import { filterSelectOptionsOfFieldMetadataItem } from '@/utils/filter/utils/filterSelectOptionsOfFieldMetadataItem';
const baseFieldMetadataItem: PartialFieldMetadataItem = {
id: 'base-field-metadata-item-id',
@@ -303,7 +302,6 @@ describe('turnAnyFieldFilterIntoRecordGqlFilter', () => {
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [arrayFieldMetadataItem],
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
@@ -351,7 +349,6 @@ describe('turnAnyFieldFilterIntoRecordGqlFilter', () => {
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [phonesFieldMetadataItem],
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
@@ -388,7 +385,7 @@ describe('turnAnyFieldFilterIntoRecordGqlFilter', () => {
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [numberFieldMetadataItem],
fields: [numberFieldMetadataItem],
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
@@ -417,7 +414,6 @@ describe('turnAnyFieldFilterIntoRecordGqlFilter', () => {
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [currencyFieldMetadataItem],
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
@@ -4,12 +4,15 @@ const operandMapping: Record<string, ViewFilterOperand> = {
[ViewFilterOperandDeprecated.Is]: ViewFilterOperand.IS,
[ViewFilterOperandDeprecated.IsNotNull]: ViewFilterOperand.IS_NOT_NULL,
[ViewFilterOperandDeprecated.IsNot]: ViewFilterOperand.IS_NOT,
[ViewFilterOperandDeprecated.LessThanOrEqual]: ViewFilterOperand.LESS_THAN_OR_EQUAL,
[ViewFilterOperandDeprecated.GreaterThanOrEqual]: ViewFilterOperand.GREATER_THAN_OR_EQUAL,
[ViewFilterOperandDeprecated.LessThanOrEqual]:
ViewFilterOperand.LESS_THAN_OR_EQUAL,
[ViewFilterOperandDeprecated.GreaterThanOrEqual]:
ViewFilterOperand.GREATER_THAN_OR_EQUAL,
[ViewFilterOperandDeprecated.IsBefore]: ViewFilterOperand.IS_BEFORE,
[ViewFilterOperandDeprecated.IsAfter]: ViewFilterOperand.IS_AFTER,
[ViewFilterOperandDeprecated.Contains]: ViewFilterOperand.CONTAINS,
[ViewFilterOperandDeprecated.DoesNotContain]: ViewFilterOperand.DOES_NOT_CONTAIN,
[ViewFilterOperandDeprecated.DoesNotContain]:
ViewFilterOperand.DOES_NOT_CONTAIN,
[ViewFilterOperandDeprecated.IsEmpty]: ViewFilterOperand.IS_EMPTY,
[ViewFilterOperandDeprecated.IsNotEmpty]: ViewFilterOperand.IS_NOT_EMPTY,
[ViewFilterOperandDeprecated.IsRelative]: ViewFilterOperand.IS_RELATIVE,
@@ -1,5 +1,3 @@
export const convertViewFilterValueToString = (value: any) => {
return typeof value === 'string'
? value
: JSON.stringify(value ?? '');
};
return typeof value === 'string' ? value : JSON.stringify(value ?? '');
};
@@ -8,10 +8,7 @@ export const createAnyFieldRecordFilterBaseProperties = ({
}: {
filterValue: string;
fieldMetadataItem: PartialFieldMetadataItem;
}): Pick<
RecordFilter,
'id' | 'value' | 'fieldMetadataId'
> => {
}): Pick<RecordFilter, 'id' | 'value' | 'fieldMetadataId'> => {
return {
id: v4(),
value: filterValue,
@@ -1,5 +1,5 @@
import { RATING_VALUES } from "@/constants";
import { type FieldRatingValue } from "@/types";
import { RATING_VALUES } from '@/constants';
import { type FieldRatingValue } from '@/types';
export const convertGreaterThanOrEqualRatingToArrayOfRatingValues = (
greaterThanValue: number,
@@ -1,4 +1,4 @@
import { type PartialFieldMetadataItem } from "@/types";
import { type PartialFieldMetadataItem } from '@/types';
export const filterSelectOptionsOfFieldMetadataItem = ({
fieldMetadataItem,
@@ -385,7 +385,7 @@ export const getEmptyRecordGqlOperationFilter = ({
});
break;
default:
throw new CustomError(
throw new CustomError(
`Unsupported empty filter type ${filterType}`,
'UNSUPPORTED_EMPTY_FILTER_TYPE',
);
@@ -399,7 +399,7 @@ export const getEmptyRecordGqlOperationFilter = ({
not: emptyRecordFilter,
};
default:
throw new CustomError(
throw new CustomError(
`Unknown operand ${operand} for ${filterType} filter`,
'UNKNOWN_OPERAND_FOR_FILTER',
);
@@ -1,5 +1,7 @@
import { FieldMetadataType, type FilterableAndTSVectorFieldType } from "@/types";
import {
FieldMetadataType,
type FilterableAndTSVectorFieldType,
} from '@/types';
export const getFilterTypeFromFieldType = (
fieldType: FieldMetadataType,
@@ -46,4 +48,4 @@ export const getFilterTypeFromFieldType = (
default:
return 'TEXT';
}
};
};
@@ -11,14 +11,12 @@ export const isExpectedSubFieldName = <
subFieldName: PossibleSubFieldName,
subFieldNameToCheck: string | null | undefined,
): subFieldNameToCheck is PossibleSubFieldName => {
const allowedSubFields = Object.values(COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES[
fieldMetadataType
]) as readonly string[];
const allowedSubFields = Object.values(
COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES[fieldMetadataType],
) as readonly string[];
return (
allowedSubFields.includes(subFieldName as string) &&
subFieldName === subFieldNameToCheck
);
};
@@ -169,7 +169,10 @@ export type ResolvedDateViewFilterValue<O extends ViewFilterOperand> =
? ReturnType<typeof resolveVariableDateViewFilterValue>
: Date | null;
type PartialViewFilter<O extends ViewFilterOperand> = {value: string, operand: O} // TODO, was done to avoid ViewFilter export
type PartialViewFilter<O extends ViewFilterOperand> = {
value: string;
operand: O;
}; // TODO, was done to avoid ViewFilter export
export const resolveDateViewFilterValue = <O extends ViewFilterOperand>(
viewFilter: PartialViewFilter<O>,
@@ -19,6 +19,7 @@ export { computeDiffBetweenObjects } from './compute-diff-between-objects';
export { deepMerge } from './deepMerge';
export { CustomError } from './errors/CustomError';
export { extractAndSanitizeObjectStringFields } from './extractAndSanitizeObjectStringFields';
export { computeMorphRelationFieldName } from './fieldMetadata/compute-morph-relation-field-name';
export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind';
export { checkIfShouldComputeEmptinessFilter } from './filter/checkIfShouldComputeEmptinessFilter';
export { checkIfShouldSkipFiltering } from './filter/checkIfShouldSkipFiltering';
@@ -1,6 +1,8 @@
import { isDefined } from "@/utils/validation";
import { isDefined } from '@/utils/validation';
export const parseJson = <T>(rawJson: string | boolean | null | number | undefined): T | null => {
export const parseJson = <T>(
rawJson: string | boolean | null | number | undefined,
): T | null => {
try {
if (!isDefined(rawJson)) {
return null;
@@ -11,8 +13,8 @@ export const parseJson = <T>(rawJson: string | boolean | null | number | undefin
}
// This is a hack to handle the case where the value is a scalar value which is part of JSON spec but not implemented before ES2019
return JSON.parse("[" + rawJson + "]")[0];
return JSON.parse('[' + rawJson + ']')[0];
} catch {
return null;
return null;
}
};
@@ -5,18 +5,33 @@ import {
} from '@/types/RelativeDateValue';
import { z } from 'zod';
const RelativeDateValueSchema = z.object({
direction: z.enum(['NEXT', 'THIS', 'PAST'] as const) as z.ZodType<VariableDateViewFilterValueDirection>,
unit: z.enum(['DAY', 'WEEK', 'MONTH', 'YEAR'] as const) as z.ZodType<VariableDateViewFilterValueUnit>,
amount: z.number().positive().optional(),
}).refine((data) => {
if (data.direction === 'NEXT' || data.direction === 'PAST') {
return data.amount !== undefined && data.amount > 0;
}
return true;
}, {
error: 'Amount is required for NEXT and PAST directions and must be positive'
});
const RelativeDateValueSchema = z
.object({
direction: z.enum([
'NEXT',
'THIS',
'PAST',
] as const) as z.ZodType<VariableDateViewFilterValueDirection>,
unit: z.enum([
'DAY',
'WEEK',
'MONTH',
'YEAR',
] as const) as z.ZodType<VariableDateViewFilterValueUnit>,
amount: z.number().positive().optional(),
})
.refine(
(data) => {
if (data.direction === 'NEXT' || data.direction === 'PAST') {
return data.amount !== undefined && data.amount > 0;
}
return true;
},
{
error:
'Amount is required for NEXT and PAST directions and must be positive',
},
);
export const safeParseRelativeDateFilterValue = (
value: string,
@@ -34,4 +49,4 @@ export const safeParseRelativeDateFilterValue = (
} catch {
return undefined;
}
}
};
@@ -1,2 +1 @@
export * from './capitalize';
@@ -16,8 +16,10 @@ export const TIPTAP_NODE_TYPES = {
IMAGE: 'image',
} as const;
export type TipTapMarkType = typeof TIPTAP_MARK_TYPES[keyof typeof TIPTAP_MARK_TYPES];
export type TipTapNodeType = typeof TIPTAP_NODE_TYPES[keyof typeof TIPTAP_NODE_TYPES];
export type TipTapMarkType =
(typeof TIPTAP_MARK_TYPES)[keyof typeof TIPTAP_MARK_TYPES];
export type TipTapNodeType =
(typeof TIPTAP_NODE_TYPES)[keyof typeof TIPTAP_NODE_TYPES];
// Order for mark rendering (inner to outer)
export const TIPTAP_MARKS_RENDER_ORDER: readonly TipTapMarkType[] = [
@@ -1,8 +1,10 @@
export const throwIfNotDefined = <T>(
value: T,
variableName: string
variableName: string,
): asserts value is NonNullable<T> => {
if (value === null || value === undefined) {
throw new Error(`Value must be defined for variable ${variableName}, this should not happen`);
throw new Error(
`Value must be defined for variable ${variableName}, this should not happen`,
);
}
}
};
@@ -16,7 +16,7 @@ export const absoluteUrlSchema = z.string().transform((value, ctx) => {
// if the hostname is a number, it's not a valid url
// if we let URL() parse it, it will throw cast an IP address and we lose the information
ctx.addIssue({
code: "custom",
code: 'custom',
message: 'domain is not a valid url',
});
@@ -28,14 +28,14 @@ export const absoluteUrlSchema = z.string().transform((value, ctx) => {
return absoluteUrl;
}
ctx.addIssue({
code: "custom",
code: 'custom',
message: 'domain is not a valid url',
});
return z.NEVER;
} catch {
ctx.addIssue({
code: "custom",
code: 'custom',
message: 'domain is not a valid url',
});
@@ -16,7 +16,9 @@ describe('assertIsDefinedOrThrow', () => {
});
it('throws the default error when value is undefined', () => {
expect(() => assertIsDefinedOrThrow(undefined)).toThrow('Value not defined');
expect(() => assertIsDefinedOrThrow(undefined)).toThrow(
'Value not defined',
);
});
it('throws the default error when value is null', () => {
@@ -38,4 +40,4 @@ describe('assertIsDefinedOrThrow', () => {
// After the assertion, TypeScript should treat `maybe` as `string`.
expectString(maybe);
});
});
});
@@ -4,9 +4,7 @@ import { isDefined } from '@/utils';
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
export function assertIsDefinedOrThrow<T>(
value: T | undefined | null,
exceptionToThrow: Error = new Error(
'Value not defined',
),
exceptionToThrow: Error = new Error('Value not defined'),
): asserts value is T {
if (!isDefined(value)) throw exceptionToThrow;
}