Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DcWorkflow } from '../definition/dc-workflow';
import { Component, computed, effect, inject, input, linkedSignal, signal } from '@angular/core';
import { Component, computed, effect, inject, input, linkedSignal, model, signal } from '@angular/core';
import { DcWorkflowModel, DcWorkflowSortOption } from '../models/dc-workflow-model';
import { XtCompositeComponent, XtMessageHandler, XtResolverService } from 'xt-components';
import {
Expand Down Expand Up @@ -56,6 +56,46 @@ export class AbstractDcWorkflow<T extends ManagedData=ManagedData> extends XtCom
return this.config().entity;
});

/** Search string used to filter the displayed items (case-insensitive substring match). */
search = model<string>('');

/**
* Updates the search string from a search box input event.
* @protected
*/
protected onSearchInput(event: Event): void {
this.search.set((event.target as HTMLInputElement).value);
}

/**
* Computed signal returning displayable elements filtered by the search string.
* Matching is case-insensitive and only items containing the string are returned.
* @protected
*/
protected searchedElements = computed(() => {
const elements = this.displayableElements();
const query = this.search().trim().toLowerCase();
if (query === '') {
return elements;
}
return elements.filter((element) => this.matchesSearch(element, query));
});

/**
* Checks whether any value of the item contains the search query (case-insensitive).
* Recursively inspects nested objects and dates.
* @protected
*/
private matchesSearch(value: unknown, query: string): boolean {
if (value == null) return false;
if (value instanceof Date) return value.toLocaleDateString().toLowerCase().includes(query);
if (Array.isArray(value)) return value.some((v) => this.matchesSearch(v, query));
if (typeof value === 'object') {
return Object.values(value).some((v) => this.matchesSearch(v, query));
}
return String(value).toLowerCase().includes(query);
}

/**
* Toggle signal that changes whenever a new store is created.
* Used to force recomputation of derived signals (e.g., displayableElements).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ describe('XtRenderComponent', () => {
template: '@if (isInForm()) {<ng-container [formGroup]="formGroup()"><input id="text_input" [name]="formControlName()" type="text" [formControlName]="formControlName()" /></ng-container>} @else {<h2>Value is {{context().displayValue()}}</h2>}'
})
export class TestCurrencyComponent extends XtSimpleComponent<string> {
override ngOnInit() {
super.ngOnInit();
// Test the context is correctly set before ngOnInit
const context = this.context();
}
}

@Component({
Expand Down
117 changes: 117 additions & 0 deletions libs/xt-type/src/handler/xt-type-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,123 @@ describe('Type Handler', () => {

});

describe('Type sorting', () => {

function handlerFor (typeName:string, handler:AbstractTypeHandler<any>):AbstractTypeHandler<any> {
const typeHierarchy = new XtBaseTypeHierarchy(typeName, handler);
typeHierarchy.initHandler();
return handler;
}

it ('should mark all primitive types as sortable', () => {
for (const typeName of ['string', 'number', 'boolean', 'date', 'date-time', 'time']) {
const handler = handlerFor(typeName, new DefaultTypeHandler());
expect(handler.isSortable(), typeName).toBe(true);
}
});

it ('should not mark an unknown complex type as sortable', () => {
const handler = handlerFor('person', new DefaultTypeHandler());
expect(handler.isSortable()).toBe(false);
});

it ('should mark a type with a numeric field as sortable', () => {
const handler = handlerFor('money', new ManagedDataHandler(
new SpecialFields<ManagedData>().setNumericValueField('amount')
));
expect(handler.isSortable()).toBe(true);
});

it ('should compare strings', () => {
const handler = handlerFor('string', new DefaultTypeHandler());
expect(handler.compareTo('abc', 'abc')).toBe(0);
expect(handler.compareTo('abc', 'abd')).toBeLessThan(0);
expect(handler.compareTo('abd', 'abc')).toBeGreaterThan(0);
});

it ('should compare numbers', () => {
const handler = handlerFor('number', new DefaultTypeHandler());
expect(handler.compareTo(3, 3)).toBe(0);
expect(handler.compareTo(2, 3)).toBeLessThan(0);
expect(handler.compareTo(3, 2)).toBeGreaterThan(0);
});

it ('should compare booleans', () => {
const handler = handlerFor('boolean', new DefaultTypeHandler());
expect(handler.compareTo(false, false)).toBe(0);
expect(handler.compareTo(true, true)).toBe(0);
expect(handler.compareTo(false, true)).toBeLessThan(0);
expect(handler.compareTo(true, false)).toBeGreaterThan(0);
});

it ('should compare dates', () => {
const handler = handlerFor('date', new DefaultTypeHandler());
const first = new Date('2018-05-01');
const second = new Date('2019-05-01');
expect(handler.compareTo(first, first)).toBe(0);
expect(handler.compareTo(first, second)).toBeLessThan(0);
expect(handler.compareTo(second, first)).toBeGreaterThan(0);
});

it ('should compare date-time values', () => {
const handler = handlerFor('date-time', new DefaultTypeHandler());
const first = new Date('2018-05-01T10:00:00.000Z');
const second = new Date('2018-05-01T12:00:00.000Z');
expect(handler.compareTo(first, second)).toBeLessThan(0);
expect(handler.compareTo(second, first)).toBeGreaterThan(0);
expect(handler.compareTo(second, second)).toBe(0);
});

it ('should compare time values', () => {
const handler = handlerFor('time', new DefaultTypeHandler());
const first = new Date('1970-01-01T08:00:00.000Z');
const second = new Date('1970-01-01T09:30:00.000Z');
expect(handler.compareTo(first, second)).toBeLessThan(0);
expect(handler.compareTo(second, first)).toBeGreaterThan(0);
expect(handler.compareTo(second, second)).toBe(0);
});

it ('should compare date values given as strings', () => {
const handler = handlerFor('date', new DefaultTypeHandler());
expect(handler.compareTo('2018-05-01', '2019-05-01')).toBeLessThan(0);
expect(handler.compareTo('2019-05-01', '2018-05-01')).toBeGreaterThan(0);
expect(handler.compareTo('2019-05-01', '2019-05-01')).toBe(0);
});

it ('should sort null values first', () => {
const handler = handlerFor('string', new DefaultTypeHandler());
expect(handler.compareTo(null as any, 'abc')).toBeLessThan(0);
expect(handler.compareTo('abc', null as any)).toBeGreaterThan(0);
expect(handler.compareTo(null as any, null as any)).toBe(0);
});

it ('should compare complex types by their numeric field', () => {
const handler = handlerFor('money', new ManagedDataHandler(
new SpecialFields<ManagedData>().setNumericValueField('amount')
));
expect(handler.compareTo({amount: 10}, {amount: 20})).toBeLessThan(0);
expect(handler.compareTo({amount: 20}, {amount: 10})).toBeGreaterThan(0);
expect(handler.compareTo({amount: 10}, {amount: 10})).toBe(0);
});

it ('should sort all primitive types by natural order', () => {
const types: {name:string, values:any[], expected:any[]}[] = [
{name: 'string', values: ['b', 'a', 'c'], expected: ['a', 'b', 'c']},
{name: 'number', values: [3, 1, 2], expected: [1, 2, 3]},
{name: 'boolean', values: [true, false, true], expected: [false, true, true]},
{name: 'date', values: [new Date('2019-05-01'), new Date('2018-05-01'), new Date('2020-05-01')], expected: [new Date('2018-05-01'), new Date('2019-05-01'), new Date('2020-05-01')]},
{name: 'date-time', values: [new Date('2019-05-01T12:00:00Z'), new Date('2019-05-01T10:00:00Z'), new Date('2019-05-01T11:00:00Z')], expected: [new Date('2019-05-01T10:00:00Z'), new Date('2019-05-01T11:00:00Z'), new Date('2019-05-01T12:00:00Z')]},
{name: 'time', values: [new Date('1970-01-01T09:00:00Z'), new Date('1970-01-01T07:00:00Z'), new Date('1970-01-01T08:00:00Z')], expected: [new Date('1970-01-01T07:00:00Z'), new Date('1970-01-01T08:00:00Z'), new Date('1970-01-01T09:00:00Z')]},
];
for (const entry of types) {
const handler = handlerFor(entry.name, new DefaultTypeHandler());
const sorted = [...entry.values].sort((a, b) => handler.compareTo(a, b));
expect(sorted).toEqual(entry.expected);
}
});

});


type ToHandleType = {
id: string,
Expand Down
76 changes: 76 additions & 0 deletions libs/xt-type/src/handler/xt-type-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ export type XtTypeHandler<Type> = {
isDisplayTemplateSet():boolean;
numberToCalculate(value:Type):number | undefined;

/**
* Checks whether values of this type can be sorted.
*/
isSortable ():boolean;
/**
* Compares two values of this type.
*/
compareTo (value1: Type, value2: Type): number;

getOrCreateMappingFrom<OtherType> (fromTypeName: string, registry:XtTypeResolver): MappingHelper<OtherType, Type> | undefined;
}

Expand All @@ -50,6 +59,11 @@ export abstract class AbstractTypeHandler<Type> implements XtTypeHandler<Type> {
*/
protected static readonly NONE_MAPPING=new MappingHelper<any,any>({});

/**
* Primitive type names that can be sorted by their natural value
*/
protected static readonly SORTABLE_PRIMITIVES=['string','number','boolean','date','date-time','time'];

/**
* @param specialFields Optional pre-configured special fields
*/
Expand Down Expand Up @@ -202,6 +216,68 @@ export abstract class AbstractTypeHandler<Type> implements XtTypeHandler<Type> {
return undefined;
}

/**
* Checks whether values of this type can be sorted.
* Primitive types (string, number, boolean, date, date-time, time) are always sortable,
* as well as any type configured with a numeric field.
* @returns True if the values of this type can be sorted
*/
isSortable(): boolean {
const typeName = this.type?.type;
if (typeName==null) return false;
if (AbstractTypeHandler.SORTABLE_PRIMITIVES.includes(typeName)) return true;
return this.fields.numericValueField!=null;
}

/**
* Compares two values of this type. Null values are always sorted first.
* Comparison uses the natural value of the type: lexicographic for strings,
* numeric for numbers and booleans, chronological for dates and times,
* and the configured numeric field for complex types.
* @param value1 The first value to compare
* @param value2 The second value to compare
* @returns A negative number if value1 is smaller, zero if equal, a positive number if value1 is greater
*/
compareTo(value1: Type, value2: Type): number {
if (value1==null) return value2==null ? 0 : -1;
if (value2==null) return 1;

const typeName = this.type?.type;
switch (typeName) {
case 'string':
return (value1 as string).localeCompare(value2 as string);
case 'number':
return (value1 as number) - (value2 as number);
case 'boolean':
return ((value1 as boolean) ? 1 : 0) - ((value2 as boolean) ? 1 : 0);
case 'date':
case 'date-time':
case 'time':
return this.timeOf(value1) - this.timeOf(value2);
default: {
if (this.fields.numericValueField!=null) {
const n1 = this.numberToCalculate(value1);
const n2 = this.numberToCalculate(value2);
if (n1==null) return n2==null ? 0 : -1;
if (n2==null) return 1;
return n1 - n2;
}
return 0;
}
}
}

/**
* Extracts a timestamp from a date value, which may be a Date, a date string or an epoch number
* @param value The value to convert to a timestamp
* @returns The timestamp in milliseconds, or 0 if it cannot be parsed
*/
private timeOf(value: Type): number {
if (value instanceof Date) return value.getTime();
const timeEpoch = Date.parse(String(value));
return isNaN(timeEpoch) ? 0 : timeEpoch;
}

/**
* Parses a date string into a Date object, handling optional timezone annotations
* @param dateAsString The date string to parse
Expand Down
22 changes: 22 additions & 0 deletions libs/xt-type/src/resolver/xt-type-resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { xtTypeManager } from '../globals';
import { DefaultTypeHandler } from '../handler/default/default-type-handler';
import { XtBaseTypeHierarchy } from './xt-type-resolver.ts';

describe('Xt Type Resolver', () => {
Expand Down Expand Up @@ -61,5 +62,26 @@ describe('Xt Type Resolver', () => {
expect((newBookType.children!['author'] as XtBaseTypeHierarchy).children).toBeDefined();

});

it ('should keep an alias type handler sortable when used as a child', () => {
const resolver = xtTypeManager();
resolver.addRootType('rating', 'number', new DefaultTypeHandler());
resolver.addRootType('movieType', {
title: 'string',
rating: 'rating'
});

const root = resolver.findTypeHandler('rating');
expect(root.typeName).toEqual('number');
expect(root.handler?.isSortable()).toBe(true);

const child = resolver.findTypeHandler('movieType', false, 'rating');
expect(child.typeName).toEqual('number');
expect(child.handler?.isSortable()).toBe(true);

// The child node must keep its alias type name so that rendering resolves the right component
const childType = resolver.findType('movieType', 'rating') as XtTypeHierarchy;
expect(childType.type).toEqual('rating');
});
})

6 changes: 4 additions & 2 deletions libs/xt-type/src/resolver/xt-type-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,9 +374,11 @@ export class XtTypeHierarchyResolver implements XtUpdatableTypeResolver {
ret = new UNRESOLVED_TYPE (typeHierarchy);
}
} else {
// Just create the hierarchy to the primitive type
// Just create the hierarchy to the primitive type.
// The handler is initialized by the root type registration (addRootType).
// For children, the handler may be shared with the root type: re-initializing
// it here would overwrite its type context (e.g. 'rating' alias of 'number').
ret= new XtBaseTypeHierarchy(typeHierarchy, handler);
ret.initHandler();
}
} else {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, model, output, Signal } from '@angular/core';
import { XtContext, XtRenderSubComponent } from 'xt-components';
import { TableModule } from 'primeng/table';
import { isTypeReference, XtTypeHierarchy, XtTypeReference } from 'xt-type';
import { isTypeReference, XtTypeHandler, XtTypeHierarchy, XtTypeReference } from 'xt-type';
import { ObjectSetBase } from './object-set-base';

/**
Expand Down Expand Up @@ -38,7 +38,7 @@ export class DefaultObjectSetComponent<T> extends ObjectSetBase<T> {
return ret;
});

/** Sub-fields that can be sorted. Only primitives (string, number, date, boolean) are sortable for now. */
/** Sub-fields that can be sorted, based on their type handler (or the primitive type when no handler is registered). */
sortableSubNames = computed<Set<string>>(() => {
const sortable = new Set<string>();
for (const subName of this.subNames()) {
Expand Down Expand Up @@ -79,10 +79,36 @@ export class DefaultObjectSetComponent<T> extends ObjectSetBase<T> {
return typeResolver.findPrimitiveType((firstElement as any)[subName])?.type;
}

/** Checks whether the sub-field type is a sortable primitive (string, number, date or boolean). */
/**
* Resolves the type handler of a sub-field, if the sub-field type is registered in the resolver.
* @param subName - The sub-field name to resolve
* @returns The type handler, or undefined when the sub-field type has no registered handler
*/
private subFieldTypeHandler(subName: string): XtTypeHandler<any> | undefined {
const typeResolver = this.resolverService.typeResolver;
const values = this.valueSet();
const firstElement = (Array.isArray(values) && values.length > 0) ? values[0] : null;
try {
const found = typeResolver.findTypeHandler(this.context().valueType, false, subName, firstElement);
return found.handler;
} catch {
return undefined;
}
}

/**
* Checks whether the sub-field type can be sorted, using the type handler when one is registered.
* @param subName - The sub-field name to check
* @returns True if the sub-field can be sorted
*/
private isSubFieldTypeSortable(subName: string): boolean {
const typeResolver = this.resolverService.typeResolver;
const typeName = this.subFieldTypeName(subName);
return (typeName == 'string') || (typeName == 'number') || (typeName == 'date') || (typeName == 'boolean');
if (typeName == null) return false;
const handler = this.subFieldTypeHandler(subName);
if (handler != null) return handler.isSortable();
// No handler registered for this type, fall back to the primitive types
return typeResolver.isPrimitiveType(typeName);
}

/** Builds an XtContext for a specific row element so its sub-fields can be rendered inline. */
Expand Down
Loading