Add comprehensive library expansion with new components and demos

- Add new libraries: ui-accessibility, ui-animations, ui-backgrounds, ui-code-display, ui-data-utils, ui-font-manager, hcl-studio
- Add extensive layout components: gallery-grid, infinite-scroll-container, kanban-board, masonry, split-view, sticky-layout
- Add comprehensive demo components for all new features
- Update project configuration and dependencies
- Expand component exports and routing structure
- Add UI landing pages planning document

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
skyai_dev
2025-09-05 05:37:37 +10:00
parent 876eb301a0
commit 5346d6d0c9
5476 changed files with 350855 additions and 10 deletions

View File

@@ -0,0 +1,63 @@
# UiDataUtils
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.0.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the library, run:
```bash
ng build ui-data-utils
```
This command will compile your project, and the build artifacts will be placed in the `dist/` directory.
### Publishing the Library
Once the project is built, you can publish your library by following these steps:
1. Navigate to the `dist` directory:
```bash
cd dist/ui-data-utils
```
2. Run the `npm publish` command to publish your library to the npm registry:
```bash
npm publish
```
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -0,0 +1,7 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/ui-data-utils",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "ui-data-utils",
"version": "0.0.1",
"description": "Data manipulation helpers for sorting, filtering, pagination, and transformation",
"keywords": ["angular", "data", "utilities", "sorting", "filtering", "pagination", "transformation"],
"peerDependencies": {
"@angular/common": "^19.2.0",
"@angular/core": "^19.2.0"
},
"dependencies": {
"tslib": "^2.3.0"
},
"sideEffects": false
}

View File

@@ -0,0 +1,360 @@
/**
* Filtering utilities for data manipulation
*/
import { FilterConfig, FilterOperator, DataType } from './types';
/**
* Get nested property value from object
* @param obj - Object to get value from
* @param path - Property path (supports dot notation)
*/
function getNestedValue(obj: any, path: string | keyof any): any {
if (typeof path === 'string' && path.includes('.')) {
return path.split('.').reduce((current, key) => current?.[key], obj);
}
return obj?.[path];
}
/**
* Convert value to appropriate type for comparison
* @param value - Value to convert
* @param dataType - Target data type
*/
function convertFilterValue(value: any, dataType: DataType): any {
if (value == null) return value;
switch (dataType) {
case 'string':
return String(value);
case 'number':
const num = Number(value);
return isNaN(num) ? 0 : num;
case 'date':
if (value instanceof Date) return value;
const date = new Date(value);
return isNaN(date.getTime()) ? new Date(0) : date;
case 'boolean':
return Boolean(value);
default:
return value;
}
}
/**
* Check if value matches filter condition
* @param itemValue - Value from the data item
* @param filterValue - Value to compare against
* @param operator - Filter operator
* @param dataType - Data type for proper comparison
*/
function matchesFilter(
itemValue: any,
filterValue: any,
operator: FilterOperator,
dataType: DataType = 'string'
): boolean {
// Handle empty values
if (operator === 'is_empty') {
return itemValue == null || itemValue === '' || (Array.isArray(itemValue) && itemValue.length === 0);
}
if (operator === 'is_not_empty') {
return itemValue != null && itemValue !== '' && !(Array.isArray(itemValue) && itemValue.length === 0);
}
// Convert values for comparison
const convertedItemValue = convertFilterValue(itemValue, dataType);
// Handle array filter values (for 'in' and 'not_in')
if (Array.isArray(filterValue)) {
const convertedFilterValues = filterValue.map(v => convertFilterValue(v, dataType));
switch (operator) {
case 'in':
return convertedFilterValues.some(fv =>
dataType === 'string'
? convertedItemValue?.toLowerCase() === fv?.toLowerCase()
: convertedItemValue === fv
);
case 'not_in':
return !convertedFilterValues.some(fv =>
dataType === 'string'
? convertedItemValue?.toLowerCase() === fv?.toLowerCase()
: convertedItemValue === fv
);
}
}
const convertedFilterValue = convertFilterValue(filterValue, dataType);
// Handle between operator (expects array with 2 values)
if (operator === 'between') {
if (!Array.isArray(filterValue) || filterValue.length !== 2) return false;
const [min, max] = filterValue.map(v => convertFilterValue(v, dataType));
return convertedItemValue >= min && convertedItemValue <= max;
}
// Handle null/undefined item values for operators that don't handle empty values
if (convertedItemValue == null) {
return operator === 'not_equals';
}
switch (operator) {
case 'equals':
return dataType === 'string'
? convertedItemValue?.toLowerCase() === convertedFilterValue?.toLowerCase()
: convertedItemValue === convertedFilterValue;
case 'not_equals':
return dataType === 'string'
? convertedItemValue?.toLowerCase() !== convertedFilterValue?.toLowerCase()
: convertedItemValue !== convertedFilterValue;
case 'contains':
if (dataType !== 'string') return false;
return convertedItemValue?.toLowerCase()?.includes(convertedFilterValue?.toLowerCase()) || false;
case 'not_contains':
if (dataType !== 'string') return true;
return !convertedItemValue?.toLowerCase()?.includes(convertedFilterValue?.toLowerCase());
case 'starts_with':
if (dataType !== 'string') return false;
return convertedItemValue?.toLowerCase()?.startsWith(convertedFilterValue?.toLowerCase()) || false;
case 'ends_with':
if (dataType !== 'string') return false;
return convertedItemValue?.toLowerCase()?.endsWith(convertedFilterValue?.toLowerCase()) || false;
case 'greater_than':
return convertedItemValue > convertedFilterValue;
case 'greater_than_equal':
return convertedItemValue >= convertedFilterValue;
case 'less_than':
return convertedItemValue < convertedFilterValue;
case 'less_than_equal':
return convertedItemValue <= convertedFilterValue;
default:
return false;
}
}
/**
* Filter array by single property
* @param data - Array to filter
* @param config - Filter configuration
*/
export function filterBy<T>(data: T[], config: FilterConfig<T>): T[] {
if (!Array.isArray(data) || data.length === 0) return data;
return data.filter(item => {
const itemValue = getNestedValue(item, config.key);
return matchesFilter(
itemValue,
config.value,
config.operator,
config.dataType || 'string'
);
});
}
/**
* Filter array by multiple conditions (AND logic)
* @param data - Array to filter
* @param configs - Array of filter configurations
*/
export function filterByMultiple<T>(data: T[], configs: FilterConfig<T>[]): T[] {
if (!Array.isArray(data) || data.length === 0) return data;
if (!configs || configs.length === 0) return data;
return data.filter(item => {
return configs.every(config => {
const itemValue = getNestedValue(item, config.key);
return matchesFilter(
itemValue,
config.value,
config.operator,
config.dataType || 'string'
);
});
});
}
/**
* Filter array by multiple conditions with OR logic
* @param data - Array to filter
* @param configs - Array of filter configurations
*/
export function filterByAny<T>(data: T[], configs: FilterConfig<T>[]): T[] {
if (!Array.isArray(data) || data.length === 0) return data;
if (!configs || configs.length === 0) return data;
return data.filter(item => {
return configs.some(config => {
const itemValue = getNestedValue(item, config.key);
return matchesFilter(
itemValue,
config.value,
config.operator,
config.dataType || 'string'
);
});
});
}
/**
* Search filter - searches across multiple properties
* @param data - Array to search
* @param searchTerm - Search term
* @param searchKeys - Properties to search in
* @param caseSensitive - Whether search is case sensitive
*/
export function searchFilter<T>(
data: T[],
searchTerm: string,
searchKeys: (keyof T)[],
caseSensitive: boolean = false
): T[] {
if (!Array.isArray(data) || data.length === 0) return data;
if (!searchTerm || searchTerm.trim() === '') return data;
if (!searchKeys || searchKeys.length === 0) return data;
const term = caseSensitive ? searchTerm : searchTerm.toLowerCase();
return data.filter(item => {
return searchKeys.some(key => {
const value = getNestedValue(item, key);
if (value == null) return false;
const stringValue = String(value);
const searchValue = caseSensitive ? stringValue : stringValue.toLowerCase();
return searchValue.includes(term);
});
});
}
/**
* Advanced search with highlighting matches
* @param data - Array to search
* @param searchTerm - Search term
* @param searchKeys - Properties to search in
* @param caseSensitive - Whether search is case sensitive
*/
export function searchWithHighlight<T extends Record<string, any>>(
data: T[],
searchTerm: string,
searchKeys: (keyof T)[],
caseSensitive: boolean = false
): Array<T & { _searchMatches: { [key: string]: string } }> {
if (!Array.isArray(data) || data.length === 0) return [];
if (!searchTerm || searchTerm.trim() === '') {
return data.map(item => ({ ...item, _searchMatches: {} }));
}
if (!searchKeys || searchKeys.length === 0) return [];
const term = caseSensitive ? searchTerm : searchTerm.toLowerCase();
const highlightStartTag = '<mark>';
const highlightEndTag = '</mark>';
return data.filter(item => {
const matches: { [key: string]: string } = {};
let hasMatch = false;
searchKeys.forEach(key => {
const value = getNestedValue(item, key);
if (value == null) return;
const stringValue = String(value);
const searchValue = caseSensitive ? stringValue : stringValue.toLowerCase();
if (searchValue.includes(term)) {
hasMatch = true;
// Create highlighted version
const regex = new RegExp(
caseSensitive ? searchTerm : searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
caseSensitive ? 'g' : 'gi'
);
matches[key as string] = stringValue.replace(regex, `${highlightStartTag}$&${highlightEndTag}`);
}
});
if (hasMatch) {
return Object.assign(item, { _searchMatches: matches });
}
return false;
}).map(item => item as T & { _searchMatches: { [key: string]: string } });
}
/**
* Create a filter predicate function
* @param config - Filter configuration
*/
export function createFilterPredicate<T>(config: FilterConfig<T>) {
return (item: T): boolean => {
const itemValue = getNestedValue(item, config.key);
return matchesFilter(
itemValue,
config.value,
config.operator,
config.dataType || 'string'
);
};
}
/**
* Get unique values from a property across all items
* @param data - Array of data
* @param key - Property key
*/
export function getUniqueValues<T>(data: T[], key: keyof T): any[] {
if (!Array.isArray(data) || data.length === 0) return [];
const values = new Set();
data.forEach(item => {
const value = getNestedValue(item, key);
if (value != null) {
values.add(value);
}
});
return Array.from(values).sort();
}
/**
* Get filter suggestions based on current data and partial input
* @param data - Array of data
* @param key - Property key
* @param partialValue - Partial value to match
* @param maxSuggestions - Maximum number of suggestions
*/
export function getFilterSuggestions<T>(
data: T[],
key: keyof T,
partialValue: string,
maxSuggestions: number = 10
): string[] {
if (!Array.isArray(data) || data.length === 0) return [];
if (!partialValue || partialValue.trim() === '') return [];
const term = partialValue.toLowerCase();
const suggestions = new Set<string>();
data.forEach(item => {
const value = getNestedValue(item, key);
if (value != null) {
const stringValue = String(value);
if (stringValue.toLowerCase().includes(term)) {
suggestions.add(stringValue);
}
}
});
return Array.from(suggestions)
.sort()
.slice(0, maxSuggestions);
}

View File

@@ -0,0 +1,280 @@
/**
* Pagination utilities for data manipulation
*/
import { PaginationConfig, PaginationResult } from './types';
/**
* Paginate data array
* @param data - Array to paginate
* @param config - Pagination configuration
*/
export function paginate<T>(data: T[], config: PaginationConfig): PaginationResult<T> {
if (!Array.isArray(data)) {
throw new Error('Data must be an array');
}
const { page, pageSize } = config;
if (page < 1) {
throw new Error('Page number must be 1 or greater');
}
if (pageSize < 1) {
throw new Error('Page size must be 1 or greater');
}
const totalItems = data.length;
const totalPages = Math.ceil(totalItems / pageSize);
const startIndex = (page - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize - 1, totalItems - 1);
const paginatedData = totalItems > 0 ? data.slice(startIndex, startIndex + pageSize) : [];
return {
data: paginatedData,
page,
pageSize,
totalItems,
totalPages,
hasPrevious: page > 1,
hasNext: page < totalPages,
startIndex: totalItems > 0 ? startIndex : -1,
endIndex: totalItems > 0 ? endIndex : -1
};
}
/**
* Calculate pagination metadata without slicing data
* @param totalItems - Total number of items
* @param page - Current page
* @param pageSize - Items per page
*/
export function calculatePages(totalItems: number, page: number, pageSize: number): Omit<PaginationResult, 'data'> {
if (totalItems < 0) {
throw new Error('Total items cannot be negative');
}
if (page < 1) {
throw new Error('Page number must be 1 or greater');
}
if (pageSize < 1) {
throw new Error('Page size must be 1 or greater');
}
const totalPages = Math.ceil(totalItems / pageSize);
const startIndex = (page - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize - 1, totalItems - 1);
return {
page,
pageSize,
totalItems,
totalPages,
hasPrevious: page > 1,
hasNext: page < totalPages,
startIndex: totalItems > 0 ? startIndex : -1,
endIndex: totalItems > 0 ? endIndex : -1
};
}
/**
* Get page numbers for pagination display (with ellipsis logic)
* @param currentPage - Current page
* @param totalPages - Total number of pages
* @param maxVisible - Maximum number of visible page numbers
*/
export function getPaginationRange(
currentPage: number,
totalPages: number,
maxVisible: number = 7
): Array<number | 'ellipsis'> {
if (currentPage < 1 || currentPage > totalPages) {
throw new Error('Current page is out of range');
}
if (totalPages <= maxVisible) {
// Show all pages if total is less than max visible
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const range: Array<number | 'ellipsis'> = [];
const sidePages = Math.floor((maxVisible - 3) / 2); // Reserve 3 spots for first, last, and current
// Always show first page
range.push(1);
if (currentPage <= sidePages + 2) {
// Current page is near the beginning
for (let i = 2; i <= Math.min(maxVisible - 1, totalPages - 1); i++) {
range.push(i);
}
if (totalPages > maxVisible - 1) {
range.push('ellipsis');
}
} else if (currentPage >= totalPages - sidePages - 1) {
// Current page is near the end
range.push('ellipsis');
for (let i = Math.max(2, totalPages - maxVisible + 2); i <= totalPages - 1; i++) {
range.push(i);
}
} else {
// Current page is in the middle
range.push('ellipsis');
for (let i = currentPage - sidePages; i <= currentPage + sidePages; i++) {
range.push(i);
}
range.push('ellipsis');
}
// Always show last page (if it's not already included)
if (totalPages > 1 && !range.includes(totalPages)) {
range.push(totalPages);
}
return range;
}
/**
* Get item range text for display (e.g., "1-10 of 100")
* @param startIndex - Start index (0-based)
* @param endIndex - End index (0-based)
* @param totalItems - Total number of items
*/
export function getItemRangeText(startIndex: number, endIndex: number, totalItems: number): string {
if (totalItems === 0) return '0 of 0';
const start = startIndex + 1; // Convert to 1-based
const end = endIndex + 1; // Convert to 1-based
return `${start}-${end} of ${totalItems}`;
}
/**
* Get page information text (e.g., "Page 1 of 10")
* @param currentPage - Current page
* @param totalPages - Total number of pages
*/
export function getPageInfoText(currentPage: number, totalPages: number): string {
if (totalPages === 0) return 'Page 0 of 0';
return `Page ${currentPage} of ${totalPages}`;
}
/**
* Calculate optimal page size based on container height and item height
* @param containerHeight - Height of the container in pixels
* @param itemHeight - Height of each item in pixels
* @param minPageSize - Minimum page size
* @param maxPageSize - Maximum page size
*/
export function calculateOptimalPageSize(
containerHeight: number,
itemHeight: number,
minPageSize: number = 5,
maxPageSize: number = 100
): number {
const calculatedSize = Math.floor(containerHeight / itemHeight);
return Math.max(minPageSize, Math.min(maxPageSize, calculatedSize));
}
/**
* Navigate to a specific page (with bounds checking)
* @param currentPage - Current page
* @param totalPages - Total number of pages
* @param targetPage - Target page to navigate to
*/
export function navigateToPage(currentPage: number, totalPages: number, targetPage: number): number {
return Math.max(1, Math.min(totalPages, targetPage));
}
/**
* Navigate to previous page
* @param currentPage - Current page
*/
export function navigateToPrevious(currentPage: number): number {
return Math.max(1, currentPage - 1);
}
/**
* Navigate to next page
* @param currentPage - Current page
* @param totalPages - Total number of pages
*/
export function navigateToNext(currentPage: number, totalPages: number): number {
return Math.min(totalPages, currentPage + 1);
}
/**
* Navigate to first page
*/
export function navigateToFirst(): number {
return 1;
}
/**
* Navigate to last page
* @param totalPages - Total number of pages
*/
export function navigateToLast(totalPages: number): number {
return Math.max(1, totalPages);
}
/**
* Check if page navigation is valid
* @param page - Page to check
* @param totalPages - Total number of pages
*/
export function isValidPage(page: number, totalPages: number): boolean {
return page >= 1 && page <= totalPages && totalPages > 0;
}
/**
* Get all possible page sizes (common values)
*/
export function getStandardPageSizes(): number[] {
return [5, 10, 20, 25, 50, 100];
}
/**
* Get recommended page size based on total items
* @param totalItems - Total number of items
*/
export function getRecommendedPageSize(totalItems: number): number {
if (totalItems <= 25) return 10;
if (totalItems <= 100) return 25;
if (totalItems <= 500) return 50;
return 100;
}
/**
* Paginate with server-side pagination simulation
* @param data - Full dataset
* @param page - Current page
* @param pageSize - Items per page
* @param searchTerm - Search term to filter data first
* @param searchKeys - Keys to search in
*/
export function serverPaginate<T>(
data: T[],
page: number,
pageSize: number,
searchTerm?: string,
searchKeys?: (keyof T)[]
): PaginationResult<T> {
let filteredData = data;
// Apply search filter first if provided
if (searchTerm && searchKeys && searchKeys.length > 0) {
const term = searchTerm.toLowerCase();
filteredData = data.filter(item => {
return searchKeys.some(key => {
const value = item[key];
return value != null && String(value).toLowerCase().includes(term);
});
});
}
// Then paginate the filtered data
return paginate(filteredData, { page, pageSize });
}

View File

@@ -0,0 +1,206 @@
/**
* Sorting utilities for data manipulation
*/
import { SortConfig, MultiSortConfig, ComparatorFn, SortDirection, DataType } from './types';
/**
* Get nested property value from object
* @param obj - Object to get value from
* @param path - Property path (supports dot notation)
*/
function getNestedValue(obj: any, path: string | keyof any): any {
if (typeof path === 'string' && path.includes('.')) {
return path.split('.').reduce((current, key) => current?.[key], obj);
}
return obj?.[path];
}
/**
* Convert value to appropriate type for comparison
* @param value - Value to convert
* @param dataType - Target data type
*/
function convertValue(value: any, dataType: DataType): any {
if (value == null) return value;
switch (dataType) {
case 'string':
return String(value).toLowerCase();
case 'number':
const num = Number(value);
return isNaN(num) ? 0 : num;
case 'date':
if (value instanceof Date) return value;
const date = new Date(value);
return isNaN(date.getTime()) ? new Date(0) : date;
case 'boolean':
return Boolean(value);
default:
return value;
}
}
/**
* Create a comparator function for a specific property and direction
* @param key - Property key to sort by
* @param direction - Sort direction
* @param dataType - Data type for proper comparison
*/
export function createComparator<T = any>(
key: keyof T,
direction: SortDirection = 'asc',
dataType: DataType = 'string'
): ComparatorFn<T> {
return (a: T, b: T): number => {
const valueA = convertValue(getNestedValue(a, key), dataType);
const valueB = convertValue(getNestedValue(b, key), dataType);
// Handle null/undefined values
if (valueA == null && valueB == null) return 0;
if (valueA == null) return direction === 'asc' ? -1 : 1;
if (valueB == null) return direction === 'asc' ? 1 : -1;
let result = 0;
if (dataType === 'string') {
result = valueA.localeCompare(valueB);
} else if (dataType === 'date') {
result = valueA.getTime() - valueB.getTime();
} else {
// For numbers and booleans
if (valueA < valueB) result = -1;
else if (valueA > valueB) result = 1;
else result = 0;
}
return direction === 'desc' ? -result : result;
};
}
/**
* Sort array by single property
* @param data - Array to sort
* @param config - Sort configuration
*/
export function sortBy<T>(data: T[], config: SortConfig<T>): T[] {
if (!Array.isArray(data) || data.length === 0) return data;
const comparator = createComparator(
config.key,
config.direction,
config.dataType || 'string'
);
return [...data].sort(comparator);
}
/**
* Sort array by multiple properties with priority
* @param data - Array to sort
* @param config - Multi-sort configuration
*/
export function sortByMultiple<T>(data: T[], config: MultiSortConfig<T>): T[] {
if (!Array.isArray(data) || data.length === 0) return data;
if (!config.sorts || config.sorts.length === 0) return data;
// Create comparators for each sort configuration
const comparators = config.sorts.map(sortConfig =>
createComparator(
sortConfig.key,
sortConfig.direction,
sortConfig.dataType || 'string'
)
);
return [...data].sort((a: T, b: T): number => {
// Apply each comparator in order until we find a non-zero result
for (const comparator of comparators) {
const result = comparator(a, b);
if (result !== 0) return result;
}
return 0;
});
}
/**
* Create a stable sort function that maintains relative order of equal elements
* @param data - Array to sort
* @param comparator - Comparison function
*/
export function stableSort<T>(data: T[], comparator: ComparatorFn<T>): T[] {
if (!Array.isArray(data) || data.length === 0) return data;
// Add index to maintain stability
const indexed = data.map((item, index) => ({ item, index }));
indexed.sort((a, b) => {
const result = comparator(a.item, b.item);
// If items are equal, maintain original order
return result !== 0 ? result : a.index - b.index;
});
return indexed.map(({ item }) => item);
}
/**
* Check if an array is sorted according to a comparator
* @param data - Array to check
* @param comparator - Comparison function
*/
export function isSorted<T>(data: T[], comparator: ComparatorFn<T>): boolean {
if (!Array.isArray(data) || data.length <= 1) return true;
for (let i = 1; i < data.length; i++) {
if (comparator(data[i - 1], data[i]) > 0) return false;
}
return true;
}
/**
* Reverse sort direction
* @param direction - Current direction
*/
export function reverseDirection(direction: SortDirection): SortDirection {
return direction === 'asc' ? 'desc' : 'asc';
}
/**
* Get sort direction for next click (for UI toggling)
* @param currentDirection - Current sort direction
* @param allowUnsorted - Whether to allow unsorted state
*/
export function getNextSortDirection(
currentDirection: SortDirection | null,
allowUnsorted: boolean = true
): SortDirection | null {
if (currentDirection === null) return 'asc';
if (currentDirection === 'asc') return 'desc';
if (currentDirection === 'desc' && allowUnsorted) return null;
return 'asc'; // Cycle back to asc if unsorted not allowed
}
/**
* Sort array by multiple keys with individual directions
* @param data - Array to sort
* @param keys - Array of keys to sort by
* @param directions - Array of directions (defaults to 'asc' for all)
* @param dataTypes - Array of data types (defaults to 'string' for all)
*/
export function sortByKeys<T>(
data: T[],
keys: (keyof T)[],
directions: SortDirection[] = [],
dataTypes: DataType[] = []
): T[] {
if (!Array.isArray(data) || data.length === 0) return data;
if (!keys || keys.length === 0) return data;
const sorts: SortConfig<T>[] = keys.map((key, index) => ({
key,
direction: directions[index] || 'asc',
dataType: dataTypes[index] || 'string'
}));
return sortByMultiple(data, { sorts });
}

View File

@@ -0,0 +1,378 @@
/**
* Data transformation utilities
*/
import { GroupByResult, AggregationFunction, AggregationResult } from './types';
/**
* Get nested property value from object
* @param obj - Object to get value from
* @param path - Property path (supports dot notation)
*/
function getNestedValue(obj: any, path: string | keyof any): any {
if (typeof path === 'string' && path.includes('.')) {
return path.split('.').reduce((current, key) => current?.[key], obj);
}
return obj?.[path];
}
/**
* Group array of objects by a specific property
* @param data - Array to group
* @param key - Property to group by
*/
export function groupBy<T>(data: T[], key: keyof T): GroupByResult<T>[] {
if (!Array.isArray(data) || data.length === 0) return [];
const groups = new Map<any, T[]>();
data.forEach(item => {
const groupKey = getNestedValue(item, key);
if (!groups.has(groupKey)) {
groups.set(groupKey, []);
}
groups.get(groupKey)!.push(item);
});
return Array.from(groups.entries()).map(([groupKey, items]) => ({
key: groupKey,
items,
count: items.length
}));
}
/**
* Group array by multiple properties
* @param data - Array to group
* @param keys - Properties to group by
*/
export function groupByMultiple<T>(data: T[], keys: (keyof T)[]): GroupByResult<T>[] {
if (!Array.isArray(data) || data.length === 0) return [];
if (!keys || keys.length === 0) return [{ key: null, items: data, count: data.length }];
const groups = new Map<string, T[]>();
data.forEach(item => {
const groupKey = keys.map(key => String(getNestedValue(item, key))).join('|');
if (!groups.has(groupKey)) {
groups.set(groupKey, []);
}
groups.get(groupKey)!.push(item);
});
return Array.from(groups.entries()).map(([groupKey, items]) => ({
key: groupKey,
items,
count: items.length
}));
}
/**
* Aggregate values using specified functions
* @param data - Array to aggregate
* @param key - Property to aggregate
* @param functions - Aggregation functions to apply
*/
export function aggregate<T>(
data: T[],
key: keyof T,
functions: AggregationFunction[]
): AggregationResult {
if (!Array.isArray(data) || data.length === 0) {
return functions.reduce((acc, fn) => ({ ...acc, [fn]: 0 }), {});
}
const values = data
.map(item => getNestedValue(item, key))
.filter(value => value != null && !isNaN(Number(value)))
.map(value => Number(value));
if (values.length === 0) {
return functions.reduce((acc, fn) => ({ ...acc, [fn]: 0 }), {});
}
const result: AggregationResult = {};
functions.forEach(fn => {
switch (fn) {
case 'sum':
result['sum'] = values.reduce((acc, val) => acc + val, 0);
break;
case 'avg':
result['avg'] = values.reduce((acc, val) => acc + val, 0) / values.length;
break;
case 'min':
result['min'] = Math.min(...values);
break;
case 'max':
result['max'] = Math.max(...values);
break;
case 'count':
result['count'] = values.length;
break;
}
});
return result;
}
/**
* Group by and aggregate in one operation
* @param data - Array to process
* @param groupKey - Property to group by
* @param aggregateKey - Property to aggregate
* @param functions - Aggregation functions
*/
export function groupAndAggregate<T>(
data: T[],
groupKey: keyof T,
aggregateKey: keyof T,
functions: AggregationFunction[]
): Array<{ group: any; aggregates: AggregationResult }> {
const groups = groupBy(data, groupKey);
return groups.map(group => ({
group: group.key,
aggregates: aggregate(group.items, aggregateKey, functions)
}));
}
/**
* Extract specific properties from objects (pluck)
* @param data - Array of objects
* @param keys - Properties to extract
*/
export function pluck<T extends Record<string, any>, K extends keyof T>(data: T[], keys: K[]): Pick<T, K>[] {
if (!Array.isArray(data) || data.length === 0) return [];
if (!keys || keys.length === 0) return [];
return data.map(item => {
const result = {} as Pick<T, K>;
keys.forEach(key => {
if (key in item) {
result[key] = item[key];
}
});
return result;
});
}
/**
* Extract single property values into array
* @param data - Array of objects
* @param key - Property to extract
*/
export function pluckProperty<T, K extends keyof T>(data: T[], key: K): T[K][] {
if (!Array.isArray(data) || data.length === 0) return [];
return data.map(item => getNestedValue(item, key)).filter(value => value !== undefined);
}
/**
* Flatten nested arrays
* @param data - Array that may contain nested arrays
* @param depth - Maximum depth to flatten (default: 1)
*/
export function flatten<T>(data: any[], depth: number = 1): T[] {
if (!Array.isArray(data)) return [];
const flattenRecursive = (arr: any[], currentDepth: number): any[] => {
return currentDepth > 0
? arr.reduce((acc, val) =>
Array.isArray(val)
? acc.concat(flattenRecursive(val, currentDepth - 1))
: acc.concat(val), [])
: arr.slice();
};
return flattenRecursive(data, depth);
}
/**
* Flatten array completely (all levels)
* @param data - Array to flatten
*/
export function flattenDeep<T>(data: any[]): T[] {
if (!Array.isArray(data)) return [];
return data.reduce((acc, val) =>
Array.isArray(val)
? acc.concat(flattenDeep(val))
: acc.concat(val), []);
}
/**
* Create pivot table from data
* @param data - Array to pivot
* @param rowKey - Property for rows
* @param colKey - Property for columns
* @param valueKey - Property for values
* @param aggregateFunction - How to aggregate values
*/
export function pivot<T>(
data: T[],
rowKey: keyof T,
colKey: keyof T,
valueKey: keyof T,
aggregateFunction: AggregationFunction = 'sum'
): { [row: string]: { [col: string]: number } } {
if (!Array.isArray(data) || data.length === 0) return {};
const result: { [row: string]: { [col: string]: number } } = {};
data.forEach(item => {
const row = String(getNestedValue(item, rowKey));
const col = String(getNestedValue(item, colKey));
const value = Number(getNestedValue(item, valueKey)) || 0;
if (!result[row]) {
result[row] = {};
}
if (!result[row][col]) {
result[row][col] = 0;
}
switch (aggregateFunction) {
case 'sum':
result[row][col] += value;
break;
case 'count':
result[row][col] += 1;
break;
case 'avg':
// For avg, we need to track sum and count separately
// This is a simplified version that treats each occurrence as separate
result[row][col] = (result[row][col] + value) / 2;
break;
case 'min':
result[row][col] = Math.min(result[row][col], value);
break;
case 'max':
result[row][col] = Math.max(result[row][col], value);
break;
}
});
return result;
}
/**
* Transpose 2D array (flip rows and columns)
* @param data - 2D array to transpose
*/
export function transpose<T>(data: T[][]): T[][] {
if (!Array.isArray(data) || data.length === 0) return [];
if (!Array.isArray(data[0])) return [];
const rows = data.length;
const cols = data[0].length;
const result: T[][] = [];
for (let col = 0; col < cols; col++) {
result[col] = [];
for (let row = 0; row < rows; row++) {
result[col][row] = data[row][col];
}
}
return result;
}
/**
* Create frequency map of values
* @param data - Array of values or objects
* @param key - Property key (if data contains objects)
*/
export function frequency<T>(data: T[], key?: keyof T): Map<any, number> {
if (!Array.isArray(data)) return new Map();
const freqMap = new Map<any, number>();
data.forEach(item => {
const value = key ? getNestedValue(item, key) : item;
freqMap.set(value, (freqMap.get(value) || 0) + 1);
});
return freqMap;
}
/**
* Get unique values from array or object property
* @param data - Array of data
* @param key - Property key (optional)
*/
export function unique<T>(data: T[], key?: keyof T): any[] {
if (!Array.isArray(data)) return [];
const values = key
? data.map(item => getNestedValue(item, key))
: data;
return [...new Set(values)];
}
/**
* Chunk array into smaller arrays of specified size
* @param data - Array to chunk
* @param size - Size of each chunk
*/
export function chunk<T>(data: T[], size: number): T[][] {
if (!Array.isArray(data) || size <= 0) return [];
const chunks: T[][] = [];
for (let i = 0; i < data.length; i += size) {
chunks.push(data.slice(i, i + size));
}
return chunks;
}
/**
* Zip multiple arrays together
* @param arrays - Arrays to zip
*/
export function zip<T>(...arrays: T[][]): T[][] {
if (arrays.length === 0) return [];
const length = Math.max(...arrays.map(arr => Array.isArray(arr) ? arr.length : 0));
const result: T[][] = [];
for (let i = 0; i < length; i++) {
result[i] = arrays.map(arr => arr[i]);
}
return result;
}
/**
* Create cross product of multiple arrays
* @param arrays - Arrays to get cross product from
*/
export function cartesianProduct<T>(...arrays: T[][]): T[][] {
if (arrays.length === 0) return [];
if (arrays.some(arr => !Array.isArray(arr) || arr.length === 0)) return [];
return arrays.reduce(
(acc, curr) => acc.flatMap(a => curr.map(b => [...a, b])),
[[]] as T[][]
);
}
/**
* Sample random items from array
* @param data - Array to sample from
* @param count - Number of items to sample
* @param withReplacement - Whether to allow duplicate selections
*/
export function sample<T>(data: T[], count: number, withReplacement: boolean = false): T[] {
if (!Array.isArray(data) || data.length === 0 || count <= 0) return [];
if (withReplacement) {
return Array.from({ length: count }, () =>
data[Math.floor(Math.random() * data.length)]
);
} else {
const shuffled = [...data].sort(() => Math.random() - 0.5);
return shuffled.slice(0, Math.min(count, data.length));
}
}

View File

@@ -0,0 +1,130 @@
/**
* Common types used across ui-data-utils
*/
/**
* Sort direction
*/
export type SortDirection = 'asc' | 'desc';
/**
* Supported data types for comparison
*/
export type DataType = 'string' | 'number' | 'date' | 'boolean';
/**
* Sort configuration for single property
*/
export interface SortConfig<T = any> {
/** Property key to sort by */
key: keyof T;
/** Sort direction */
direction: SortDirection;
/** Data type for proper comparison */
dataType?: DataType;
}
/**
* Multi-column sort configuration
*/
export interface MultiSortConfig<T = any> {
/** Array of sort configurations in priority order */
sorts: SortConfig<T>[];
}
/**
* Comparator function type
*/
export type ComparatorFn<T = any> = (a: T, b: T) => number;
/**
* Filter operator types
*/
export type FilterOperator =
| 'equals'
| 'not_equals'
| 'contains'
| 'not_contains'
| 'starts_with'
| 'ends_with'
| 'greater_than'
| 'greater_than_equal'
| 'less_than'
| 'less_than_equal'
| 'between'
| 'in'
| 'not_in'
| 'is_empty'
| 'is_not_empty';
/**
* Filter configuration
*/
export interface FilterConfig<T = any> {
/** Property key to filter by */
key: keyof T;
/** Filter operator */
operator: FilterOperator;
/** Filter value(s) */
value: any;
/** Data type for proper comparison */
dataType?: DataType;
}
/**
* Pagination configuration
*/
export interface PaginationConfig {
/** Current page (1-based) */
page: number;
/** Items per page */
pageSize: number;
}
/**
* Pagination result
*/
export interface PaginationResult<T = any> {
/** Paginated data */
data: T[];
/** Current page */
page: number;
/** Items per page */
pageSize: number;
/** Total number of items */
totalItems: number;
/** Total number of pages */
totalPages: number;
/** Has previous page */
hasPrevious: boolean;
/** Has next page */
hasNext: boolean;
/** Start index (0-based) */
startIndex: number;
/** End index (0-based) */
endIndex: number;
}
/**
* Aggregation functions
*/
export type AggregationFunction = 'sum' | 'avg' | 'min' | 'max' | 'count';
/**
* Aggregation result
*/
export interface AggregationResult {
[key: string]: number;
}
/**
* Group by result
*/
export interface GroupByResult<T = any> {
/** Group key */
key: any;
/** Items in this group */
items: T[];
/** Group count */
count: number;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UiDataUtilsComponent } from './ui-data-utils.component';
describe('UiDataUtilsComponent', () => {
let component: UiDataUtilsComponent;
let fixture: ComponentFixture<UiDataUtilsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UiDataUtilsComponent]
})
.compileComponents();
fixture = TestBed.createComponent(UiDataUtilsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
@Component({
selector: 'lib-ui-data-utils',
imports: [],
template: `
<p>
ui-data-utils works!
</p>
`,
styles: ``
})
export class UiDataUtilsComponent {
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { UiDataUtilsService } from './ui-data-utils.service';
describe('UiDataUtilsService', () => {
let service: UiDataUtilsService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UiDataUtilsService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,9 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class UiDataUtilsService {
constructor() { }
}

View File

@@ -0,0 +1,22 @@
/*
* Public API Surface of ui-data-utils
*/
// Types and interfaces
export * from './lib/types';
// Sorting utilities
export * from './lib/sorting';
// Filtering utilities
export * from './lib/filtering';
// Pagination utilities
export * from './lib/pagination';
// Transformation utilities
export * from './lib/transformation';
// Legacy exports (keep for compatibility)
export * from './lib/ui-data-utils.service';
export * from './lib/ui-data-utils.component';

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/lib",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"**/*.spec.ts"
]
}

View File

@@ -0,0 +1,11 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false
},
"angularCompilerOptions": {
"compilationMode": "partial"
}
}

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"**/*.spec.ts",
"**/*.d.ts"
]
}