From 8079b6a9381d552150fc9d254f4040ae692139fe Mon Sep 17 00:00:00 2001
From: reverie3 <37984867+reverie3@users.noreply.github.com>
Date: Wed, 7 Aug 2024 13:00:08 +0300
Subject: [PATCH] feat(kit): create TUI_TIME_VALUE_TRANSFORMER for
 tui-input-time component

---
 projects/core/tokens/data-list-host.ts        |   2 +-
 .../input-time/examples/6/index.html          |  46 ++----
 .../components/input-time/examples/6/index.ts |  36 +++--
 .../input-time/examples/7/index.html          |  39 +++++
 .../components/input-time/examples/7/index.ts |  28 ++++
 .../input-time/input-time.component.ts        |   5 +
 .../input-time/input-time.module.ts           |   2 +
 .../input-time/input-time.template.html       |  36 ++++-
 .../input-time/input-time.component.ts        |  16 +-
 .../test/input-time.component.spec.ts         | 143 +++++++++++++++---
 .../tokens/date-inputs-value-transformers.ts  |   7 +
 11 files changed, 281 insertions(+), 79 deletions(-)
 create mode 100644 projects/demo/src/modules/components/input-time/examples/7/index.html
 create mode 100644 projects/demo/src/modules/components/input-time/examples/7/index.ts

diff --git a/projects/core/tokens/data-list-host.ts b/projects/core/tokens/data-list-host.ts
index 3237b4cd3acc..74f8878e0752 100644
--- a/projects/core/tokens/data-list-host.ts
+++ b/projects/core/tokens/data-list-host.ts
@@ -8,7 +8,7 @@ export const TUI_DATA_LIST_HOST = new InjectionToken<TuiDataListHost<unknown>>(
     '[TUI_DATA_LIST_HOST]',
 );
 
-export function tuiAsDataListHost(useExisting: Type<TuiDataListHost<unknown>>): Provider {
+export function tuiAsDataListHost<T>(useExisting: Type<TuiDataListHost<T>>): Provider {
     return {
         provide: TUI_DATA_LIST_HOST,
         useExisting,
diff --git a/projects/demo/src/modules/components/input-time/examples/6/index.html b/projects/demo/src/modules/components/input-time/examples/6/index.html
index 50c9a8681edb..96865373e900 100644
--- a/projects/demo/src/modules/components/input-time/examples/6/index.html
+++ b/projects/demo/src/modules/components/input-time/examples/6/index.html
@@ -1,39 +1,11 @@
-<form
-    class="b-form"
-    [formGroup]="testForm"
+<tui-input-time
+    [formControl]="control"
+    [items]="items"
 >
-    <tui-input-time
-        formControlName="testValue"
-        class="tui-space_bottom-3"
-        [tuiTextfieldCleaner]="true"
-    >
-        Choose a time
-    </tui-input-time>
+    Input time
+</tui-input-time>
 
-    <tui-input-time
-        formControlName="testValue2"
-        mode="HH:MM:SS"
-        class="tui-space_bottom-3"
-        [tuiTextfieldCleaner]="true"
-    >
-        Choose a time
-    </tui-input-time>
-
-    <tui-input-time
-        formControlName="testValue3"
-        class="tui-space_bottom-3"
-        [items]="items"
-        [tuiTextfieldCleaner]="true"
-    >
-        Choose a time
-    </tui-input-time>
-
-    <tui-input-time
-        formControlName="testValue4"
-        mode="HH:MM:SS"
-        [items]="items"
-        [tuiTextfieldCleaner]="true"
-    >
-        Choose a time
-    </tui-input-time>
-</form>
+<p>Stringified control value:</p>
+<p>
+    <code>{{ control.value }}</code>
+</p>
diff --git a/projects/demo/src/modules/components/input-time/examples/6/index.ts b/projects/demo/src/modules/components/input-time/examples/6/index.ts
index 82104db7c914..30d37751b24e 100644
--- a/projects/demo/src/modules/components/input-time/examples/6/index.ts
+++ b/projects/demo/src/modules/components/input-time/examples/6/index.ts
@@ -1,9 +1,22 @@
 import {Component} from '@angular/core';
-import {FormControl, FormGroup} from '@angular/forms';
+import {FormControl} from '@angular/forms';
 import {changeDetection} from '@demo/emulate/change-detection';
 import {encapsulation} from '@demo/emulate/encapsulation';
-import {TuiTime} from '@taiga-ui/cdk';
-import {tuiCreateTimePeriods, tuiInputTimeOptionsProvider} from '@taiga-ui/kit';
+import {AbstractTuiValueTransformer, TuiTime} from '@taiga-ui/cdk';
+import {TUI_TIME_VALUE_TRANSFORMER, tuiCreateTimePeriods} from '@taiga-ui/kit';
+
+class ExampleTimeTransformer extends AbstractTuiValueTransformer<
+    TuiTime | null,
+    string | null
+> {
+    fromControlValue(controlValue: string): TuiTime | null {
+        return controlValue ? TuiTime.fromString(controlValue) : null;
+    }
+
+    toControlValue(time: TuiTime | null): string {
+        return time ? time.toString() : '';
+    }
+}
 
 @Component({
     selector: 'tui-input-time-example-6',
@@ -11,18 +24,13 @@ import {tuiCreateTimePeriods, tuiInputTimeOptionsProvider} from '@taiga-ui/kit';
     encapsulation,
     changeDetection,
     providers: [
-        tuiInputTimeOptionsProvider({
-            nativePicker: true,
-        }),
+        {
+            provide: TUI_TIME_VALUE_TRANSFORMER,
+            useClass: ExampleTimeTransformer,
+        },
     ],
 })
 export class TuiInputTimeExample6 {
-    readonly testForm = new FormGroup({
-        testValue: new FormControl(new TuiTime(10, 30)),
-        testValue2: new FormControl(new TuiTime(10, 30, 0)),
-        testValue3: new FormControl(new TuiTime(14, 30)),
-        testValue4: new FormControl(new TuiTime(10, 30, 0)),
-    });
-
-    readonly items = tuiCreateTimePeriods(14, 16, [0, 30]);
+    readonly control = new FormControl('');
+    readonly items = tuiCreateTimePeriods();
 }
diff --git a/projects/demo/src/modules/components/input-time/examples/7/index.html b/projects/demo/src/modules/components/input-time/examples/7/index.html
new file mode 100644
index 000000000000..50c9a8681edb
--- /dev/null
+++ b/projects/demo/src/modules/components/input-time/examples/7/index.html
@@ -0,0 +1,39 @@
+<form
+    class="b-form"
+    [formGroup]="testForm"
+>
+    <tui-input-time
+        formControlName="testValue"
+        class="tui-space_bottom-3"
+        [tuiTextfieldCleaner]="true"
+    >
+        Choose a time
+    </tui-input-time>
+
+    <tui-input-time
+        formControlName="testValue2"
+        mode="HH:MM:SS"
+        class="tui-space_bottom-3"
+        [tuiTextfieldCleaner]="true"
+    >
+        Choose a time
+    </tui-input-time>
+
+    <tui-input-time
+        formControlName="testValue3"
+        class="tui-space_bottom-3"
+        [items]="items"
+        [tuiTextfieldCleaner]="true"
+    >
+        Choose a time
+    </tui-input-time>
+
+    <tui-input-time
+        formControlName="testValue4"
+        mode="HH:MM:SS"
+        [items]="items"
+        [tuiTextfieldCleaner]="true"
+    >
+        Choose a time
+    </tui-input-time>
+</form>
diff --git a/projects/demo/src/modules/components/input-time/examples/7/index.ts b/projects/demo/src/modules/components/input-time/examples/7/index.ts
new file mode 100644
index 000000000000..18b6be0f6e9d
--- /dev/null
+++ b/projects/demo/src/modules/components/input-time/examples/7/index.ts
@@ -0,0 +1,28 @@
+import {Component} from '@angular/core';
+import {FormControl, FormGroup} from '@angular/forms';
+import {changeDetection} from '@demo/emulate/change-detection';
+import {encapsulation} from '@demo/emulate/encapsulation';
+import {TuiTime} from '@taiga-ui/cdk';
+import {tuiCreateTimePeriods, tuiInputTimeOptionsProvider} from '@taiga-ui/kit';
+
+@Component({
+    selector: 'tui-input-time-example-7',
+    templateUrl: './index.html',
+    encapsulation,
+    changeDetection,
+    providers: [
+        tuiInputTimeOptionsProvider({
+            nativePicker: true,
+        }),
+    ],
+})
+export class TuiInputTimeExample7 {
+    readonly testForm = new FormGroup({
+        testValue: new FormControl(new TuiTime(10, 30)),
+        testValue2: new FormControl(new TuiTime(10, 30, 0)),
+        testValue3: new FormControl(new TuiTime(14, 30)),
+        testValue4: new FormControl(new TuiTime(10, 30, 0)),
+    });
+
+    readonly items = tuiCreateTimePeriods(14, 16, [0, 30]);
+}
diff --git a/projects/demo/src/modules/components/input-time/input-time.component.ts b/projects/demo/src/modules/components/input-time/input-time.component.ts
index 7974089f2508..15ccd25e4b32 100644
--- a/projects/demo/src/modules/components/input-time/input-time.component.ts
+++ b/projects/demo/src/modules/components/input-time/input-time.component.ts
@@ -65,6 +65,11 @@ export class ExampleTuiInputTimeComponent extends AbstractExampleTuiControl {
         HTML: import('./examples/6/index.html?raw'),
     };
 
+    readonly example7: TuiDocExample = {
+        TypeScript: import('./examples/7/index.ts?raw'),
+        HTML: import('./examples/7/index.html?raw'),
+    };
+
     override cleaner = false;
 
     control = new FormControl(new TuiTime(15, 30), Validators.required);
diff --git a/projects/demo/src/modules/components/input-time/input-time.module.ts b/projects/demo/src/modules/components/input-time/input-time.module.ts
index 4d91799f14ca..e97f0d9dc4b6 100644
--- a/projects/demo/src/modules/components/input-time/input-time.module.ts
+++ b/projects/demo/src/modules/components/input-time/input-time.module.ts
@@ -24,6 +24,7 @@ import {TuiInputTimeExample3} from './examples/3';
 import {TuiInputTimeExample4} from './examples/4';
 import {TuiInputTimeExample5} from './examples/5';
 import {TuiInputTimeExample6} from './examples/6';
+import {TuiInputTimeExample7} from './examples/7';
 import {ExampleTuiInputTimeComponent} from './input-time.component';
 
 @NgModule({
@@ -52,6 +53,7 @@ import {ExampleTuiInputTimeComponent} from './input-time.component';
         TuiInputTimeExample4,
         TuiInputTimeExample5,
         TuiInputTimeExample6,
+        TuiInputTimeExample7,
     ],
     exports: [ExampleTuiInputTimeComponent],
 })
diff --git a/projects/demo/src/modules/components/input-time/input-time.template.html b/projects/demo/src/modules/components/input-time/input-time.template.html
index f012e10d041b..d3bf45291307 100644
--- a/projects/demo/src/modules/components/input-time/input-time.template.html
+++ b/projects/demo/src/modules/components/input-time/input-time.template.html
@@ -4,10 +4,32 @@
     type="components"
 >
     <ng-template pageTab>
-        <p>
+        <div class="tui-space_bottom-9">
             <code>InputTime</code>
             allows input time in HH:MM format
-        </p>
+        </div>
+
+        <h3>DI-tokens for input-configurations:</h3>
+        <dl>
+            <dt>
+                <code>TUI_TIME_VALUE_TRANSFORMER</code>
+            </dt>
+            <dd>
+                custom format of control output (
+                <code>TuiTime | null</code>
+                is default).
+                <p>
+                    <a
+                        fragment="string-control-output"
+                        routerLink="."
+                        tuiLink
+                    >
+                        See example
+                    </a>
+                    with string as control's output.
+                </p>
+            </dd>
+        </dl>
 
         <tui-doc-example
             id="base"
@@ -50,10 +72,18 @@
             <tui-input-time-example-5></tui-input-time-example-5>
         </tui-doc-example>
 
+        <tui-doc-example
+            id="string-control-output"
+            heading="With control's output as string"
+            [content]="example6"
+        >
+            <tui-input-time-example-6></tui-input-time-example-6>
+        </tui-doc-example>
+
         <tui-doc-example
             id="native"
             heading="Native input time"
-            [content]="example6"
+            [content]="example7"
         >
             <tui-notification class="tui-space_bottom-5">
                 Please note that iOS Safari doesn't support native picker in modes other than HH:MM
diff --git a/projects/kit/components/input-time/input-time.component.ts b/projects/kit/components/input-time/input-time.component.ts
index a39a2976523c..0b25561be901 100644
--- a/projects/kit/components/input-time/input-time.component.ts
+++ b/projects/kit/components/input-time/input-time.component.ts
@@ -15,6 +15,7 @@ import {MaskitoOptions} from '@maskito/core';
 import {maskitoTimeOptionsGenerator} from '@maskito/kit';
 import {
     AbstractTuiNullableControl,
+    AbstractTuiValueTransformer,
     ALWAYS_FALSE_HANDLER,
     TUI_IS_IOS,
     TUI_IS_MOBILE,
@@ -23,6 +24,7 @@ import {
     tuiAsFocusableItemAccessor,
     TuiBooleanHandler,
     TuiFocusableElementAccessor,
+    TuiIdentityMatcher,
     tuiIsElement,
     tuiIsInput,
     tuiIsNativeFocused,
@@ -43,7 +45,7 @@ import {
 } from '@taiga-ui/core';
 import {TUI_SELECT_OPTION} from '@taiga-ui/kit/components/select-option';
 import {FIXED_DROPDOWN_CONTROLLER_PROVIDER} from '@taiga-ui/kit/providers';
-import {TUI_TIME_TEXTS} from '@taiga-ui/kit/tokens';
+import {TUI_TIME_TEXTS, TUI_TIME_VALUE_TRANSFORMER} from '@taiga-ui/kit/tokens';
 import {Observable, timer} from 'rxjs';
 import {map, takeUntil} from 'rxjs/operators';
 
@@ -104,8 +106,11 @@ export class TuiInputTimeComponent
         @Inject(TUI_IS_IOS) private readonly isIos: boolean,
         @Inject(TUI_TEXTFIELD_SIZE)
         private readonly textfieldSize: TuiTextfieldSizeDirective,
+        @Optional()
+        @Inject(TUI_TIME_VALUE_TRANSFORMER)
+        override readonly valueTransformer: AbstractTuiValueTransformer<TuiTime | null> | null,
     ) {
-        super(control, cdr);
+        super(control, cdr, valueTransformer);
     }
 
     @HostBinding('attr.data-size')
@@ -190,6 +195,13 @@ export class TuiInputTimeComponent
         this.open = !this.open;
     }
 
+    readonly identityMatcher: TuiIdentityMatcher<TuiTime> = (
+        controlValue: TuiTime,
+        dropdownValue: TuiTime,
+    ) =>
+        controlValue instanceof TuiTime &&
+        controlValue.valueOf() === dropdownValue.valueOf();
+
     onValueChange(value: string): void {
         this.open = !!this.items.length;
 
diff --git a/projects/kit/components/input-time/test/input-time.component.spec.ts b/projects/kit/components/input-time/test/input-time.component.spec.ts
index c96e7d7bf606..62a861d80532 100644
--- a/projects/kit/components/input-time/test/input-time.component.spec.ts
+++ b/projects/kit/components/input-time/test/input-time.component.spec.ts
@@ -1,9 +1,9 @@
-import {Component, DebugElement, ViewChild} from '@angular/core';
+import {Component, DebugElement, Type, ViewChild} from '@angular/core';
 import {ComponentFixture, TestBed} from '@angular/core/testing';
 import {FormControl, ReactiveFormsModule} from '@angular/forms';
 import {By} from '@angular/platform-browser';
 import {NoopAnimationsModule} from '@angular/platform-browser/animations';
-import {TuiTime} from '@taiga-ui/cdk';
+import {AbstractTuiValueTransformer, TuiTime} from '@taiga-ui/cdk';
 import {
     TuiHintModule,
     TuiRootModule,
@@ -12,6 +12,7 @@ import {
     TuiTextfieldControllerModule,
 } from '@taiga-ui/core';
 import {TuiInputTimeComponent, TuiInputTimeModule} from '@taiga-ui/kit';
+import {TUI_TIME_VALUE_TRANSFORMER} from '@taiga-ui/kit/tokens';
 import {tuiCreateKeyboardEvent, TuiNativeInputPO, TuiPageObject} from '@taiga-ui/testing';
 
 const TIMES = [
@@ -66,20 +67,22 @@ describe('InputTime', () => {
         return pageObject.getByAutomationId('tui-input-time__dropdown');
     }
 
-    beforeEach(async () => {
-        TestBed.configureTestingModule({
-            imports: [
-                TuiRootModule,
-                TuiInputTimeModule,
-                ReactiveFormsModule,
-                NoopAnimationsModule,
-                TuiTextfieldControllerModule,
-                TuiHintModule,
-            ],
-            declarations: [TestComponent],
-        });
-        await TestBed.compileComponents();
-        fixture = TestBed.createComponent(TestComponent);
+    const meta = {
+        imports: [
+            TuiRootModule,
+            TuiInputTimeModule,
+            ReactiveFormsModule,
+            NoopAnimationsModule,
+            TuiTextfieldControllerModule,
+            TuiHintModule,
+        ],
+        declarations: [TestComponent],
+    };
+
+    const initializeEnvironment = async (
+        componentType: Type<TestComponent> = TestComponent,
+    ): Promise<void> => {
+        fixture = TestBed.createComponent(componentType);
         pageObject = new TuiPageObject(fixture);
         testComponent = fixture.componentInstance;
         fixture.detectChanges();
@@ -88,22 +91,31 @@ describe('InputTime', () => {
         input = fixture.debugElement.query(By.css('input')).nativeElement;
         await fixture.whenStable();
         fixture.detectChanges();
-    });
+    };
 
     describe('Initial value', () => {
+        beforeEach(async () => {
+            TestBed.configureTestingModule(meta);
+            await TestBed.compileComponents();
+            await initializeEnvironment();
+        });
+
         it('The value in the field is formatted by mask', async () => {
             await fixture.whenStable();
             expect(input.value).toBe('12:30');
         });
 
         it('The initial value in the formControl is issued as an object with the hours and minutes properties', () => {
-            expect(testComponent.control.value.hours).toBe(12);
-            expect(testComponent.control.value.minutes).toBe(30);
+            expect(testComponent.control.value).toEqual(new TuiTime(12, 30));
         });
     });
 
     describe('The value in the formControl changes outside', () => {
-        beforeEach(() => {
+        beforeEach(async () => {
+            TestBed.configureTestingModule(meta);
+            await TestBed.compileComponents();
+            await initializeEnvironment();
+
             testComponent.control.setValue(new TuiTime(22, 30));
             fixture.detectChanges();
         });
@@ -122,6 +134,12 @@ describe('InputTime', () => {
     });
 
     describe('Short time input (less than 5 characters, including colon)', () => {
+        beforeEach(async () => {
+            TestBed.configureTestingModule(meta);
+            await TestBed.compileComponents();
+            await initializeEnvironment();
+        });
+
         it('The value of formControl is passed null', () => {
             component.onValueChange('11:1');
             fixture.detectChanges();
@@ -130,7 +148,13 @@ describe('InputTime', () => {
     });
 
     describe('Keyboard control', () => {
-        beforeEach(async () => fixture.whenStable());
+        beforeEach(async () => {
+            TestBed.configureTestingModule(meta);
+            await TestBed.compileComponents();
+            await initializeEnvironment();
+
+            await fixture.whenStable();
+        });
 
         it('If the cursor is at position 0, then pressing UP increases the hour by 1', () => {
             input.focus();
@@ -188,7 +212,11 @@ describe('InputTime', () => {
     });
 
     describe('Drop-down list', () => {
-        beforeEach(() => {
+        beforeEach(async () => {
+            TestBed.configureTestingModule(meta);
+            await TestBed.compileComponents();
+            await initializeEnvironment();
+
             testComponent.items = TIMES;
 
             fixture.detectChanges();
@@ -277,4 +305,75 @@ describe('InputTime', () => {
             });
         });
     });
+
+    describe('InputTime + TUI_TIME_VALUE_TRANSFORMER', () => {
+        class TestTransformer extends AbstractTuiValueTransformer<
+            TuiTime | null,
+            string
+        > {
+            fromControlValue(controlValue: string): TuiTime | null {
+                return controlValue ? TuiTime.fromString(controlValue) : null;
+            }
+
+            toControlValue(componentValue: TuiTime | null): string {
+                return componentValue ? componentValue.toString() : '';
+            }
+        }
+
+        class TransformerTestComponent extends TestComponent {
+            override control = new FormControl('12:30');
+        }
+
+        beforeEach(async () => {
+            TestBed.configureTestingModule({
+                ...meta,
+                providers: [
+                    {provide: TUI_TIME_VALUE_TRANSFORMER, useClass: TestTransformer},
+                ],
+                declarations: [TransformerTestComponent],
+            });
+            await TestBed.compileComponents();
+            await initializeEnvironment(TransformerTestComponent);
+
+            testComponent.items = TIMES;
+            fixture.detectChanges();
+        });
+
+        it('correctly transforms initial value', () => {
+            expect(inputPO.value).toBe('12:30');
+            expect(testComponent.control.value).toBe('12:30');
+        });
+
+        it('transforms typed value', () => {
+            inputPO.sendText('12:00');
+            fixture.detectChanges();
+
+            expect(inputPO.value).toBe('12:00');
+            expect(testComponent.control.value).toBe('12:00');
+        });
+
+        it('transforms empty value', () => {
+            inputPO.sendText('');
+            fixture.detectChanges();
+
+            expect(testComponent.control.value).toBe('');
+        });
+
+        it('transforms selected value', () => {
+            inputPO.sendText('03');
+            fixture.detectChanges();
+            pageObject.getByAutomationId('tui-input-time__item')!.nativeElement.click();
+
+            expect(testComponent.control.value).toBe('03:00');
+        });
+
+        it('selected item has check mark', () => {
+            inputPO.sendText('03:00');
+            fixture.detectChanges();
+
+            expect(
+                pageObject.getByAutomationId('tui-select-option__checkmark'),
+            ).toBeTruthy();
+        });
+    });
 });
diff --git a/projects/kit/tokens/date-inputs-value-transformers.ts b/projects/kit/tokens/date-inputs-value-transformers.ts
index a47fbfe35978..f3d1599bcf3f 100644
--- a/projects/kit/tokens/date-inputs-value-transformers.ts
+++ b/projects/kit/tokens/date-inputs-value-transformers.ts
@@ -23,3 +23,10 @@ export const TUI_DATE_RANGE_VALUE_TRANSFORMER = new InjectionToken<
 export const TUI_DATE_TIME_VALUE_TRANSFORMER = new InjectionToken<
     TuiControlValueTransformer<[TuiDay | null, TuiTime | null]>
 >('[TUI_DATE_TIME_VALUE_TRANSFORMER]');
+
+/**
+ * Control value transformer for InputTime component
+ */
+export const TUI_TIME_VALUE_TRANSFORMER = new InjectionToken<
+    TuiControlValueTransformer<TuiTime | null>
+>('[TUI_TIME_VALUE_TRANSFORMER]');