diff --git a/packages/app/universal-ts-utils/src/node.ts b/packages/app/universal-ts-utils/src/node.ts index 59c9f288..13f085d3 100644 --- a/packages/app/universal-ts-utils/src/node.ts +++ b/packages/app/universal-ts-utils/src/node.ts @@ -13,5 +13,12 @@ export * from './public/array/sortByField.js' // object export * from './public/object/areDeepEqual.js' -export * from './public/object/groupBy.js' +export * from './public/object/convertDateFieldsToIsoString.js' +export * from './public/object/copyWithoutEmpty.js' +export * from './public/object/copyWithoutNullish.js' +export * from './public/object/deepClone.js' +export * from './public/object/groupByPath.js' export * from './public/object/groupByUnique.js' +export * from './public/object/isEmpty.js' +export * from './public/object/pick.js' +export * from './public/object/transformToKebabCase.js' diff --git a/packages/app/universal-ts-utils/src/public/object/__snapshots__/copyWithoutNullish.spec.ts.snap b/packages/app/universal-ts-utils/src/public/object/__snapshots__/copyWithoutNullish.spec.ts.snap new file mode 100644 index 00000000..8b853433 --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/__snapshots__/copyWithoutNullish.spec.ts.snap @@ -0,0 +1,10 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`copyWithoutNullish > Does nothing when there are no undefined fields 1`] = ` +{ + "a": "a", + "b": "", + "c": " ", + "e": {}, +} +`; diff --git a/packages/app/universal-ts-utils/src/public/object/convertDateFieldsToIsoString.spec.ts b/packages/app/universal-ts-utils/src/public/object/convertDateFieldsToIsoString.spec.ts new file mode 100644 index 00000000..5212d95e --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/convertDateFieldsToIsoString.spec.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import { convertDateFieldsToIsoString } from './convertDateFieldsToIsoString.js' + +type TestInputType = { + id: number + value: string + date: Date + code: number + reason?: string | null + other?: TestInputType + array?: { + id: number + createdAt: Date + }[] +} + +type TestExpectedType = { + id: number + value: string + date: string + code: number + other?: TestExpectedType + array?: { + id: number + createdAt: string + }[] +} + +describe('convertDateFieldsToIsoString', () => { + it('empty object', () => { + expect(convertDateFieldsToIsoString({})).toStrictEqual({}) + }) + + it('simple object', () => { + const date = new Date() + const input: TestInputType = { + id: 1, + date, + value: 'test', + reason: 'reason', + code: 100, + } + + const output: TestExpectedType = convertDateFieldsToIsoString(input) + + expect(output).toStrictEqual({ + id: 1, + date: date.toISOString(), + value: 'test', + code: 100, + reason: 'reason', + }) + }) + + it('simple array', () => { + const date1 = new Date() + const date2 = new Date() + const input: TestInputType[] = [ + { + id: 1, + date: date1, + value: 'test', + reason: 'reason', + code: 100, + }, + { + id: 2, + date: date2, + value: 'test 2', + reason: 'reason 2', + code: 200, + }, + ] + + const output: TestExpectedType[] = convertDateFieldsToIsoString(input) + + expect(output).toStrictEqual([ + { + id: 1, + date: date1.toISOString(), + value: 'test', + code: 100, + reason: 'reason', + }, + { + id: 2, + date: date2.toISOString(), + value: 'test 2', + code: 200, + reason: 'reason 2', + }, + ]) + }) + + it('handles undefined and null', () => { + const date = new Date() + const input: TestInputType = { + id: 1, + date, + value: 'test', + code: 100, + reason: null, + other: undefined, + } + + const output: TestExpectedType = convertDateFieldsToIsoString(input) + + expect(output).toStrictEqual({ + id: 1, + date: date.toISOString(), + value: 'test', + code: 100, + reason: null, + other: undefined, + }) + }) + + it('properly handles all types of arrays', () => { + const date = new Date() + const input = { + array1: [date, date], + array2: [1, 2], + array3: ['a', 'b'], + array4: [ + { id: 1, value: 'value', date, code: 100 } satisfies TestInputType, + { id: 2, value: 'value2', date, code: 200 } satisfies TestInputType, + ], + array5: [1, date, 'a', { id: 1, value: 'value', date, code: 100 } satisfies TestInputType], + } + + type Expected = { + array1: string[] + array2: number[] + array3: string[] + array4: TestExpectedType[] + array5: (number | string | TestExpectedType)[] + } + const output: Expected = convertDateFieldsToIsoString(input) + + expect(output).toStrictEqual({ + array1: [date.toISOString(), date.toISOString()], + array2: [1, 2], + array3: ['a', 'b'], + array4: [ + { id: 1, value: 'value', date: date.toISOString(), code: 100 }, + { id: 2, value: 'value2', date: date.toISOString(), code: 200 }, + ], + array5: [ + 1, + date.toISOString(), + 'a', + { id: 1, value: 'value', date: date.toISOString(), code: 100 }, + ], + }) + }) + + it('nested objects and array', () => { + const date1 = new Date() + const date2 = new Date() + date2.setFullYear(1990) + const input: TestInputType = { + id: 1, + date: date1, + value: 'test', + code: 100, + reason: 'reason', + other: { + id: 2, + value: 'test 2', + date: date2, + code: 200, + reason: null, + other: undefined, + }, + array: [ + { + id: 1, + createdAt: date1, + }, + { + id: 2, + createdAt: date2, + }, + ], + } + + const output: TestExpectedType = convertDateFieldsToIsoString(input) + + expect(output).toMatchObject({ + id: 1, + date: date1.toISOString(), + value: 'test', + code: 100, + reason: 'reason', + other: { + id: 2, + value: 'test 2', + date: date2.toISOString(), + code: 200, + reason: null, + other: undefined, + }, + array: [ + { + id: 1, + createdAt: date1.toISOString(), + }, + { + id: 2, + createdAt: date2.toISOString(), + }, + ], + }) + }) +}) diff --git a/packages/app/universal-ts-utils/src/public/object/convertDateFieldsToIsoString.ts b/packages/app/universal-ts-utils/src/public/object/convertDateFieldsToIsoString.ts new file mode 100644 index 00000000..4e9c148e --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/convertDateFieldsToIsoString.ts @@ -0,0 +1,51 @@ +type DatesAsString = T extends Date ? string : ExactlyLikeWithDateAsString + +type ExactlyLikeWithDateAsString = T extends object ? { [K in keyof T]: DatesAsString } : T + +/** + * Recursively converts all Date fields in an object or array of objects to ISO string format. + * This function retains the structure of the input, ensuring non-Date fields remain unchanged, + * while Date fields are replaced with their ISO string representations. + * + * @param {object | object[]} object - The object or array of objects to convert. + * @returns {object | object[]} A new object or array of objects with Date fields as ISO strings. + * + * @example + *```typescript + * const obj = { id: 1, created: new Date(), meta: { updated: new Date() } } + * const result = convertDateFieldsToIsoString(obj) + * console.log(result) // { id: 1, created: '2024-01-01T00:00:00.000Z', meta: { updated: '2024-01-01T00:00:00.000Z' } } + * ``` + */ +export function convertDateFieldsToIsoString( + object: Input, +): ExactlyLikeWithDateAsString +export function convertDateFieldsToIsoString( + object: Input[], +): ExactlyLikeWithDateAsString[] +export function convertDateFieldsToIsoString( + object: Input | Input[], +): ExactlyLikeWithDateAsString | ExactlyLikeWithDateAsString[] { + if (Array.isArray(object)) { + return object.map(internalConvert) as ExactlyLikeWithDateAsString[] + } + + return Object.entries(object).reduce( + (result, [key, value]) => { + // @ts-expect-error + result[key] = internalConvert(value) + return result + }, + {} as ExactlyLikeWithDateAsString, + ) +} + +const internalConvert = (item: T): DatesAsString => { + // @ts-expect-error + if (item instanceof Date) return item.toISOString() + // @ts-expect-error + if (item && typeof item === 'object') return convertDateFieldsToIsoString(item) + + // @ts-expect-error + return item +} diff --git a/packages/app/universal-ts-utils/src/public/object/copyWithoutEmpty.spec.ts b/packages/app/universal-ts-utils/src/public/object/copyWithoutEmpty.spec.ts new file mode 100644 index 00000000..65e9f2c5 --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/copyWithoutEmpty.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { copyWithoutEmpty } from './copyWithoutEmpty.js' + +describe('copyWithoutEmpty', () => { + it('Does nothing when there are no empty fields', () => { + const result = copyWithoutEmpty({ + a: 'a', + b: ' t ', + c: ' tt', + d: 'tt ', + e: {}, + y: 88, + z: 0, + }) + + expect(result).toMatchInlineSnapshot(` + { + "a": "a", + "b": " t ", + "c": " tt", + "d": "tt ", + "e": {}, + "y": 88, + "z": 0, + } + `) + }) + + it('Removes empty fields', () => { + const result = copyWithoutEmpty({ + a: undefined, + b: 'a', + c: '', + d: undefined, + e: ' ', + f: null, + g: { + someParam: 12, + }, + h: undefined, + y: 88, + z: 0, + }) + + const varWithNarrowedType = result satisfies Record< + string, + string | Record | null | number + > + const bValue: string = varWithNarrowedType.b + const gValue: { + someParam: number + } = varWithNarrowedType.g + + expect(bValue).toBe('a') + expect(gValue).toEqual({ + someParam: 12, + }) + + expect(result).toMatchInlineSnapshot(` + { + "b": "a", + "g": { + "someParam": 12, + }, + "y": 88, + "z": 0, + } + `) + }) +}) diff --git a/packages/app/universal-ts-utils/src/public/object/copyWithoutEmpty.ts b/packages/app/universal-ts-utils/src/public/object/copyWithoutEmpty.ts new file mode 100644 index 00000000..ecef6809 --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/copyWithoutEmpty.ts @@ -0,0 +1,32 @@ +import type { RecordKeyType } from '../../internal/types.js' + +type Output> = Pick< + T, + { + [Prop in keyof T]: T[Prop] extends null | undefined | '' ? never : Prop + }[keyof T] +> + +/** + * Creates a shallow copy of an object, excluding properties with "empty" values. + * + * A "empty" value includes `null`, `undefined`, empty strings (`''`). + * + * @param {Record} object - The source object from which to copy properties. + * @returns {Record} A new object containing only the properties from the source object + * that do not have "falsy" values. + */ +export const copyWithoutEmpty = >(object: T): Output => + Object.keys(object).reduce( + (acc, key) => { + const value = object[key] as unknown + if (value === undefined) return acc + if (value === null) return acc + if (typeof value === 'string' && value.trim().length === 0) return acc + + // TODO: handle nested objects + acc[key] = object[key] + return acc + }, + {} as Record, + ) as Output diff --git a/packages/app/universal-ts-utils/src/public/object/copyWithoutNullish.spec.ts b/packages/app/universal-ts-utils/src/public/object/copyWithoutNullish.spec.ts new file mode 100644 index 00000000..ab599975 --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/copyWithoutNullish.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { copyWithoutNullish } from './copyWithoutNullish.js' + +describe('copyWithoutNullish', () => { + it('Does nothing when there are no undefined fields', () => { + const result = copyWithoutNullish({ + a: 'a', + b: '', + c: ' ', + d: null, + e: {}, + }) + + expect(result).toMatchSnapshot() + }) + + it('Removes undefined fields', () => { + const result = copyWithoutNullish({ + a: undefined, + b: 'a', + c: '', + d: undefined, + e: ' ', + f: null, + g: { + someParam: 12, + }, + h: undefined, + }) + + const varWithNarrowedType = result satisfies Record< + string, + string | Record | null + > + const bValue: string = varWithNarrowedType.b + const gValue: { + someParam: number + } = varWithNarrowedType.g + + expect(bValue).toBe('a') + expect(gValue).toEqual({ + someParam: 12, + }) + + expect(result).toMatchInlineSnapshot(` + { + "b": "a", + "c": "", + "e": " ", + "g": { + "someParam": 12, + }, + } + `) + }) +}) diff --git a/packages/app/universal-ts-utils/src/public/object/copyWithoutNullish.ts b/packages/app/universal-ts-utils/src/public/object/copyWithoutNullish.ts new file mode 100644 index 00000000..ad204b78 --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/copyWithoutNullish.ts @@ -0,0 +1,33 @@ +import type { RecordKeyType } from '../../internal/types.js' + +type Output> = Pick< + T, + { + [Prop in keyof T]: T[Prop] extends null | undefined ? never : Prop + }[keyof T] +> + +/** + * Creates a shallow copy of an object, excluding properties with `null` or `undefined` values. + * + * This function iterates over an object's own enumerable properties and creates a new + * object that excludes properties with `null` or `undefined` values. + * + * @param {Record} object - The source object from which to copy properties. + * @returns {Record} A new object containing only the properties from the source object + * that do not have `null` or `undefined` values. + */ +export const copyWithoutNullish = >( + object: T, +): Output => + Object.keys(object).reduce( + (acc, key) => { + const value = object[key] + if (value === undefined || value === null) return acc + + // TODO: handle nested objects + acc[key] = object[key] + return acc + }, + {} as Record, + ) as Output diff --git a/packages/app/universal-ts-utils/src/public/object/deepClone.spec.ts b/packages/app/universal-ts-utils/src/public/object/deepClone.spec.ts new file mode 100644 index 00000000..938f0ca1 --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/deepClone.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { deepClone } from './deepClone.js' + +describe('deepClone', () => { + it('will deep clone an object', () => { + const object = { + names: [ + { + name: 'Cameron', + }, + { + name: 'Alexander', + }, + { + name: 'Smith', + }, + ], + date: new Date(), + isEnabled: true, + age: 12, + } + + const clonedObject = deepClone(object) + object.names = [] + object.age = 22 + object.isEnabled = false + expect(clonedObject.date).instanceof(Date) + expect(clonedObject.date).not.toBe(object.date) + expect(clonedObject.names).toStrictEqual([ + { + name: 'Cameron', + }, + { + name: 'Alexander', + }, + { + name: 'Smith', + }, + ]) + expect(clonedObject.isEnabled).toBe(true) + expect(clonedObject.age).toBe(12) + }) + + it('will return null or undefined if no object is provided', () => { + expect(deepClone(undefined)).toBeUndefined() + expect(deepClone(null)).toBeNull() + }) +}) diff --git a/packages/app/universal-ts-utils/src/public/object/deepClone.ts b/packages/app/universal-ts-utils/src/public/object/deepClone.ts new file mode 100644 index 00000000..805da986 --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/deepClone.ts @@ -0,0 +1,13 @@ +/** + * Returns a deep cloned copy of an object. + * + * This function utilizes the `structuredClone` method, which is capable of deep cloning complex objects, + * including nested structures. However, it has limitations and does not support cloning functions, + * Error objects, WeakMap, WeakSet, DOM nodes, and certain other browser-specific objects like Window. + * + * @param {object} object - The object to be deeply cloned. If the object is `undefined` or `null`, it returns the object as is. + * @return {object} A deep clone of the input object, or the input itself if it is `undefined` or `null`. + * + */ +export const deepClone = (object: T): T => + object ? structuredClone(object) : object diff --git a/packages/app/universal-ts-utils/src/public/object/groupByPath.spec.ts b/packages/app/universal-ts-utils/src/public/object/groupByPath.spec.ts new file mode 100644 index 00000000..3b8a0bbf --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/groupByPath.spec.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from 'vitest' +import { groupByPath } from './groupByPath.js' + +describe('groupByPath', () => { + it('Empty array', () => { + const array: { id: { nestedId: string } }[] = [] + const result = groupByPath(array, 'id.nestedId') + expect(Object.keys(result)).length(0) + }) + + type TestType = { + id?: number | null + name: string + bool: boolean + symbol?: symbol + nested?: { + code: number + } + } + + type TestType1 = { + id?: number | null + name: string + bool: boolean + nested?: { + code: number + }[] + } + + it('Correctly groups by string values', () => { + const input: TestType[] = [ + { + id: 1, + name: 'a', + bool: true, + nested: { code: 100 }, + }, + { + id: 2, + name: 'c', + bool: true, + nested: { code: 200 }, + }, + { + id: 3, + name: 'b', + bool: true, + nested: { code: 300 }, + }, + { + id: 4, + name: 'a', + bool: true, + nested: { code: 400 }, + }, + ] + + const result: Record = groupByPath(input, 'name') + expect(result).toStrictEqual({ + a: [ + { + id: 1, + name: 'a', + bool: true, + nested: { code: 100 }, + }, + { + id: 4, + name: 'a', + bool: true, + nested: { code: 400 }, + }, + ], + b: [ + { + id: 3, + name: 'b', + bool: true, + nested: { code: 300 }, + }, + ], + c: [ + { + id: 2, + name: 'c', + bool: true, + nested: { code: 200 }, + }, + ], + }) + }) + + it('Correctly groups by nested string values', () => { + const input: TestType[] = [ + { + id: 1, + name: 'a', + bool: true, + nested: { code: 100 }, + }, + { + id: 2, + name: 'c', + bool: true, + nested: { code: 200 }, + }, + { + id: 3, + name: 'b', + bool: true, + nested: { code: 300 }, + }, + { + id: 4, + name: 'a', + bool: true, + nested: { code: 100 }, + }, + ] + + const result: Record = groupByPath(input, 'nested.code') + expect(result).toStrictEqual({ + 100: [ + { + id: 1, + name: 'a', + bool: true, + nested: { code: 100 }, + }, + { + id: 4, + name: 'a', + bool: true, + nested: { code: 100 }, + }, + ], + 300: [ + { + id: 3, + name: 'b', + bool: true, + nested: { code: 300 }, + }, + ], + 200: [ + { + id: 2, + name: 'c', + bool: true, + nested: { code: 200 }, + }, + ], + }) + }) + + it('return empty record for nested array key', () => { + const input: TestType1[] = [ + { + id: 1, + name: 'a', + bool: true, + nested: [{ code: 100 }], + }, + ] + + const result: Record = groupByPath(input, 'nested.code') + expect(result).toStrictEqual({}) + }) + + it('Correctly groups by number values', () => { + const symbolA = Symbol('a') + const symbolB = Symbol('b') + const input: TestType[] = [ + { + id: 1, + name: 'a', + bool: true, + symbol: symbolA, + }, + { + id: 2, + name: 'c', + bool: false, + symbol: symbolB, + }, + { + id: 3, + name: 'd', + bool: false, + symbol: symbolA, + }, + ] + + const result: Record = groupByPath(input, 'symbol') + + expect(result).toStrictEqual({ + [symbolA]: [ + { + id: 1, + name: 'a', + bool: true, + symbol: symbolA, + }, + { + id: 3, + name: 'd', + bool: false, + symbol: symbolA, + }, + ], + [symbolB]: [ + { + id: 2, + name: 'c', + bool: false, + symbol: symbolB, + }, + ], + }) + }) + + it('Correctly groups by symbol values', () => { + const input: TestType[] = [ + { + id: 1, + name: 'a', + bool: true, + }, + { + id: 1, + name: 'b', + bool: false, + }, + { + id: 2, + name: 'c', + bool: false, + }, + { + id: 3, + name: 'd', + bool: false, + }, + ] + + const result: Record = groupByPath(input, 'id') + + expect(result).toStrictEqual({ + 1: [ + { + id: 1, + name: 'a', + bool: true, + }, + { + id: 1, + name: 'b', + bool: false, + }, + ], + 2: [ + { + id: 2, + name: 'c', + bool: false, + }, + ], + 3: [ + { + id: 3, + name: 'd', + bool: false, + }, + ], + }) + }) + + it('Correctly handles undefined and null', () => { + const input: TestType[] = [ + { + id: 1, + name: 'a', + bool: true, + }, + { + name: 'c', + bool: true, + }, + { + id: null, + name: 'd', + bool: true, + }, + { + id: 1, + name: 'b', + bool: true, + }, + ] + + const result = groupByPath(input, 'id') + + expect(result).toStrictEqual({ + 1: [ + { + id: 1, + name: 'a', + bool: true, + }, + { + id: 1, + name: 'b', + bool: true, + }, + ], + }) + }) +}) diff --git a/packages/app/universal-ts-utils/src/public/object/groupByPath.ts b/packages/app/universal-ts-utils/src/public/object/groupByPath.ts new file mode 100644 index 00000000..2d27a5aa --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/groupByPath.ts @@ -0,0 +1,72 @@ +import type { RecordKeyType } from '../../internal/types.js' + +/** + * Groups an array of objects based on a specified key path. + * + * This function supports nested keys, allowing the use of dot notation + * to group objects by deeply nested properties. + * + * @param {object[]} array - The array of objects to be grouped. + * @param {string} selector - The key path used for grouping the objects. Supports nested keys using dot notation. + * @returns {Record} An object where each key represents a unique value from + * the given selector, and the corresponding value is an array of objects associated with that key. + * + * @example + * ```typescript + * const users = [ + * { name: "A", address: { city: "New York" }, age: 30 }, + * { name: "B", address: { city: "Los Angeles" }, age: 25 }, + * { name: "C", address: { city: "New York" }, age: 35 }, + * ] + * const usersGroupedByCity = groupByPath(users, 'address.city') + * + * console.log(usersGroupedByCity) + * Output: + * { + * "New York": [ + * { name: "Alice", address: { city: "New York", zipCode: 10001 }, age: 30 }, + * { name: "Charlie", address: { city: "New York", zipCode: 10001 }, age: 35 } + * ], + * "Los Angeles": [ + * { name: "Bob", address: { city: "Los Angeles", zipCode: 90001 }, age: 25 } + * ] + * } + * ``` + */ +export const groupByPath = ( + array: T[], + selector: string, +): Record => { + return array.reduce( + (acc, item) => { + const key = getKeyBySelector(item, selector) + if (key === undefined) return acc + + if (!acc[key]) acc[key] = [] + acc[key].push(item) + + return acc + }, + {} as Record, + ) +} + +const getKeyBySelector = ( + item: T, + selector: string, +): RecordKeyType | undefined => { + const tree = selector.split('.') + + let result = item as unknown + for (const treeProp of tree) { + // @ts-expect-error + if (treeProp in result) result = result[treeProp] + else break + } + + if (typeof result === 'string') return result + if (typeof result === 'number') return result + if (typeof result === 'symbol') return result + + return undefined +} diff --git a/packages/app/universal-ts-utils/src/public/object/isEmpty.spec.ts b/packages/app/universal-ts-utils/src/public/object/isEmpty.spec.ts new file mode 100644 index 00000000..708ed7ad --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/isEmpty.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { isEmpty } from './isEmpty.js' + +describe('isEmpty', () => { + it('Returns true for completely empty object', () => { + const params = {} + const result = isEmpty(params) + expect(result).toBe(true) + }) + + it('Returns true for object with only undefined fields', () => { + const params = { a: undefined } + const result = isEmpty(params) + expect(result).toBe(true) + }) + + it('Returns false for object with null', () => { + const params = { a: null } + const result = isEmpty(params) + expect(result).toBe(false) + }) + + it('Returns false for non-empty object', () => { + const params = { a: '' } + const result = isEmpty(params) + expect(result).toBe(false) + }) + + it('handle arrays', () => { + expect(isEmpty([])).toBe(true) + + const array1 = [{ a: 'a' }, { a: 'b' }, { a: null }] + expect(isEmpty(array1)).toBe(false) + + const array2 = [{ a: 'a' }, {}, { a: undefined }] + expect(isEmpty(array2)).toBe(false) + + const array3 = [{}, { a: undefined }] + expect(isEmpty(array3)).toBe(true) + }) +}) diff --git a/packages/app/universal-ts-utils/src/public/object/isEmpty.ts b/packages/app/universal-ts-utils/src/public/object/isEmpty.ts new file mode 100644 index 00000000..9fe8122f --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/isEmpty.ts @@ -0,0 +1,22 @@ +import type { RecordKeyType } from '../../internal/types.js' + +/** + * Checks if an object or an array of objects is empty. + * + * For an object, it is considered empty if it has no own enumerable properties with non-undefined values. + * For an array, it is considered empty if all objects within it are empty by the same criteria. + * + * @param {Record| Record[]} obj - The object or array of objects to evaluate. + * @returns {boolean} Returns `true` if the object or every object within the array is empty, `false` otherwise. + */ +export const isEmpty = ( + obj: Record | Record[], +): boolean => { + if (Array.isArray(obj)) return obj.every(isEmpty) + + for (const key in obj) { + if (Object.hasOwn(obj, key) && obj[key] !== undefined) return false + } + + return true +} diff --git a/packages/app/universal-ts-utils/src/public/object/pick.spec.ts b/packages/app/universal-ts-utils/src/public/object/pick.spec.ts new file mode 100644 index 00000000..725595f8 --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/pick.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { pick } from './pick.js' + +describe('pick', () => { + it('Picks specified fields', () => { + const result = pick( + { + a: 'a', + b: '', + c: ' ', + d: null, + e: {}, + }, + ['a', 'c', 'e'], + ) + expect(result).toStrictEqual({ a: 'a', c: ' ', e: {} }) + }) + + it('Ignores missing fields', () => { + const result = pick( + { + a: 'a', + b: '', + c: ' ', + d: null, + e: {}, + }, + ['a', 'f', 'g'] as any, + ) + + expect(result).toStrictEqual({ a: 'a' }) + }) + + it('Includes undefined and null fields by default', () => { + const result = pick( + { + a: 'a', + b: undefined, + c: {}, + d: null, + }, + ['a', 'b', 'd'], + ) + + expect(result).toStrictEqual({ a: 'a', b: undefined, d: null }) + }) + + it('Skips undefined and null fields if it is specified ', () => { + const obj = { + a: 'a', + b: undefined, + c: {}, + d: null, + } + + expect(pick(obj, ['a', 'b', 'd'], { keepUndefined: false, keepNull: false })).toStrictEqual({ + a: 'a', + }) + expect(pick(obj, ['a', 'b', 'd'], { keepNull: false })).toStrictEqual({ + a: 'a', + b: undefined, + }) + expect(pick(obj, ['a', 'b', 'd'], { keepUndefined: false })).toStrictEqual({ + a: 'a', + d: null, + }) + }) +}) diff --git a/packages/app/universal-ts-utils/src/public/object/pick.ts b/packages/app/universal-ts-utils/src/public/object/pick.ts new file mode 100644 index 00000000..3845c38f --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/pick.ts @@ -0,0 +1,67 @@ +import type { RecordKeyType } from '../../internal/types.js' + +export type PickOptions = { + keepUndefined?: boolean + keepNull?: boolean +} + +// Type utility to filter out keys based on type `T` and PickOptions `O` +type FilteredKeys = { + [P in K]: (O['keepUndefined'] extends false ? (undefined extends T[P] ? never : P) : P) & + (O['keepNull'] extends false ? (null extends T[P] ? never : P) : P) +}[K] + +// Main type to conditionally pick properties based on options +type PickOutput = Pick> + +/** + * Picks specified properties from an object and returns a new object with those properties. + * + * This function allows you to create a subset of an object by specifying which properties + * should be picked. You can also control whether properties with `undefined` or `null` + * values should be included in the result through the options parameter. + * + * @template T - The type of the source object. + * @template K - The type of the keys to be picked from the source object. + * + * @param {T} source - The source object to pick properties from. + * @param {K[]} propNames - An array of property names to be picked from the source object. + * @param {PickOptions} [options] - Optional settings to control whether `undefined` or `null` + * values are kept in the result: + * - `keepUndefined`: If false, properties with `undefined` values are skipped. Defaults to true. + * - `keepNull`: If false, properties with `null` values are skipped. Defaults to true. + * + * @return An object containing the picked properties. + */ +export const pick = < + T extends Record, + K extends keyof T, + O extends PickOptions = { keepUndefined: true; keepNull: true }, +>( + source: T, + propNames: readonly K[], + options?: O, +): PickOutput => { + const result = {} as T + + for (const prop of propNames) { + if (shouldBePicked(source, prop, options)) result[prop] = source[prop] + } + + return result +} + +const shouldBePicked = , K extends keyof T>( + source: T, + propName: K, + options?: PickOptions, +): boolean => { + if (!(propName in source)) return false + + const sourceValue = source[propName] + + if (sourceValue === undefined && options?.keepUndefined === false) return false + if (sourceValue === null && options?.keepNull === false) return false + + return true +} diff --git a/packages/app/universal-ts-utils/src/public/object/transformToKebabCase.spec.ts b/packages/app/universal-ts-utils/src/public/object/transformToKebabCase.spec.ts new file mode 100644 index 00000000..0ff1900b --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/transformToKebabCase.spec.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest' +import { transformToKebabCase } from './transformToKebabCase.js' + +describe('transformToKebabCase', () => { + it('handle simple null and undefined', () => { + const result1: null = transformToKebabCase(null) + expect(result1).toBe(null) + + const result2: undefined = transformToKebabCase(undefined) + expect(result2).toBe(undefined) + }) + + it('handle null and undefined in object', () => { + type MyType = { + my_first_undefined?: number + mySecondUndefined?: number + my_first_null: number | null + mySecondNull: number | null + nested?: MyType + } + + type MyExpectedType = { + 'my-first-undefined'?: number + 'my-second-undefined'?: number + 'my-first-null': number | null + 'my-second-null': number | null + nested?: MyExpectedType + } + + const input: MyType = { + my_first_undefined: undefined, + mySecondUndefined: 1, + my_first_null: null, + mySecondNull: 2, + nested: { + my_first_undefined: undefined, + mySecondUndefined: 3, + my_first_null: null, + mySecondNull: 4, + }, + } + const result: MyExpectedType = transformToKebabCase(input) + + expect(result).toEqual({ + 'my-first-undefined': undefined, + 'my-second-undefined': 1, + 'my-first-null': null, + 'my-second-null': 2, + nested: { + 'my-first-undefined': undefined, + 'my-second-undefined': 3, + 'my-first-null': null, + 'my-second-null': 4, + }, + } satisfies MyExpectedType) + }) + + it('handle arrays', () => { + const input = [ + { helloWorld: 'world', my_normal_array: [1, 2] }, + { + goodBy: 'world', + my_object_array: [{ myFriend: true }, { myLaptop: false }], + }, + ] + const result = transformToKebabCase(input) + + expect(result).toEqual([ + { 'hello-world': 'world', 'my-normal-array': [1, 2] }, + { + 'good-by': 'world', + 'my-object-array': [{ 'my-friend': true }, { 'my-laptop': false }], + }, + ]) + }) + + describe('camelCase', () => { + it('works with simple objects', () => { + type MyType = { + myProp: string + mySecondProp: number + extra: string + } + type MyExpectedType = { + 'my-prop': string + 'my-second-prop': number + extra: string + } + + const input: MyType = { + myProp: 'example', + mySecondProp: 1, + extra: 'extra', + } + const result: MyExpectedType = transformToKebabCase(input) + + expect(result).toEqual({ + 'my-prop': 'example', + 'my-second-prop': 1, + extra: 'extra', + } satisfies MyExpectedType) + }) + + it('works with sub objects', () => { + type MyType = { + myProp: string + mySecondProp: { + thirdProp: number + extra: number + } + } + type MyExpectedType = { + 'my-prop': string + 'my-second-prop': { + 'third-prop': number + extra: number + } + } + + const input: MyType = { + myProp: 'example', + mySecondProp: { thirdProp: 1, extra: 1 }, + } + const result: MyExpectedType = transformToKebabCase(input) + + expect(result).toEqual({ + 'my-prop': 'example', + 'my-second-prop': { 'third-prop': 1, extra: 1 }, + } satisfies MyExpectedType) + }) + + it('abbreviations', () => { + type MyType = { + myHTTPKey: string + } + type MyExpectedType = { + 'my-http-key': string + } + + const input: MyType = { myHTTPKey: 'myValue' } + const result: MyExpectedType = transformToKebabCase(input) + + expect(result).toEqual({ + 'my-http-key': 'myValue', + } satisfies MyExpectedType) + }) + + it('handling non-alphanumeric symbols', () => { + type MyType = { + myProp: string + 'my_second.prop:example': number + } + type MyExpectedType = { + 'my-prop': string + 'my-second.prop:example': number + } + + const input: MyType = { myProp: 'example', 'my_second.prop:example': 1 } + const result: MyExpectedType = transformToKebabCase(input) + + expect(result).toEqual({ + 'my-prop': 'example', + 'my-second.prop:example': 1, + }) + }) + }) + + describe('snake_case', () => { + it('snake_case works with simple objects', () => { + type MyType = { + my_prop: string + my_second_prop: number + extra: string + } + type MyExpectedType = { + 'my-prop': string + 'my-second-prop': number + extra: string + } + + const input: MyType = { + my_prop: 'example', + my_second_prop: 1, + extra: 'extra', + } + const result: MyExpectedType = transformToKebabCase(input) + + expect(result).toEqual({ + 'my-prop': 'example', + 'my-second-prop': 1, + extra: 'extra', + } satisfies MyExpectedType) + }) + + it('works with sub objects', () => { + type MyType = { + my_prop: string + my_second_prop: { + third_prop: number + extra: number + } + } + type MyExpectedType = { + 'my-prop': string + 'my-second-prop': { + 'third-prop': number + extra: number + } + } + + const input: MyType = { + my_prop: 'example', + my_second_prop: { third_prop: 1, extra: 1 }, + } + const result: MyExpectedType = transformToKebabCase(input) + + expect(result).toEqual({ + 'my-prop': 'example', + 'my-second-prop': { 'third-prop': 1, extra: 1 }, + } satisfies MyExpectedType) + }) + }) +}) diff --git a/packages/app/universal-ts-utils/src/public/object/transformToKebabCase.ts b/packages/app/universal-ts-utils/src/public/object/transformToKebabCase.ts new file mode 100644 index 00000000..3bed3128 --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/object/transformToKebabCase.ts @@ -0,0 +1,57 @@ +type TransformToKebabCaseInputType = Record | null | undefined +type TransformToKebabCaseReturnType = Input extends Record + ? Output + : Input + +/** + * Transforms the keys of an object or array of objects from camelCase or snake_case to kebab-case. + * This transformation is applied recursively, ensuring any nested objects are also processed. + * Non-object inputs are returned unchanged. + * + * @param {Record | Record[]} object - The object(s) whose keys will be transformed. + * @returns {Record | Record[]} The object(s) with keys converted to kebab-case. + * + * @example + * ```typescript + * const obj = { myId: 1, creationId: 1, metaObj: { updateId: 1 } } + * const result = transformToKebabCase(obj) + * console.log(result) // { 'my-id': 1, 'creation-date': 1, meta-obj: { 'update-date': 1 } } + * ``` + */ +export function transformToKebabCase< + Output extends Record, + Input extends TransformToKebabCaseInputType, +>(object: Input): TransformToKebabCaseReturnType +export function transformToKebabCase< + Output extends Record, + Input extends TransformToKebabCaseInputType, +>(object: Input[]): TransformToKebabCaseReturnType[] +export function transformToKebabCase( + object: Input | Input[], +): TransformToKebabCaseReturnType | TransformToKebabCaseReturnType[] { + if (Array.isArray(object)) { + return object.map(transformToKebabCase) as TransformToKebabCaseReturnType[] + } + + if (typeof object !== 'object' || object === null || object === undefined) { + return object as TransformToKebabCaseReturnType + } + + return Object.entries(object as Record).reduce( + (result, [key, value]) => { + result[transformKey(key)] = + value && typeof value === 'object' + ? transformToKebabCase(value as TransformToKebabCaseInputType) + : value + return result + }, + {} as Record, + ) as TransformToKebabCaseReturnType +} + +const transformKey = (key: string): string => + key + .replace(/([a-z])([A-Z])/g, '$1-$2') // transforms basic camelCase + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // transforms abbreviations + .replace(/_/g, '-') // transforms snake_case + .toLowerCase() // finally lowercase all