From 2bcb429c39c67c7790cfa22792aa694179ed98eb Mon Sep 17 00:00:00 2001 From: Daniel Karski Date: Fri, 25 Oct 2024 09:08:27 +0200 Subject: [PATCH] [CP-3166] Enhance Sorting Logic with Grouping, Null Handling, and Case Insensitivity (#2116) --- .../lib/feature/data-provider-config.test.ts | 4 +- .../src/lib/feature/data-provider-config.ts | 39 +- .../feature/src/lib/setup-component.tsx | 8 +- libs/generic-view/utils/src/index.ts | 5 +- .../data-filter.test.ts} | 38 +- .../data-filter.ts} | 10 +- .../index.ts | 3 +- .../data-provider-sort.test.ts | 156 ----- .../data-provider-sort.ts | 58 -- .../src/lib/data-sort/data-sort.helpers.ts | 88 +++ .../utils/src/lib/data-sort/data-sort.test.ts | 582 ++++++++++++++++++ .../utils/src/lib/data-sort/data-sort.ts | 86 +++ .../utils/src/lib/data-sort/index.ts | 6 + .../string-to-regex.test.ts | 0 .../string-to-regex.ts | 0 15 files changed, 827 insertions(+), 256 deletions(-) rename libs/generic-view/utils/src/lib/{data-provider-helpers/data-provider-filter.test.ts => data-filter/data-filter.test.ts} (69%) rename libs/generic-view/utils/src/lib/{data-provider-helpers/data-provider-filter.ts => data-filter/data-filter.ts} (67%) rename libs/generic-view/utils/src/lib/{data-provider-helpers => data-filter}/index.ts (66%) delete mode 100644 libs/generic-view/utils/src/lib/data-provider-helpers/data-provider-sort.test.ts delete mode 100644 libs/generic-view/utils/src/lib/data-provider-helpers/data-provider-sort.ts create mode 100644 libs/generic-view/utils/src/lib/data-sort/data-sort.helpers.ts create mode 100644 libs/generic-view/utils/src/lib/data-sort/data-sort.test.ts create mode 100644 libs/generic-view/utils/src/lib/data-sort/data-sort.ts create mode 100644 libs/generic-view/utils/src/lib/data-sort/index.ts rename libs/generic-view/utils/src/lib/{data-provider-helpers => string-to-regex}/string-to-regex.test.ts (100%) rename libs/generic-view/utils/src/lib/{data-provider-helpers => string-to-regex}/string-to-regex.ts (100%) diff --git a/libs/device/models/src/lib/feature/data-provider-config.test.ts b/libs/device/models/src/lib/feature/data-provider-config.test.ts index 59925e6826..d8e78a9a20 100644 --- a/libs/device/models/src/lib/feature/data-provider-config.test.ts +++ b/libs/device/models/src/lib/feature/data-provider-config.test.ts @@ -12,7 +12,7 @@ describe("dataProviderSchema", () => { entitiesType: "someType", sort: [ { - providerField: "someField", + field: "someField", direction: "asc", priority: 1, orderingPatterns: ["/pattern/"], @@ -20,7 +20,7 @@ describe("dataProviderSchema", () => { ], filters: [ { - providerField: "someField", + field: "someField", patterns: ["/pattern/"], }, ], diff --git a/libs/device/models/src/lib/feature/data-provider-config.ts b/libs/device/models/src/lib/feature/data-provider-config.ts index afe0d6e82f..41fddc4a44 100644 --- a/libs/device/models/src/lib/feature/data-provider-config.ts +++ b/libs/device/models/src/lib/feature/data-provider-config.ts @@ -64,23 +64,46 @@ export type DataProviderField = | z.infer | z.infer +const sortDirectionSchema = z.union([z.literal("asc"), z.literal("desc")]) + +export type SortDirection = z.infer + +const sortOrderingPatternsSchema = z.array(regexSchema) + +export type SortOrderingPatterns = z.infer + +// Types come from Intl.CollatorOptions["sensitivity"], used to control text comparison sensitivity +const sortSensitivitySchema = z.enum(["base", "accent", "case", "variant"]) + +export type SortSensitivity = z.infer + +const emptyOrderSchema = z.enum(["first", "last"]) + const sortSchema = z .array( - z.object({ - providerField: z.string(), - priority: z.number().nonnegative(), - direction: z.union([z.literal("asc"), z.literal("desc")]), - orderingPatterns: z.array(regexSchema).optional(), - }) + z + .object({ + field: z.string().optional(), + fieldGroup: z.array(z.string()).optional(), + priority: z.number().nonnegative(), + direction: sortDirectionSchema, + orderingPatterns: sortOrderingPatternsSchema.optional(), + sensitivity: sortSensitivitySchema.optional(), + emptyOrder: emptyOrderSchema.optional(), + }) + .refine((data) => data.field || data.fieldGroup, { + message: "Either field or fieldGroup must be provided", + path: ["field", "fieldGroup"], + }) ) .optional() -export type DataProviderSortConfig = z.infer +export type DataSortConfig = z.infer const filtersSchema = z .array( z.object({ - providerField: z.string(), + field: z.string(), patterns: z.array(regexSchema), }) ) diff --git a/libs/generic-view/feature/src/lib/setup-component.tsx b/libs/generic-view/feature/src/lib/setup-component.tsx index 5ed9e43377..60f5bf3455 100644 --- a/libs/generic-view/feature/src/lib/setup-component.tsx +++ b/libs/generic-view/feature/src/lib/setup-component.tsx @@ -24,8 +24,8 @@ import { useFormField, } from "generic-view/store" import { - dataProviderFilter, - dataProviderSort, + dataFilter, + dataSort, mapLayoutSizes, RecursiveComponent, useViewFormContext, @@ -120,11 +120,11 @@ export const setupComponent =

( }) if (dataProvider?.source === "entities-array") { - const filteredData = dataProviderFilter( + const filteredData = dataFilter( [...entitiesData], dataProvider.filters ) - const sortedData = dataProviderSort([...filteredData], dataProvider.sort) + const sortedData = dataSort([...filteredData], dataProvider.sort) editableProps.data = sortedData?.map((item) => item[idFieldKey!]) } else if (dataProvider?.source === "entities-field") { if (entityData) { diff --git a/libs/generic-view/utils/src/index.ts b/libs/generic-view/utils/src/index.ts index fc8ef582eb..bc4e5face0 100644 --- a/libs/generic-view/utils/src/index.ts +++ b/libs/generic-view/utils/src/index.ts @@ -11,7 +11,8 @@ export * from "./lib/view-generators/generate-view-config" export * from "./lib/map-layout-sizes/map-layout-sizes" export * from "./lib/models/modal.types" export * from "./lib/get-base-device-info" -export * from "./lib/data-provider-helpers" +export * from "./lib/data-filter" +export * from "./lib/data-sort" export * from "./lib/forms-provider/forms-provider" -export * from "./lib/data-provider-helpers/string-to-regex" +export * from "./lib/string-to-regex/string-to-regex" export * from "./lib/use-current-view-key" diff --git a/libs/generic-view/utils/src/lib/data-provider-helpers/data-provider-filter.test.ts b/libs/generic-view/utils/src/lib/data-filter/data-filter.test.ts similarity index 69% rename from libs/generic-view/utils/src/lib/data-provider-helpers/data-provider-filter.test.ts rename to libs/generic-view/utils/src/lib/data-filter/data-filter.test.ts index 8bed873e86..5be3b85b00 100644 --- a/libs/generic-view/utils/src/lib/data-provider-helpers/data-provider-filter.test.ts +++ b/libs/generic-view/utils/src/lib/data-filter/data-filter.test.ts @@ -3,40 +3,40 @@ * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md */ -import { dataProviderFilter } from "./data-provider-filter" +import { dataFilter } from "./data-filter" import { DataProviderFiltersConfig } from "device/models" describe("dataProviderFilter", () => { it("returns all data when no filters are provided", () => { const data = [{ name: "Alice" }, { name: "Bob" }] - const result = dataProviderFilter(data) + const result = dataFilter(data) expect(result).toEqual(data) }) it("filters data based on single field with single pattern", () => { const data = [{ name: "Alice" }, { name: "Bob" }] const filters: DataProviderFiltersConfig = [ - { providerField: "name", patterns: ["/Alice/"] }, + { field: "name", patterns: ["/Alice/"] }, ] - const result = dataProviderFilter(data, filters) + const result = dataFilter(data, filters) expect(result).toEqual([{ name: "Alice" }]) }) it("filters data based on nested field with single pattern", () => { const data = [{ user: { name: "Alice" } }, { user: { name: "Bob" } }] const filters: DataProviderFiltersConfig = [ - { providerField: "user.name", patterns: ["/Alice/"] }, + { field: "user.name", patterns: ["/Alice/"] }, ] - const result = dataProviderFilter(data, filters) + const result = dataFilter(data, filters) expect(result).toEqual([{ user: { name: "Alice" } }]) }) it("filters data based on single field with multiple patterns", () => { const data = [{ name: "Alice" }, { name: "Anastasia" }, { name: "Charlie" }] const filters: DataProviderFiltersConfig = [ - { providerField: "name", patterns: ["/^A/m", "/.+e$/m"] }, + { field: "name", patterns: ["/^A/m", "/.+e$/m"] }, ] - const result = dataProviderFilter(data, filters) + const result = dataFilter(data, filters) expect(result).toEqual([{ name: "Alice" }]) }) @@ -47,10 +47,10 @@ describe("dataProviderFilter", () => { { name: "Agnes", age: "30" }, ] const filters: DataProviderFiltersConfig = [ - { providerField: "name", patterns: ["/^A/m"] }, - { providerField: "age", patterns: ["/2[\\d]/"] }, + { field: "name", patterns: ["/^A/m"] }, + { field: "age", patterns: ["/2[\\d]/"] }, ] - const result = dataProviderFilter(data, filters) + const result = dataFilter(data, filters) expect(result).toEqual([ { name: "Alice", age: "25" }, { name: "Anastasia", age: "29" }, @@ -60,39 +60,39 @@ describe("dataProviderFilter", () => { it("returns empty array when no data matches the filters", () => { const data = [{ name: "Alice" }, { name: "Bob" }] const filters: DataProviderFiltersConfig = [ - { providerField: "name", patterns: ["/Charlie/"] }, + { field: "name", patterns: ["/Charlie/"] }, ] - const result = dataProviderFilter(data, filters) + const result = dataFilter(data, filters) expect(result).toEqual([]) }) it("handles empty data array", () => { const data: Record[] = [] const filters: DataProviderFiltersConfig = [ - { providerField: "name", patterns: ["/Alice/"] }, + { field: "name", patterns: ["/Alice/"] }, ] - const result = dataProviderFilter(data, filters) + const result = dataFilter(data, filters) expect(result).toEqual([]) }) it("handles undefined data", () => { const filters: DataProviderFiltersConfig = [ - { providerField: "name", patterns: ["/Alice/"] }, + { field: "name", patterns: ["/Alice/"] }, ] - const result = dataProviderFilter(undefined, filters) + const result = dataFilter(undefined, filters) expect(result).toEqual([]) }) it("handles undefined filters", () => { const data = [{ name: "Alice" }, { name: "Bob" }] - const result = dataProviderFilter(data, undefined) + const result = dataFilter(data, undefined) expect(result).toEqual(data) }) it("handles empty filters", () => { const data = [{ name: "Alice" }, { name: "Bob" }] const filters = [] as DataProviderFiltersConfig - const result = dataProviderFilter(data, filters) + const result = dataFilter(data, filters) expect(result).toEqual(data) }) }) diff --git a/libs/generic-view/utils/src/lib/data-provider-helpers/data-provider-filter.ts b/libs/generic-view/utils/src/lib/data-filter/data-filter.ts similarity index 67% rename from libs/generic-view/utils/src/lib/data-provider-helpers/data-provider-filter.ts rename to libs/generic-view/utils/src/lib/data-filter/data-filter.ts index 6d47a7e8cc..ef65f2e8c3 100644 --- a/libs/generic-view/utils/src/lib/data-provider-helpers/data-provider-filter.ts +++ b/libs/generic-view/utils/src/lib/data-filter/data-filter.ts @@ -4,21 +4,21 @@ */ import { DataProviderFiltersConfig } from "device/models" -import { stringToRegex } from "./string-to-regex" +import { stringToRegex } from "../string-to-regex/string-to-regex" import { cloneDeep, get } from "lodash" -export const dataProviderFilter = ( +export const dataFilter = ( data: Record[] = [], filters?: DataProviderFiltersConfig ) => { if (!filters || !data) return data return data.filter((item) => { - return cloneDeep(filters).every(({ providerField, patterns }) => { - const field = get(item, providerField) as string + return cloneDeep(filters).every(({ field, patterns }) => { + const value = get(item, field) as string return patterns.every((pattern) => { const regex = stringToRegex(pattern) - return regex.test(field || "") + return regex.test(value || "") }) }) }) diff --git a/libs/generic-view/utils/src/lib/data-provider-helpers/index.ts b/libs/generic-view/utils/src/lib/data-filter/index.ts similarity index 66% rename from libs/generic-view/utils/src/lib/data-provider-helpers/index.ts rename to libs/generic-view/utils/src/lib/data-filter/index.ts index 167c355b9f..41052f0f60 100644 --- a/libs/generic-view/utils/src/lib/data-provider-helpers/index.ts +++ b/libs/generic-view/utils/src/lib/data-filter/index.ts @@ -3,5 +3,4 @@ * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md */ -export * from "./data-provider-sort" -export * from "./data-provider-filter" +export * from "./data-filter" diff --git a/libs/generic-view/utils/src/lib/data-provider-helpers/data-provider-sort.test.ts b/libs/generic-view/utils/src/lib/data-provider-helpers/data-provider-sort.test.ts deleted file mode 100644 index c3d8e5e2d2..0000000000 --- a/libs/generic-view/utils/src/lib/data-provider-helpers/data-provider-sort.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Copyright (c) Mudita sp. z o.o. All rights reserved. - * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md - */ - -import { dataProviderSort } from "./data-provider-sort" -import { DataProviderSortConfig } from "device/models" - -describe("dataProviderSort", () => { - it("returns all data when no sort configuration is provided", () => { - const data = [{ name: "Alice" }, { name: "Bob" }] - const result = dataProviderSort(data) - expect(result).toEqual(data) - }) - - it("sorts data based on single field in ascending order", () => { - const data = [{ name: "Charlie" }, { name: "Alice" }, { name: "Bob" }] - const sort = [ - { providerField: "name", direction: "asc", priority: 1 }, - ] as DataProviderSortConfig - const result = dataProviderSort(data, sort) - expect(result).toEqual([ - { name: "Alice" }, - { name: "Bob" }, - { name: "Charlie" }, - ]) - }) - - it("sorts data based on nested field in ascending order", () => { - const data = [ - { user: { name: "Charlie" } }, - { user: { name: "Alice" } }, - { user: { name: "Bob" } }, - ] - const sort: DataProviderSortConfig = [ - { providerField: "user.name", direction: "asc", priority: 1 }, - ] - const result = dataProviderSort(data, sort) - expect(result).toEqual([ - { user: { name: "Alice" } }, - { user: { name: "Bob" } }, - { user: { name: "Charlie" } }, - ]) - }) - - it("sorts data based on single field in descending order", () => { - const data = [{ name: "Charlie" }, { name: "Alice" }, { name: "Bob" }] - const sort: DataProviderSortConfig = [ - { providerField: "name", direction: "desc", priority: 1 }, - ] - const result = dataProviderSort(data, sort) - expect(result).toEqual([ - { name: "Charlie" }, - { name: "Bob" }, - { name: "Alice" }, - ]) - }) - - it("sorts data based on multiple fields with different priorities", () => { - const data = [ - { name: "Alice", age: "30" }, - { name: "Alice", age: "25" }, - { name: "Bob", age: "20" }, - ] - const sort: DataProviderSortConfig = [ - { providerField: "name", direction: "asc", priority: 1 }, - { providerField: "age", direction: "asc", priority: 2 }, - ] - const result = dataProviderSort(data, sort) - expect(result).toEqual([ - { name: "Alice", age: "25" }, - { name: "Alice", age: "30" }, - { name: "Bob", age: "20" }, - ]) - }) - - it("sorts incomplete data based on multiple fields with different priorities", () => { - const data = [ - { name: "Bob", surname: "Smith" }, - { name: "Alice" }, - { name: "Alice", surname: "Smith" }, - ] - const sort: DataProviderSortConfig = [ - { providerField: "name", direction: "asc", priority: 2 }, - { providerField: "surname", direction: "asc", priority: 1 }, - ] - const result = dataProviderSort(data, sort) - expect(result).toEqual([ - { - name: "Alice", - }, - { - name: "Alice", - surname: "Smith", - }, - { - name: "Bob", - surname: "Smith", - }, - ]) - }) - - it("sorts data based on ordering patterns ", () => { - const data = [ - { name: "Charlie" }, - { name: "Bob" }, - { name: "Alice" }, - { name: "Beatrice" }, - ] - const sort: DataProviderSortConfig = [ - { - providerField: "name", - direction: "asc", - priority: 1, - orderingPatterns: ["/^B/m"], - }, - ] - const result = dataProviderSort(data, sort) - expect(result).toEqual([ - { name: "Beatrice" }, - { name: "Bob" }, - { name: "Alice" }, - { name: "Charlie" }, - ]) - }) - - it("returns empty array when data is empty", () => { - const data: Record[] = [] - const sort: DataProviderSortConfig = [ - { providerField: "name", direction: "asc", priority: 1 }, - ] - const result = dataProviderSort(data, sort) - expect(result).toEqual([]) - }) - - it("handles undefined data", () => { - const sort: DataProviderSortConfig = [ - { providerField: "name", direction: "asc", priority: 1 }, - ] - const result = dataProviderSort(undefined, sort) - expect(result).toEqual([]) - }) - - it("handles undefined sort configuration", () => { - const data = [{ name: "Alice" }, { name: "Bob" }] - const result = dataProviderSort(data, undefined) - expect(result).toEqual(data) - }) - - it("handles empty sort configuration", () => { - const data = [{ name: "Alice" }, { name: "Bob" }] - const sort = [] as DataProviderSortConfig - const result = dataProviderSort(data, sort) - expect(result).toEqual(data) - }) -}) diff --git a/libs/generic-view/utils/src/lib/data-provider-helpers/data-provider-sort.ts b/libs/generic-view/utils/src/lib/data-provider-helpers/data-provider-sort.ts deleted file mode 100644 index e31c54feb2..0000000000 --- a/libs/generic-view/utils/src/lib/data-provider-helpers/data-provider-sort.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Copyright (c) Mudita sp. z o.o. All rights reserved. - * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md - */ - -import { DataProviderSortConfig } from "device/models" -import { stringToRegex } from "./string-to-regex" -import { cloneDeep, get } from "lodash" - -export const dataProviderSort = ( - data: Record[] = [], - sort?: DataProviderSortConfig -) => { - if (!sort || !data) return data - const fieldsSortedByPriority = cloneDeep(sort).sort( - (a, b) => a.priority - b.priority - ) - - return data.sort((a, b) => { - let score = 0 - for (const { - providerField, - direction, - orderingPatterns = [], - } of fieldsSortedByPriority) { - const fieldA = get(a, providerField) as string - const fieldB = get(b, providerField) as string - if (!fieldA || !fieldB) { - continue - } - - for (let i = 0; i < orderingPatterns.length; i++) { - const regex = stringToRegex(orderingPatterns[i]) - const matchA = regex.test(fieldA) - const matchB = regex.test(fieldB) - - if (matchA && !matchB) { - score = -1 - break - } - if (!matchA && matchB) { - score = 1 - break - } - } - if (score === 0) { - score = - direction === "asc" - ? fieldA.localeCompare(fieldB) - : fieldB.localeCompare(fieldA) - if (score !== 0) { - break - } - } - } - return score - }) -} diff --git a/libs/generic-view/utils/src/lib/data-sort/data-sort.helpers.ts b/libs/generic-view/utils/src/lib/data-sort/data-sort.helpers.ts new file mode 100644 index 0000000000..109e7db12e --- /dev/null +++ b/libs/generic-view/utils/src/lib/data-sort/data-sort.helpers.ts @@ -0,0 +1,88 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import { cloneDeep, get } from "lodash" +import { + DataSortConfig, + SortDirection, + SortOrderingPatterns, + SortSensitivity, +} from "device/models" +import { stringToRegex } from "../string-to-regex/string-to-regex" + +export const sortByPriority = (sortConfigs: DataSortConfig) => { + if (!sortConfigs) return [] + return cloneDeep(sortConfigs).sort((a, b) => a.priority - b.priority) +} + +export const compareFields = ( + fieldA: string | number, + fieldB: string | number, + direction: SortDirection, + sensitivity: SortSensitivity +) => { + if (typeof fieldA === "number" && typeof fieldB === "number") { + return direction === "asc" ? fieldA - fieldB : fieldB - fieldA + } + + if (typeof fieldA === "string" && typeof fieldB === "string") { + const comparison = fieldA.localeCompare(fieldB, undefined, { sensitivity }) + return direction === "asc" ? comparison : -comparison + } + + if (typeof fieldA === "string" && typeof fieldB === "number") { + return direction === "asc" ? -1 : 1 + } + + if (typeof fieldA === "number" && typeof fieldB === "string") { + return direction === "asc" ? 1 : -1 + } + + return 0 +} + +export const compareWithOrderingPatterns = ( + fieldA: string | number, + fieldB: string | number, + patterns: SortOrderingPatterns, + direction: SortDirection +) => { + const directionMultiplier = direction === "asc" ? 1 : -1 + + for (const pattern of patterns) { + const regex = stringToRegex(pattern) + const matchA = regex.test(String(fieldA)) + const matchB = regex.test(String(fieldB)) + + if (matchA && !matchB) return -1 * directionMultiplier + if (!matchA && matchB) return 1 * directionMultiplier + } + return 0 +} + +export const getFirstNonEmptyField = ( + obj: unknown, + fields: string[] +): unknown => { + const fieldKey = fields.find((field) => { + const value = get(obj, field) + + if (value === null || value === undefined) { + return false + } + + if (typeof value === "string" && value.trim() === "") { + return false + } + + if (typeof value === "number" && isNaN(value)) { + return false + } + + return true + }) + + return fieldKey ? get(obj, fieldKey) : "" +} diff --git a/libs/generic-view/utils/src/lib/data-sort/data-sort.test.ts b/libs/generic-view/utils/src/lib/data-sort/data-sort.test.ts new file mode 100644 index 0000000000..d2d7c2cb66 --- /dev/null +++ b/libs/generic-view/utils/src/lib/data-sort/data-sort.test.ts @@ -0,0 +1,582 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import { dataSort } from "./data-sort" +import { DataSortConfig } from "device/models" + +describe("dataProviderSort", () => { + it("returns all data when no sort configuration is provided", () => { + const data = [{ name: "Alice" }, { name: "Bob" }] + const result = dataSort(data) + expect(result).toEqual(data) + }) + + it("sorts data based on single field in ascending order", () => { + const data = [{ name: "Charlie" }, { name: "Alice" }, { name: "Bob" }] + const sort = [ + { field: "name", direction: "asc", priority: 1 }, + ] as DataSortConfig + const result = dataSort(data, sort) + expect(result).toEqual([ + { name: "Alice" }, + { name: "Bob" }, + { name: "Charlie" }, + ]) + }) + + it("sorts data based on single field in descending order", () => { + const data = [{ name: "Charlie" }, { name: "Alice" }, { name: "Bob" }] + const sort: DataSortConfig = [ + { field: "name", direction: "desc", priority: 1 }, + ] + const result = dataSort(data, sort) + expect(result).toEqual([ + { name: "Charlie" }, + { name: "Bob" }, + { name: "Alice" }, + ]) + }) + + it("sorts data based on nested field in ascending order", () => { + const data = [ + { user: { name: "Charlie" } }, + { user: { name: "Alice" } }, + { user: { name: "Bob" } }, + ] + const sort: DataSortConfig = [ + { field: "user.name", direction: "asc", priority: 1 }, + ] + const result = dataSort(data, sort) + expect(result).toEqual([ + { user: { name: "Alice" } }, + { user: { name: "Bob" } }, + { user: { name: "Charlie" } }, + ]) + }) + + it("sorts data with numeric values as primitive numbers", () => { + const data = [ + { user: { age: 30 } }, + { user: { age: 40 } }, + { user: { age: 25 } }, + ] + const sort: DataSortConfig = [ + { field: "user.age", direction: "asc", priority: 1 }, + ] + const result = dataSort(data, sort) + expect(result).toEqual([ + { user: { age: 25 } }, + { user: { age: 30 } }, + { user: { age: 40 } }, + ]) + }) + + it("sorts numeric values correctly without converting them to strings", () => { + const data = [ + { value: 3 }, + { value: 300 }, + { value: 25 }, + { value: 100 }, + { value: 5 }, + ] + const sort: DataSortConfig = [ + { field: "value", direction: "asc", priority: 1 }, + ] + const result = dataSort(data, sort) + + expect(result).toEqual([ + { value: 3 }, + { value: 5 }, + { value: 25 }, + { value: 100 }, + { value: 300 }, + ]) + }) + + it("sorts strings before numbers with strings in lexicographical order and numbers in ascending order", () => { + const data = [ + { value: "Bob" }, + { value: 1 }, + { value: 3 }, + { value: "Alice" }, + { value: 2 }, + ] + const sort: DataSortConfig = [ + { field: "value", direction: "asc", priority: 1 }, + ] + const result = dataSort(data, sort) + + expect(result).toEqual([ + { value: "Alice" }, + { value: "Bob" }, + { value: 1 }, + { value: 2 }, + { value: 3 }, + ]) + }) + + it("sorts data based on multiple fields with different priorities", () => { + const data = [ + { name: "Alice", age: "30" }, + { name: "Alice", age: "25" }, + { name: "Bob", age: "20" }, + ] + const sort: DataSortConfig = [ + { field: "name", direction: "asc", priority: 1 }, + { field: "age", direction: "asc", priority: 2 }, + ] + const result = dataSort(data, sort) + expect(result).toEqual([ + { name: "Alice", age: "25" }, + { name: "Alice", age: "30" }, + { name: "Bob", age: "20" }, + ]) + }) + + it("sorts incomplete data based on multiple fields with different priorities", () => { + const data = [ + { name: "Bob", surname: "Smith" }, + { name: "Alice" }, + { name: "Alice", surname: "Smith" }, + ] + const sort: DataSortConfig = [ + { field: "surname", direction: "asc", priority: 1 }, + { field: "name", direction: "asc", priority: 2 }, + ] + const result = dataSort(data, sort) + expect(result).toEqual([ + { name: "Alice", surname: "Smith" }, + { name: "Bob", surname: "Smith" }, + { name: "Alice" }, + ]) + }) + + it("sorts data with sensitivity set to 'base' (ignores case and accents)", () => { + const data = [ + { name: "alice" }, + { name: "álice" }, + { name: "Bob" }, + { name: "charlie" }, + { name: "Alice" }, + ] + const sort: DataSortConfig = [ + { + field: "name", + direction: "asc", + priority: 1, + sensitivity: "base", + }, + ] + const result = dataSort(data, sort) + + expect(result).toEqual([ + { name: "alice" }, + { name: "álice" }, + { name: "Alice" }, + { name: "Bob" }, + { name: "charlie" }, + ]) + }) + + it("sorts data with sensitivity set to 'accent' (ignores case, but considers accents)", () => { + const data = [ + { name: "alice" }, + { name: "álice" }, + { name: "Bob" }, + { name: "charlie" }, + { name: "Alice" }, + ] + const sort: DataSortConfig = [ + { + field: "name", + direction: "asc", + priority: 1, + sensitivity: "accent", + }, + ] + const result = dataSort(data, sort) + + expect(result).toEqual([ + { name: "alice" }, + { name: "Alice" }, + { name: "álice" }, + { name: "Bob" }, + { name: "charlie" }, + ]) + }) + + it("sorts data with sensitivity set to 'case' (considers case, but ignores accents)", () => { + const data = [ + { name: "alice" }, + { name: "álice" }, + { name: "Bob" }, + { name: "charlie" }, + { name: "Alice" }, + ] + const sort: DataSortConfig = [ + { + field: "name", + direction: "asc", + priority: 1, + sensitivity: "case", + }, + ] + const result = dataSort(data, sort) + expect(result).toEqual([ + { name: "alice" }, + { name: "álice" }, + { name: "Alice" }, + { name: "Bob" }, + { name: "charlie" }, + ]) + }) + + it("sorts data with sensitivity set to 'variant' (considers both case and accents)", () => { + const data = [ + { name: "alice" }, + { name: "álice" }, + { name: "Bob" }, + { name: "charlie" }, + { name: "Alice" }, + ] + const sort: DataSortConfig = [ + { + field: "name", + direction: "asc", + priority: 1, + sensitivity: "variant", + }, + ] + const result = dataSort(data, sort) + expect(result).toEqual([ + { name: "alice" }, + { name: "Alice" }, + { name: "álice" }, + { name: "Bob" }, + { name: "charlie" }, + ]) + }) + + it("sorts data with empty values first when `emptyOrder` is set to 'first'", () => { + const data = [ + { name: "alice" }, + { name: null }, + { name: "Bob" }, + { name: "charlie" }, + { name: "" }, + { name: undefined }, + ] + const sort: DataSortConfig = [ + { + field: "name", + direction: "asc", + priority: 1, + emptyOrder: "first", + }, + ] + const result = dataSort(data, sort) + + expect(result).toEqual([ + { name: null }, + { name: "" }, + { name: undefined }, + { name: "alice" }, + { name: "Bob" }, + { name: "charlie" }, + ]) + }) + + it("sorts data with empty values last when `emptyOrder` is set to 'last'", () => { + const data = [ + { name: "alice" }, + { name: null }, + { name: "Bob" }, + { name: "charlie" }, + { name: "" }, + { name: undefined }, + ] + const sort: DataSortConfig = [ + { + field: "name", + direction: "asc", + priority: 1, + emptyOrder: "last", + }, + ] + const result = dataSort(data, sort) + + expect(result).toEqual([ + { name: "alice" }, + { name: "Bob" }, + { name: "charlie" }, + { name: null }, + { name: "" }, + { name: undefined }, + ]) + }) + + it("sorts data based on fieldGroup fields when lastName is the same", () => { + const data = [ + { firstName: "Alice", lastName: "Smith" }, + { firstName: "Charlie", lastName: "Smith" }, + { firstName: "Bob", lastName: "Smith" }, + ] + + const sort: DataSortConfig = [ + { + fieldGroup: ["lastName", "firstName"], + direction: "asc", + priority: 1, + }, + ] + + const result = dataSort(data, sort) + + expect(result).toEqual([ + { firstName: "Alice", lastName: "Smith" }, + { firstName: "Bob", lastName: "Smith" }, + { firstName: "Charlie", lastName: "Smith" }, + ]) + }) + + it("handles sorting when lastName is empty and firstName is used", () => { + const data = [ + { firstName: "Alice", lastName: "" }, + { firstName: "Bob", lastName: "" }, + { firstName: "Charlie", lastName: "Smith" }, + ] + + const sortConfig: DataSortConfig = [ + { + fieldGroup: ["lastName", "firstName"], + priority: 1, + direction: "asc", + }, + ] + + const result = dataSort(data, sortConfig) + expect(result).toEqual([ + { firstName: "Alice", lastName: "" }, + { firstName: "Bob", lastName: "" }, + { firstName: "Charlie", lastName: "Smith" }, + ]) + }) + + it("sorts data with empty fieldGroup values first when emptyOrder is 'first'", () => { + const data = [ + { firstName: "Bob", lastName: "Smith", displayName: "Bob Smith" }, + { firstName: "Alice", lastName: null, displayName: "Alice" }, + { firstName: "Yuki", lastName: null, displayName: "Yuki" }, + { firstName: undefined, lastName: undefined, displayName: "Dave" }, + { firstName: "Charlie", lastName: "", displayName: "Charlie" }, + ] + + const sortConfig: DataSortConfig = [ + { + fieldGroup: ["lastName", "firstName"], + priority: 1, + direction: "asc", + emptyOrder: "first", + }, + ] + + const result = dataSort(data, sortConfig) + expect(result).toEqual([ + { firstName: undefined, lastName: undefined, displayName: "Dave" }, + { firstName: "Alice", lastName: null, displayName: "Alice" }, + { firstName: "Charlie", lastName: "", displayName: "Charlie" }, + { firstName: "Bob", lastName: "Smith", displayName: "Bob Smith" }, + { firstName: "Yuki", lastName: null, displayName: "Yuki" }, + ]) + }) + + it("sorts data with empty fieldGroup values last when emptyOrder is 'last'", () => { + const data = [ + { firstName: "Bob", lastName: "Smith", displayName: "Bob Smith" }, + { firstName: "Alice", lastName: null, displayName: "Alice" }, + { firstName: undefined, lastName: undefined, displayName: "Dave" }, + { firstName: "Charlie", lastName: "", displayName: "Charlie" }, + { firstName: "Yuki", lastName: null, displayName: "Yuki" }, + ] + + const sortConfig: DataSortConfig = [ + { + fieldGroup: ["lastName", "firstName"], + priority: 1, + direction: "asc", + emptyOrder: "last", + }, + ] + + const result = dataSort(data, sortConfig) + expect(result).toEqual([ + { firstName: "Alice", lastName: null, displayName: "Alice" }, + { firstName: "Charlie", lastName: "", displayName: "Charlie" }, + { firstName: "Bob", lastName: "Smith", displayName: "Bob Smith" }, + { firstName: "Yuki", lastName: null, displayName: "Yuki" }, + { firstName: undefined, lastName: undefined, displayName: "Dave" }, + ]) + }) + + it("sorts data based on ordering patterns", () => { + const data = [ + { name: "Charlie" }, + { name: "Bob" }, + { name: "Alice" }, + { name: "Beatrice" }, + ] + const sort: DataSortConfig = [ + { + field: "name", + direction: "asc", + priority: 1, + orderingPatterns: ["/^B/m"], + }, + ] + const result = dataSort(data, sort) + expect(result).toEqual([ + { name: "Beatrice" }, + { name: "Bob" }, + { name: "Alice" }, + { name: "Charlie" }, + ]) + }) + + it("sorts data using orderingPatterns for alphabetical, numeric, and special character fields", () => { + const data = [ + { displayName: "Anna", firstName: "Anna", lastName: "" }, + { displayName: "490123456789", firstName: "", lastName: "" }, + { displayName: "+48345678902", firstName: "", lastName: "" }, + { displayName: "Michael", firstName: "Michael", lastName: "Brown" }, + { displayName: "home@home.com", firstName: "", lastName: "" }, + ] + + const sortConfig: DataSortConfig = [ + { + fieldGroup: ["displayName"], + priority: 1, + direction: "asc", + orderingPatterns: [ + "/^\\p{L}.*/u", // first alphabetical values + "/^\\d+$/", // then numeric values + "/^[^a-zA-Z\\d\\s@]+$/", // then special characters (non-alphanumeric) + ], + sensitivity: "variant", + }, + ] + + const result = dataSort(data, sortConfig) + expect(result).toEqual([ + { displayName: "Anna", firstName: "Anna", lastName: "" }, + { displayName: "home@home.com", firstName: "", lastName: "" }, + { displayName: "Michael", firstName: "Michael", lastName: "Brown" }, + { displayName: "490123456789", firstName: "", lastName: "" }, + { displayName: "+48345678902", firstName: "", lastName: "" }, + ]) + }) + + it("sorts complex data using `fieldGroup` and `orderingPatterns`, handling empty and numeric values", () => { + const data = [ + { displayName: "Anna", firstName: "Anna", lastName: "" }, + { displayName: "+48345678902", firstName: "", lastName: "" }, + { displayName: "Michael", firstName: "Michael", lastName: "Brown" }, + { displayName: "Numer 12345", firstName: "Numer", lastName: "12345" }, + ] + + const sortConfig: DataSortConfig = [ + { + fieldGroup: ["lastName", "firstName", "displayName"], + priority: 1, + direction: "asc", + orderingPatterns: [ + "/^\\p{L}.*/u", // alphabetic first + "/^\\d+$/", // numeric second + ], + sensitivity: "variant", + }, + ] + + const result = dataSort(data, sortConfig) + expect(result).toEqual([ + { displayName: "Anna", firstName: "Anna", lastName: "" }, + { displayName: "Michael", firstName: "Michael", lastName: "Brown" }, + { displayName: "Numer 12345", firstName: "Numer", lastName: "12345" }, + { displayName: "+48345678902", firstName: "", lastName: "" }, + ]) + }) + + it("sorts data with phone numbers and empty lastName", () => { + const data = [ + { displayName: "+48345678902", firstName: "", lastName: "" }, + { displayName: "Jane Smith", firstName: "Jane", lastName: "Smith" }, + { displayName: "490123456789", firstName: "", lastName: "" }, + { displayName: "Emily Davis", firstName: "Emily", lastName: "Davis" }, + ] + + const sortConfig: DataSortConfig = [ + { + fieldGroup: ["lastName", "displayName"], + priority: 1, + direction: "asc", + orderingPatterns: ["/^\\p{L}.*/u", "/^\\d+$/"], + sensitivity: "variant", + }, + ] + + const result = dataSort(data, sortConfig) + expect(result).toEqual([ + { displayName: "Emily Davis", firstName: "Emily", lastName: "Davis" }, + { displayName: "Jane Smith", firstName: "Jane", lastName: "Smith" }, + { displayName: "490123456789", firstName: "", lastName: "" }, + { displayName: "+48345678902", firstName: "", lastName: "" }, + ]) + }) + + it("returns empty array when data is empty", () => { + const data: Record[] = [] + const sort: DataSortConfig = [ + { field: "name", direction: "asc", priority: 1 }, + ] + const result = dataSort(data, sort) + expect(result).toEqual([]) + }) + + it("handles undefined data", () => { + const sort: DataSortConfig = [ + { field: "name", direction: "asc", priority: 1 }, + ] + const result = dataSort(undefined, sort) + expect(result).toEqual([]) + }) + + it("handles undefined sort configuration", () => { + const data = [{ name: "Alice" }, { name: "Bob" }] + const result = dataSort(data, undefined) + expect(result).toEqual(data) + }) + + it("handles empty sort configuration", () => { + const data = [{ name: "Alice" }, { name: "Bob" }] + const sort = [] as DataSortConfig + const result = dataSort(data, sort) + expect(result).toEqual(data) + }) + + it("returns unsorted data when neither `providerField` nor `fieldGroup` is provided", () => { + const data = [ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + { name: "Charlie", age: 35 }, + ] + const sortConfig: DataSortConfig = [ + { + priority: 1, + direction: "asc", + }, + ] + const result = dataSort(data, sortConfig) + expect(result).toEqual(data) + }) +}) diff --git a/libs/generic-view/utils/src/lib/data-sort/data-sort.ts b/libs/generic-view/utils/src/lib/data-sort/data-sort.ts new file mode 100644 index 0000000000..b524e7e7ca --- /dev/null +++ b/libs/generic-view/utils/src/lib/data-sort/data-sort.ts @@ -0,0 +1,86 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import { DataSortConfig } from "device/models" +import isEmpty from "lodash/isEmpty" +import { + compareFields, + compareWithOrderingPatterns, + getFirstNonEmptyField, + sortByPriority, +} from "./data-sort.helpers" + +export const dataSort = ( + data: Record[] = [], + configs?: DataSortConfig +) => { + if (!configs || !data) return data + + const sortedConfigs = sortByPriority(configs) + + return data.sort((a, b) => { + for (const { + field, + fieldGroup, + direction, + orderingPatterns = [], + sensitivity = "variant", + emptyOrder = "last", + } of sortedConfigs) { + const fields = field ? [field] : fieldGroup + if (!fields) continue + let index = 0 + + while (index < fields.length) { + const remainingFields = fields.slice(index) + const fieldA = getFirstNonEmptyField(a, remainingFields) + const fieldB = getFirstNonEmptyField(b, remainingFields) + + if (fieldA === fieldB) { + index++ + continue + } + + if (isEmpty(fieldA) && !isEmpty(fieldB)) { + return emptyOrder === "first" ? -1 : 1 + } else if (!isEmpty(fieldA) && isEmpty(fieldB)) { + return emptyOrder === "first" ? 1 : -1 + } + + if ( + (typeof fieldA !== "string" && typeof fieldA !== "number") || + (typeof fieldB !== "string" && typeof fieldB !== "number") + ) { + index++ + continue + } + + const regexComparison = compareWithOrderingPatterns( + fieldA, + fieldB, + orderingPatterns, + direction + ) + if (regexComparison !== 0) { + return regexComparison + } + + const fieldComparison = compareFields( + fieldA, + fieldB, + direction, + sensitivity + ) + if (fieldComparison !== 0) { + return fieldComparison + } else { + index++ + } + } + } + + return 0 + }) +} diff --git a/libs/generic-view/utils/src/lib/data-sort/index.ts b/libs/generic-view/utils/src/lib/data-sort/index.ts new file mode 100644 index 0000000000..58f4bb5fa2 --- /dev/null +++ b/libs/generic-view/utils/src/lib/data-sort/index.ts @@ -0,0 +1,6 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +export * from "./data-sort" diff --git a/libs/generic-view/utils/src/lib/data-provider-helpers/string-to-regex.test.ts b/libs/generic-view/utils/src/lib/string-to-regex/string-to-regex.test.ts similarity index 100% rename from libs/generic-view/utils/src/lib/data-provider-helpers/string-to-regex.test.ts rename to libs/generic-view/utils/src/lib/string-to-regex/string-to-regex.test.ts diff --git a/libs/generic-view/utils/src/lib/data-provider-helpers/string-to-regex.ts b/libs/generic-view/utils/src/lib/string-to-regex/string-to-regex.ts similarity index 100% rename from libs/generic-view/utils/src/lib/data-provider-helpers/string-to-regex.ts rename to libs/generic-view/utils/src/lib/string-to-regex/string-to-regex.ts